diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 45d81ebc7..c43b31bcd 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -13,7 +13,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 33 - versionCode 90 + versionCode 92 versionName "2.4.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationRequests.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationRequests.java new file mode 100644 index 000000000..401f47f48 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationRequests.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.notifications; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.NotificationRequest; + +public class GetNotificationRequests extends HeaderPaginationRequest{ + public GetNotificationRequests(String maxID){ + super(HttpMethod.GET, "/notifications/requests", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java index 3c5a162fb..19b4c8adc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.api.requests.notifications; -import com.google.gson.annotations.SerializedName; +import android.text.TextUtils; + import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.ApiUtils; @@ -12,6 +13,10 @@ import java.util.List; public class GetNotifications extends MastodonAPIRequest>{ public GetNotifications(String maxID, int limit, EnumSet includeTypes){ + this(maxID, limit, includeTypes, null); + } + + public GetNotifications(String maxID, int limit, EnumSet includeTypes, String onlyAccountID){ super(HttpMethod.GET, "/notifications", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); @@ -25,6 +30,8 @@ public class GetNotifications extends MastodonAPIRequest>{ addQueryParameter("exclude_types[]", type); } } + if(!TextUtils.isEmpty(onlyAccountID)) + addQueryParameter("account_id", onlyAccountID); removeUnsupportedItems=true; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationsPolicy.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationsPolicy.java new file mode 100644 index 000000000..078a2d9c0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotificationsPolicy.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.notifications; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.NotificationsPolicy; + +public class GetNotificationsPolicy extends MastodonAPIRequest{ + public GetNotificationsPolicy(){ + super(HttpMethod.GET, "/notifications/policy", NotificationsPolicy.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RespondToNotificationRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RespondToNotificationRequest.java new file mode 100644 index 000000000..7aed89a38 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RespondToNotificationRequest.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.notifications; + +import org.joinmastodon.android.api.ResultlessMastodonAPIRequest; + +public class RespondToNotificationRequest extends ResultlessMastodonAPIRequest{ + public RespondToNotificationRequest(String id, boolean allow){ + super(HttpMethod.POST, "/notifications/requests/"+id+(allow ? "/accept" : "/dismiss")); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/SetNotificationsPolicy.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/SetNotificationsPolicy.java new file mode 100644 index 000000000..63f27ab45 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/SetNotificationsPolicy.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.notifications; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.NotificationsPolicy; + +public class SetNotificationsPolicy extends MastodonAPIRequest{ + public SetNotificationsPolicy(NotificationsPolicy policy){ + super(HttpMethod.PUT, "/notifications/policy", NotificationsPolicy.class); + setRequestBody(policy); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationRequestRespondedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationRequestRespondedEvent.java new file mode 100644 index 000000000..8662ccad9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationRequestRespondedEvent.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.events; + +public class NotificationRequestRespondedEvent{ + public final String accountID, requestID; + + public NotificationRequestRespondedEvent(String accountID, String requestID){ + this.accountID=accountID; + this.requestID=requestID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountNotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountNotificationsListFragment.java new file mode 100644 index 000000000..5101f35ba --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountNotificationsListFragment.java @@ -0,0 +1,181 @@ +package org.joinmastodon.android.fragments; + +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.TextView; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.notifications.GetNotifications; +import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest; +import org.joinmastodon.android.events.NotificationRequestRespondedEvent; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.ui.Snackbar; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.SingleViewRecyclerAdapter; + +public class AccountNotificationsListFragment extends BaseNotificationsListFragment{ + private Account account; + private String requestID; + private TextView expandedTitle; + private boolean choiceMade, allowed; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + requestID=getArguments().getString("requestID"); + setTitleMarqueeEnabled(false); + loadData(); + setTitle(getString(R.string.notifications_from_user, account.displayName)); + setHasOptionsMenu(true); + } + + @Override + protected void doLoadData(int offset, int count){ + if(!refreshing && endMark!=null) + endMark.setVisibility(View.GONE); + currentRequest=new GetNotifications(offset==0 ? null : maxID, count, EnumSet.allOf(Notification.Type.class), account.id) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result, !result.isEmpty()); + maxID=result.isEmpty() ? null : result.get(result.size()-1).id; + endMark.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE); + } + }) + .exec(accountID); + } + + @Override + protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){ + return super.needDividerForExtraItem(child, bottomSibling, holder, siblingHolder) || (siblingHolder!=null && siblingHolder.getAbsoluteAdapterPosition()>=list.getAdapter().getItemCount()); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + + expandedTitle=(TextView) LayoutInflater.from(getActivity()).inflate(R.layout.expanded_title_medium, list, false); + expandedTitle.setText(getTitle()); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(expandedTitle)); + + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + if(recyclerView.getChildCount()==0) + return; + float fraction; + View topChild=recyclerView.getChildAt(0); + if(recyclerView.getChildAdapterPosition(topChild)>0){ + fraction=1; + }else{ + fraction=(-topChild.getTop())/(float)(topChild.getHeight()-topChild.getPaddingBottom()); + } + expandedTitle.setAlpha(1f-fraction); + toolbarTitleView.setAlpha(fraction); + } + }); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + inflater.inflate(R.menu.notification_request, menu); + MenuItem mute=menu.findItem(R.id.mute); + MenuItem allow=menu.findItem(R.id.allow); + if(choiceMade && allowed){ + allow.setIcon(R.drawable.ic_check_wght700_24px); + tintMenuIcon(allow, R.attr.colorM3Primary); + }else{ + tintMenuIcon(allow, R.attr.colorM3OnSurfaceVariant); + } + if(choiceMade && !allowed){ + mute.setIcon(R.drawable.ic_volume_off_wght700_24px); + tintMenuIcon(mute, R.attr.colorM3Primary); + }else{ + tintMenuIcon(mute, R.attr.colorM3OnSurfaceVariant); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(choiceMade) + return true; + allowed=item.getItemId()==R.id.allow; + new RespondToNotificationRequest(requestID, allowed) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + choiceMade=true; + invalidateOptionsMenu(); + E.post(new NotificationRequestRespondedEvent(accountID, requestID)); + new Snackbar.Builder(getActivity()) + .setText(getString(allowed ? R.string.notifications_allowed : R.string.notifications_muted, account.displayName)) + .show(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, false) + .exec(accountID); + return true; + } + + @Override + protected List buildDisplayItems(Notification n){ + if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ + return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN); + } + return super.buildDisplayItems(n); + } + + @Override + protected boolean wantsToolbarMenuIconsTinted(){ + return false; + } + + private void tintMenuIcon(MenuItem item, int color){ + int tintColor=UiUtils.getThemeColor(getActivity(), color); + if(Build.VERSION.SDK_INT defaultFilter; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseNotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseNotificationsListFragment.java new file mode 100644 index 000000000..ab9420294 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseNotificationsListFragment.java @@ -0,0 +1,120 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; + +public abstract class BaseNotificationsListFragment extends BaseStatusListFragment{ + protected String maxID; + protected View endMark; + + @Override + protected List buildDisplayItems(Notification n){ + NotificationHeaderStatusDisplayItem titleItem; + if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ + titleItem=null; + }else{ + titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); + if(n.status!=null){ + n.status.card=null; + n.status.spoilerText=null; + } + } + if(n.status!=null){ + int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER); + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags); + if(titleItem!=null) + items.add(0, titleItem); + return items; + }else if(titleItem!=null){ + return Collections.singletonList(titleItem); + }else{ + return Collections.emptyList(); + } + } + + @Override + protected void addAccountToKnown(Notification s){ + if(!knownAccounts.containsKey(s.account.id)) + knownAccounts.put(s.account.id, s.account); + if(s.status!=null && !knownAccounts.containsKey(s.status.account.id)) + knownAccounts.put(s.status.account.id, s.status.account); + } + + @Override + public void onItemClick(String id){ + Notification n=getNotificationByID(id); + if(n.status!=null){ + Status status=n.status; + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(status.clone())); + if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId)) + args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId))); + Nav.go(getActivity(), ThreadFragment.class, args); + }else{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(n.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + } + + private Notification getNotificationByID(String id){ + for(Notification n : data){ + if(n.id.equals(id)) + return n; + } + return null; + } + + protected void removeNotification(Notification n){ + data.remove(n); + preloadedData.remove(n); + int index=-1; + for(int i=0; i exten toolbar.setNavigationContentDescription(R.string.back); } - protected int getMainAdapterOffset(){ + public int getMainAdapterOffset(){ if(list.getAdapter() instanceof MergeRecyclerAdapter mergeAdapter){ return mergeAdapter.getPositionForAdapter(adapter); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 542bf5ee2..8fdbb49a8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -34,7 +34,6 @@ import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; @@ -155,7 +154,7 @@ public class HashtagTimelineFragment extends StatusListFragment{ } @Override - protected int getMainAdapterOffset(){ + public int getMainAdapterOffset(){ return 1; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationRequestsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationRequestsFragment.java new file mode 100644 index 000000000..ba46edf39 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationRequestsFragment.java @@ -0,0 +1,250 @@ +package org.joinmastodon.android.fragments; + +import android.annotation.SuppressLint; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.notifications.GetNotificationRequests; +import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest; +import org.joinmastodon.android.events.NotificationRequestRespondedEvent; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.NotificationRequest; +import org.joinmastodon.android.model.viewmodel.AccountViewModel; +import org.joinmastodon.android.ui.BetterItemAnimator; +import org.joinmastodon.android.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.Snackbar; +import org.parceler.Parcels; + +import java.util.HashMap; +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.views.UsableRecyclerView; + +public class NotificationRequestsFragment extends MastodonRecyclerFragment{ + private String accountID; + private String maxID; + private HashMap accountViewModels=new HashMap<>(); + private View endMark; + private NotificationRequestsAdapter adapter; + + public NotificationRequestsFragment(){ + super(50); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + setTitle(R.string.filtered_notifications); + loadData(); + E.register(this); + } + + @Override + public void onDestroy(){ + E.unregister(this); + super.onDestroy(); + } + + @Override + protected void doLoadData(int offset, int count){ + if(!refreshing && endMark!=null) + endMark.setVisibility(View.GONE); + currentRequest=new GetNotificationRequests(offset==0 ? null : maxID) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(data.isEmpty() || refreshing) + accountViewModels.clear(); + maxID=result.getNextPageMaxID(); + for(NotificationRequest req:result){ + accountViewModels.put(req.account.id, new AccountViewModel(req.account, accountID, false)); + } + onDataLoaded(result, !TextUtils.isEmpty(maxID)); + endMark.setVisibility(TextUtils.isEmpty(maxID) ? View.VISIBLE : View.GONE); + } + }) + .exec(accountID); + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + return adapter=new NotificationRequestsAdapter(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + list.setItemAnimator(new BetterItemAnimator()); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof NotificationRequestViewHolder).setDrawBelowLastItem(true)); + } + + @Override + protected View onCreateFooterView(LayoutInflater inflater){ + View v=inflater.inflate(R.layout.load_more_with_end_mark, null); + endMark=v.findViewById(R.id.end_mark); + endMark.setVisibility(View.GONE); + return v; + } + + @Subscribe + public void onNotificationRequestResponded(NotificationRequestRespondedEvent ev){ + if(adapter==null || !ev.accountID.equals(accountID)) + return; + for(int i=0;i implements ImageLoaderRecyclerAdapter{ + + public NotificationRequestsAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public NotificationRequestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new NotificationRequestViewHolder(); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public void onBindViewHolder(NotificationRequestViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getImageCountForItem(int position){ + return Objects.requireNonNull(accountViewModels.get(data.get(position).account.id)).emojiHelper.getImageCount()+1; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + AccountViewModel model=Objects.requireNonNull(accountViewModels.get(data.get(position).account.id)); + return switch(image){ + case 0 -> model.avaRequest; + default -> model.emojiHelper.getImageRequest(image-1); + }; + } + } + + private class NotificationRequestViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + private final TextView name, username, badge; + private final ImageView ava; + private final ImageButton allow, mute; + + public NotificationRequestViewHolder(){ + super(getActivity(), R.layout.item_notification_request, list); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + badge=findViewById(R.id.badge); + ava=findViewById(R.id.ava); + allow=findViewById(R.id.btn_allow); + mute=findViewById(R.id.btn_mute); + ava.setOutlineProvider(OutlineProviders.roundedRect(8)); + ava.setClipToOutline(true); + allow.setOnClickListener(this::onAllowClick); + mute.setOnClickListener(this::onMuteClick); + } + + @SuppressLint("DefaultLocale") + @Override + public void onBind(NotificationRequest item){ + AccountViewModel model=Objects.requireNonNull(accountViewModels.get(item.account.id)); + name.setText(model.parsedName); + username.setText(item.account.getDisplayUsername()); + badge.setText(item.notificationsCount>99 ? String.format("%d+", 99) : String.format("%d", item.notificationsCount)); + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + if(image==null) + ava.setImageResource(R.drawable.image_placeholder); + else + ava.setImageDrawable(image); + }else{ + AccountViewModel model=Objects.requireNonNull(accountViewModels.get(item.account.id)); + model.emojiHelper.setImageDrawable(index-1, image); + name.invalidate(); + } + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("targetAccount", Parcels.wrap(item.account)); + args.putString("requestID", item.id); + Nav.go(getActivity(), AccountNotificationsListFragment.class, args); + } + + private void onAllowClick(View v){ + acceptOrDecline(true); + } + + private void onMuteClick(View v){ + acceptOrDecline(false); + } + + private void acceptOrDecline(boolean accept){ + new RespondToNotificationRequest(item.id, accept) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Void result){ + int pos=data.indexOf(item); + data.remove(pos); + adapter.notifyItemRemoved(pos); + new Snackbar.Builder(getActivity()) + .setText(getString(accept ? R.string.notifications_allowed : R.string.notifications_muted, item.account.displayName)) + .show(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, false) + .exec(accountID); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index f51dc6842..3e124c3eb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -1,57 +1,70 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.os.Bundle; import android.text.TextUtils; -import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.widget.Button; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.notifications.GetNotificationsPolicy; +import org.joinmastodon.android.api.requests.notifications.SetNotificationsPolicy; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.NotificationsPolicy; import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; +import org.joinmastodon.android.model.viewmodel.ListItem; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.OutlineProviders; -import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem; +import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.viewcontrollers.GenericListItemsViewController; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.utils.ObjectIdComparator; -import org.parceler.Parcels; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.MergeRecyclerAdapter; -public class NotificationsListFragment extends BaseStatusListFragment{ +public class NotificationsListFragment extends BaseNotificationsListFragment{ private boolean onlyMentions; - private String maxID; private View tabBar; private View mentionsTab, allTab; - private View endMark; private String unreadMarker, realUnreadMarker; private MenuItem markAllReadItem; private boolean reloadingFromCache; + private ListItem requestsItem=new ListItem<>(R.string.filtered_notifications, 0, R.drawable.ic_inventory_2_24px, i->openNotificationRequests()); + private ArrayList> requestsItems=new ArrayList<>(); + private GenericListItemsAdapter requestsRowAdapter=new GenericListItemsAdapter<>(requestsItems); + private NotificationsPolicy lastPolicy; @Override public void onCreate(Bundle savedInstanceState){ @@ -74,43 +87,12 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ - NotificationHeaderStatusDisplayItem titleItem; - if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ - titleItem=null; - }else{ - titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); - if(n.status!=null){ - n.status.card=null; - n.status.spoilerText=null; - } - } - if(n.status!=null){ - int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER); - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags); - if(titleItem!=null) - items.add(0, titleItem); - return items; - }else if(titleItem!=null){ - return Collections.singletonList(titleItem); - }else{ - return Collections.emptyList(); - } - } - - @Override - protected void addAccountToKnown(Notification s){ - if(!knownAccounts.containsKey(s.account.id)) - knownAccounts.put(s.account.id, s.account); - if(s.status!=null && !knownAccounts.containsKey(s.status.account.id)) - knownAccounts.put(s.status.account.id, s.status.account); - } - @Override protected void doLoadData(int offset, int count){ if(!refreshing && !reloadingFromCache) endMark.setVisibility(View.GONE); + if(offset==0) + reloadPolicy(); AccountSessionManager.getInstance() .getAccount(accountID).getCacheController() .getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ @@ -142,30 +124,10 @@ public class NotificationsListFragment extends BaseStatusListFragment=adapter.getItemCount()) || holder.getAbsoluteAdapterPosition()=adapter.getItemCount()); - } - @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.notifications, menu); markAllReadItem=menu.findItem(R.id.mark_all_read); + MenuItem filters=menu.findItem(R.id.filters); + filters.setVisible(lastPolicy!=null); } @Override public boolean onOptionsItemSelected(MenuItem item){ - if(item.getItemId()==R.id.mark_all_read){ + int id=item.getItemId(); + if(id==R.id.mark_all_read){ markAsRead(); resetUnreadBackground(); + }else if(id==R.id.filters){ + showFiltersAlert(); } return true; } + @Override + protected RecyclerView.Adapter getAdapter(){ + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(requestsRowAdapter); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + private void markAsRead(){ if(data.isEmpty()) return; @@ -366,4 +304,93 @@ public class NotificationsListFragment extends BaseStatusListFragment0; + if(isShown && !needShow){ + requestsItems.clear(); + requestsRowAdapter.notifyItemRemoved(0); + }else if(!isShown && needShow){ + requestsItem.subtitle=getResources().getQuantityString(R.plurals.x_people_you_may_know, count, count); + requestsItems.add(requestsItem); + requestsRowAdapter.notifyItemInserted(0); + }else if(isShown){ + requestsItem.subtitle=getResources().getQuantityString(R.plurals.x_people_you_may_know, count, count); + requestsRowAdapter.notifyItemChanged(0); + } + lastPolicy=policy; + invalidateOptionsMenu(); + } + + private void reloadPolicy(){ + new GetNotificationsPolicy() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(NotificationsPolicy policy){ + updatePolicy(policy); + } + + @Override + public void onError(ErrorResponse errorResponse){ + + } + }) + .exec(accountID); + } + + private void showFiltersAlert(){ + GenericListItemsViewController controller=new GenericListItemsViewController<>(getActivity()); + Consumer> toggler=item->{ + item.toggle(); + controller.rebindItem(item); + }; + CheckableListItem followingItem, followersItem, newAccountsItem, mentionsItem; + List> items=List.of( + followingItem=new CheckableListItem<>(R.string.notification_filter_following, R.string.notification_filter_following_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNotFollowing, toggler, true), + followersItem=new CheckableListItem<>(R.string.notification_filter_followers, R.string.notification_filter_followers_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNotFollowers, toggler, true), + newAccountsItem=new CheckableListItem<>(R.string.notification_filter_new_accounts, R.string.notification_filter_new_accounts_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterNewAccounts, toggler, true), + mentionsItem=new CheckableListItem<>(R.string.notification_filter_mentions, R.string.notification_filter_mentions_explanation, CheckableListItem.Style.CHECKBOX, lastPolicy.filterPrivateMentions, toggler, true) + ); + controller.setItems(items); + AlertDialog dlg=new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.filter_notifications) + .setView(controller.getView()) + .setPositiveButton(R.string.save, null) + .show(); + Button btn=dlg.getButton(Dialog.BUTTON_POSITIVE); + btn.setOnClickListener(v->{ + UiUtils.showProgressForAlertButton(btn, true); + NotificationsPolicy newPolicy=new NotificationsPolicy(); + newPolicy.filterNotFollowing=followingItem.checked; + newPolicy.filterNotFollowers=followersItem.checked; + newPolicy.filterNewAccounts=newAccountsItem.checked; + newPolicy.filterPrivateMentions=mentionsItem.checked; + new SetNotificationsPolicy(newPolicy) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(NotificationsPolicy policy){ + updatePolicy(policy); + dlg.dismiss(); + } + + @Override + public void onError(ErrorResponse errorResponse){ + Activity activity=getActivity(); + if(activity==null) + return; + UiUtils.showProgressForAlertButton(btn, false); + errorResponse.showToast(activity); + } + }) + .exec(accountID); + }); + } + + private void openNotificationRequests(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), NotificationRequestsFragment.class, args); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java index db46823ea..2fb440d14 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java @@ -134,7 +134,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ } @Override - protected int getMainAdapterOffset(){ + public int getMainAdapterOffset(){ return 1; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationRequest.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationRequest.java new file mode 100644 index 000000000..4a5931a03 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationRequest.java @@ -0,0 +1,27 @@ +package org.joinmastodon.android.model; + +import org.joinmastodon.android.api.ObjectValidationException; +import org.joinmastodon.android.api.RequiredField; + +import java.time.Instant; + +public class NotificationRequest extends BaseModel{ + @RequiredField + public String id; + @RequiredField + public Instant createdAt; + @RequiredField + public Instant updatedAt; + public int notificationsCount; + @RequiredField + public Account account; + public Status lastStatus; + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + account.postprocess(); + if(lastStatus!=null) + lastStatus.postprocess(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationsPolicy.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationsPolicy.java new file mode 100644 index 000000000..6a54977b1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationsPolicy.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.model; + +public class NotificationsPolicy extends BaseModel{ + public boolean filterNewAccounts; + public boolean filterNotFollowers; + public boolean filterNotFollowing; + public boolean filterPrivateMentions; + public Summary summary; + + public static class Summary{ + public int pendingNotificationsCount; + public int pendingRequestsCount; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java index 3860e0ec2..978c1d611 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java @@ -25,6 +25,10 @@ public class AccountViewModel{ public final String verifiedLink; public AccountViewModel(Account account, String accountID){ + this(account, accountID, true); + } + + public AccountViewModel(Account account, String accountID, boolean needBio){ this.account=account; avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50)); emojiHelper=new CustomEmojiHelper(); @@ -32,9 +36,13 @@ public class AccountViewModel{ parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis); else parsedName=account.displayName; - parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName); - ssb.append(parsedBio); + if(needBio){ + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); + ssb.append(parsedBio); + }else{ + parsedBio=null; + } emojiHelper.setText(ssb); String verifiedLink=null; for(AccountField fld:account.fields){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/DividerItemDecoration.java b/mastodon/src/main/java/org/joinmastodon/android/ui/DividerItemDecoration.java index 5be3be6c9..fb875805a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/DividerItemDecoration.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/DividerItemDecoration.java @@ -35,8 +35,9 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{ this.drawDividerPredicate=drawDividerPredicate; } - public void setDrawBelowLastItem(boolean drawBelowLastItem){ + public DividerItemDecoration setDrawBelowLastItem(boolean drawBelowLastItem){ this.drawBelowLastItem=drawBelowLastItem; + return this; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index eca371609..52246a628 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -151,10 +151,12 @@ public abstract class StatusDisplayItem{ if(!imageAttachments.isEmpty()){ PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments); MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent); - if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0) + if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0){ mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden); - else if(statusForContent.sensitive && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia) + mediaGrid.sensitiveRevealed=false; + }else if(statusForContent.sensitive && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia){ mediaGrid.sensitiveRevealed=true; + } contentItems.add(mediaGrid); } for(Attachment att:statusForContent.mediaAttachments){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java index b63f49d86..5fd914139 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/InsetStatusItemDecoration.java @@ -37,7 +37,7 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{ for(int i=0; i sdi) && sdi.getItem().inset; if(inset){ if(rect.isEmpty()){ @@ -82,7 +82,7 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{ RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); if(holder instanceof StatusDisplayItem.Holder sdi){ boolean inset=sdi.getItem().inset; - int pos=holder.getAbsoluteAdapterPosition(); + int pos=holder.getAbsoluteAdapterPosition()-listFragment.getMainAdapterOffset(); if(inset){ boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset; boolean bottomSiblingInset=pos{ + private UsableRecyclerView list; + private List> items; + private GenericListItemsAdapter adapter; + private Context context; + + public GenericListItemsViewController(Context context, List> items){ + this.context=context; + setItems(items); + } + + public GenericListItemsViewController(Context context){ + this.context=context; + } + + public void setItems(List> items){ + if(this.items!=null) + throw new IllegalStateException("items already set"); + this.items=items; + adapter=new GenericListItemsAdapter<>(items); + list=new UsableRecyclerView(context); + list.setLayoutManager(new LinearLayoutManager(context)); + list.setAdapter(adapter); + list.addItemDecoration(new DividerItemDecoration(context, R.attr.colorM3OutlineVariant, 1, 16, 16, vh->(vh instanceof SimpleListItemViewHolder ivh && ivh.getItem().dividerAfter) || (vh instanceof CheckableListItemViewHolder cvh && cvh.getItem().dividerAfter))); + list.setItemAnimator(new BetterItemAnimator()); + } + + public GenericListItemsAdapter getAdapter(){ + return adapter; + } + + public View getView(){ + return list; + } + + public void rebindItem(ListItem item){ + if(list.findViewHolderForAdapterPosition(items.indexOf(item)) instanceof ListItemViewHolder holder){ + holder.rebind(); + } + } +} diff --git a/mastodon/src/main/res/color/action_bar_icons.xml b/mastodon/src/main/res/color/action_bar_icons.xml index fbb09a8a0..88c473c0a 100644 --- a/mastodon/src/main/res/color/action_bar_icons.xml +++ b/mastodon/src/main/res/color/action_bar_icons.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_ava_badge.xml b/mastodon/src/main/res/drawable/bg_ava_badge.xml new file mode 100644 index 000000000..666f0b05b --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_ava_badge.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_check_wght700_24px.xml b/mastodon/src/main/res/drawable/ic_check_wght700_24px.xml new file mode 100644 index 000000000..0dfa6dca7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_check_wght700_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_inventory_2_24px.xml b/mastodon/src/main/res/drawable/ic_inventory_2_24px.xml new file mode 100644 index 000000000..a6f18c2b3 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_inventory_2_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_tune_24px.xml b/mastodon/src/main/res/drawable/ic_tune_24px.xml new file mode 100644 index 000000000..55fa6f483 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_tune_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_volume_off_wght700_24px.xml b/mastodon/src/main/res/drawable/ic_volume_off_wght700_24px.xml new file mode 100644 index 000000000..de45cef28 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_volume_off_wght700_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/expanded_title_medium.xml b/mastodon/src/main/res/layout/expanded_title_medium.xml new file mode 100644 index 000000000..08c0e7a34 --- /dev/null +++ b/mastodon/src/main/res/layout/expanded_title_medium.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_notification_request.xml b/mastodon/src/main/res/layout/item_notification_request.xml new file mode 100644 index 000000000..0e4fae16f --- /dev/null +++ b/mastodon/src/main/res/layout/item_notification_request.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/notification_request.xml b/mastodon/src/main/res/menu/notification_request.xml new file mode 100644 index 000000000..e29a33b88 --- /dev/null +++ b/mastodon/src/main/res/menu/notification_request.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/notifications.xml b/mastodon/src/main/res/menu/notifications.xml index 33b2b4490..3b91c5cec 100644 --- a/mastodon/src/main/res/menu/notifications.xml +++ b/mastodon/src/main/res/menu/notifications.xml @@ -1,4 +1,13 @@ - + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 34667fa9f..df4a70b6d 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + Mastodon Log in @@ -728,4 +728,23 @@ Mute conversation Unmute conversation Quiet public + Filtered notifications + Filter out notifications from... + People you don’t follow + Until you manually approve them + People not following you + Including people who have been following you fewer than 3 days + New accounts + Created within the past 30 days + Unsolicited private mentions + Filtered unless it’s in reply to your own mention or if you follow the sender + Allow notifications + Mute notifications + + %,d person you may know + %,d people you may know + + Notifications from %s + Notifications from %s will be muted. + %s will now appear in your notification list. \ No newline at end of file diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml index 6b5121b61..8f26478fe 100644 --- a/mastodon/src/main/res/values/styles.xml +++ b/mastodon/src/main/res/values/styles.xml @@ -215,6 +215,8 @@