From 68d6a4ab25685005e5453b60e02ebe214736bb21 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sun, 6 Dec 2020 01:12:25 +0300 Subject: [PATCH] Support Mastodon notification bell and status notification type --- .../keylesspalace/tusky/AccountActivity.kt | 27 ++++---- .../keylesspalace/tusky/ViewMediaActivity.kt | 2 +- .../tusky/adapter/NotificationsAdapter.java | 65 +++++++++++++++---- .../tusky/adapter/PollAdapter.kt | 2 +- .../notifications/NotificationHelper.java | 39 ++++------- .../tusky/entity/Notification.kt | 20 +++++- .../tusky/entity/Relationship.kt | 3 +- .../tusky/fragment/NotificationsFragment.java | 7 +- .../tusky/network/MastodonApi.kt | 3 +- .../tusky/viewmodel/AccountViewModel.kt | 33 ++++++++-- .../res/layout/item_status_notification.xml | 2 - 11 files changed, 137 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index 236b31d2..e696ebc7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -327,7 +327,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI * Subscribe to data loaded at the view model */ private fun subscribeObservables() { - viewModel.accountData.observe(this, Observer> { + viewModel.accountData.observe(this) { when (it) { is Success -> onAccountChanged(it.data) is Error -> { @@ -336,8 +336,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .show() } } - }) - viewModel.relationshipData.observe(this, Observer> { + } + viewModel.relationshipData.observe(this) { val relation = it?.data if (relation != null) { onRelationshipChanged(relation) @@ -349,11 +349,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .show() } - }) - viewModel.accountFieldData.observe(this, Observer>> { + } + viewModel.accountFieldData.observe(this) { accountFieldAdapter.fields = it accountFieldAdapter.notifyDataSetChanged() - }) + } viewModel.noteSaved.observe(this) { saveNoteInfo.visible(it, View.INVISIBLE) } @@ -367,9 +367,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.refresh() adapter.refreshContent() } - viewModel.isRefreshing.observe(this, Observer { isRefreshing -> + viewModel.isRefreshing.observe(this) { isRefreshing -> swipeToRefreshLayout.isRefreshing = isRefreshing == true - }) + } swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } @@ -436,7 +436,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI accountAvatarImageView.setOnClickListener { avatarView -> - val intent = ViewMediaActivity.newAvatarIntent(avatarView.context, account.avatar) + val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) avatarView.transitionName = account.avatar val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) @@ -635,12 +635,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI accountFollowsYouTextView.visible(relation.followedBy) // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field - if(!viewModel.isSelf && followState == FollowState.FOLLOWING && relation.subscribing != null) { + // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call + if(!viewModel.isSelf && followState == FollowState.FOLLOWING + && (relation.subscribing != null || relation.notifying != null)) { accountSubscribeButton.show() accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() } - subscribing = relation.subscribing + if(relation.notifying != null) + subscribing = relation.notifying + else if(relation.subscribing != null) + subscribing = relation.subscribing } accountNoteTextInputLayout.visible(relation.note != null) diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 7b9477cd..b40adb17 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -88,7 +88,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener return intent } - fun newAvatarIntent(context: Context?, url: String): Intent { + fun newSingleImageIntent(context: Context?, url: String): Intent { val intent = Intent(context, ViewMediaActivity::class.java) intent.putExtra(EXTRA_AVATAR_URL, url) return intent diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 93081f2a..999d07c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -39,6 +39,7 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; +import com.bumptech.glide.Glide; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; @@ -216,8 +217,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter { holder.setUsername(statusViewData.getNickname()); holder.setCreatedAt(statusViewData.getCreatedAt()); - holder.setAvatars(concreteNotificaton.getStatusViewData().getAvatar(), - concreteNotificaton.getAccount().getAvatar()); + if(concreteNotificaton.getType() == Notification.Type.STATUS) { + holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); + } else { + holder.setAvatars(statusViewData.getAvatar(), + concreteNotificaton.getAccount().getAvatar()); + } } holder.setMessage(concreteNotificaton, statusListener); @@ -291,10 +296,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { switch (concrete.getType()) { case MENTION: case POLL: { - if(concrete.getStatusViewData() != null && concrete.getStatusViewData().isMuted()) + if (concrete.getStatusViewData() != null && concrete.getStatusViewData().isMuted()) return VIEW_TYPE_MUTED_STATUS; return VIEW_TYPE_STATUS; } + case STATUS: case FAVOURITE: case REBLOG: case EMOJI_REACTION: { @@ -426,7 +432,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private StatusViewData.Concrete statusViewData; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; - + + private int avatarRadius48dp; + private int avatarRadius36dp; + private int avatarRadius24dp; + StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { super(itemView); message = itemView.findViewById(R.id.notification_top_text); @@ -452,6 +462,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusContent.setOnClickListener(this); shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); } private void showNotificationContent(boolean show) { @@ -460,7 +474,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); statusContent.setVisibility(show ? View.VISIBLE : View.GONE); statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); replyInfo.setVisibility(show ? View.VISIBLE : View.GONE); } @@ -545,6 +558,17 @@ public class NotificationsAdapter extends RecyclerView.Adapter { wholeMessage = String.format(format, displayName); break; } + case STATUS: { + icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, + R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); + } + + String format = context.getString(R.string.notification_subscription_format); + wholeMessage = String.format(format, displayName); + break; + } case EMOJI_REACTION: { icon = ContextCompat.getDrawable(context, R.drawable.ic_emoji_24dp); if(icon != null) { @@ -595,19 +619,34 @@ public class NotificationsAdapter extends RecyclerView.Adapter { this.notificationId = notificationId; } - void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { - - int statusAvatarRadius = statusAvatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_36dp); + void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { + statusAvatar.setPaddingRelative(0, 0, 0, 0); ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, statusAvatarRadius, statusDisplayOptions.animateAvatars()); + statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); - int notificationAvatarRadius = statusAvatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_24dp); + if (statusDisplayOptions.showBotOverlay() && isBot) { + notificationAvatar.setVisibility(View.VISIBLE); + notificationAvatar.setBackgroundColor(0x50ffffff); + Glide.with(notificationAvatar) + .load(R.drawable.ic_bot_24dp) + .into(notificationAvatar); + } else { + notificationAvatar.setVisibility(View.GONE); + } + } + + void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { + int padding = Utils.dpToPx(statusAvatar.getContext(), 12); + statusAvatar.setPaddingRelative(0, 0, padding, padding); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars()); + + notificationAvatar.setVisibility(View.VISIBLE); ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - notificationAvatarRadius, statusDisplayOptions.animateAvatars()); + avatarRadius24dp, statusDisplayOptions.animateAvatars()); } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index a990d326..6255a886 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -82,7 +82,7 @@ class PollAdapter: RecyclerView.Adapter() { val percent = calculatePercent(option.votesCount, votersCount, voteCount) val emojifiedPollOptionText = buildDescription(option.title, percent, holder.resultTextView.context) .emojify(emojis, holder.resultTextView) - holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) + holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) val level = percent * 100 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 82eab93d..8cc59006 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -152,6 +152,7 @@ public class NotificationHelper { */ public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) { + body = Notification.rewriteToStatusTypeIfNeeded(body, account.getAccountId()); if (!filterNotification(account, body, context)) { return; @@ -572,9 +573,9 @@ public class NotificationHelper { switch (notification.getType()) { case MENTION: - if(isMentionedInNotification(notification, account.getAccountId())) - return account.getNotificationsMentioned(); - else return account.getNotificationsSubscriptions(); + return account.getNotificationsMentioned(); + case STATUS: + return account.getNotificationsSubscriptions(); case FOLLOW: return account.getNotificationsFollowed(); case FOLLOW_REQUEST: @@ -600,9 +601,9 @@ public class NotificationHelper { private static String getChannelId(AccountEntity account, Notification notification) { switch (notification.getType()) { case MENTION: - if(isMentionedInNotification(notification, account.getAccountId())) - return CHANNEL_MENTION + account.getIdentifier(); - else return CHANNEL_SUBSCRIPTIONS + account.getIdentifier(); + return CHANNEL_MENTION + account.getIdentifier(); + case STATUS: + return CHANNEL_SUBSCRIPTIONS + account.getIdentifier(); case FOLLOW: return CHANNEL_FOLLOW + account.getIdentifier(); case FOLLOW_REQUEST: @@ -671,32 +672,17 @@ public class NotificationHelper { return null; } - - private static boolean isMentionedInNotification(Notification not, String id) { - if(not.getStatus() != null) { - for(int i = 0; i < not.getStatus().getMentions().length; i++) { - if(not.getStatus().getMentions()[i].getId().equals(id)) { - return true; - } - } - - return false; - } - - return true; // actually should never happen, true just in case someone breaks API again - } @Nullable private static String titleForType(Context context, Notification notification, AccountEntity account) { String accountName = StringUtils.unicodeWrap(notification.getAccount().getName()); switch (notification.getType()) { case MENTION: - if(isMentionedInNotification(notification, account.getAccountId())) { - return String.format(context.getString(R.string.notification_mention_format), - accountName); - } else { - return String.format(context.getString(R.string.notification_subscription_format), accountName); - } + return String.format(context.getString(R.string.notification_mention_format), + accountName); + case STATUS: + return String.format(context.getString(R.string.notification_subscription_format), + accountName); case FOLLOW: return String.format(context.getString(R.string.notification_follow_format), accountName); @@ -739,6 +725,7 @@ public class NotificationHelper { case FAVOURITE: case REBLOG: case EMOJI_REACTION: + case STATUS: if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { return notification.getStatus().getSpoilerText(); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index a88a9b92..c57addc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -46,7 +46,8 @@ data class Notification( EMOJI_REACTION("pleroma:emoji_reaction"), FOLLOW_REQUEST("follow_request"), CHAT_MESSAGE("pleroma:chat_mention"), - MOVE("move"); + MOVE("move"), + STATUS("status"); /* Mastodon 3.3.0rc1 */ companion object { @@ -58,7 +59,7 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL, EMOJI_REACTION, FOLLOW_REQUEST, CHAT_MESSAGE, MOVE) + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL, EMOJI_REACTION, FOLLOW_REQUEST, CHAT_MESSAGE, MOVE, STATUS) val asStringList = asList.map { it.presentation } } @@ -88,4 +89,19 @@ data class Notification( } } + + companion object { + + // for Pleroma compatibility that uses Mention type + @JvmStatic + fun rewriteToStatusTypeIfNeeded(body: Notification, accountId: String) : Notification { + if (body.type == Type.MENTION + && body.status != null) { + return if (body.status.mentions.any { + it.id == accountId + }) body else body.copy(type = Type.STATUS) + } + return body + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index 243f2e22..e25a3d10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -28,5 +28,6 @@ data class Relationship ( @SerializedName("showing_reblogs") val showingReblogs: Boolean, val subscribing: Boolean? = null, // Pleroma extension @SerializedName("domain_blocking") val blockingDomain: Boolean, - val note: String? // nullable for backward compatibility / feature detection + val note: String?, // nullable for backward compatibility / feature detection + val notifying: Boolean? // since 3.3.0rc ) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 0ee22c02..1e2ae2e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -177,7 +177,10 @@ public class NotificationsFragment extends SFragment implements @Override public NotificationViewData apply(Either input) { if (input.isRight()) { - Notification notification = input.asRight(); + Notification notification = Notification.rewriteToStatusTypeIfNeeded( + input.asRight(), accountManager.getActiveAccount().getAccountId() + ); + return ViewDataUtils.notificationToViewData( notification, alwaysShowSensitiveMedia, @@ -874,6 +877,8 @@ public class NotificationsFragment extends SFragment implements return getString(R.string.notification_emoji_name); case MOVE: return getString(R.string.notification_move_name); + case STATUS: + return getString(R.string.notification_subscription_name); default: return "Unknown"; } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index af27d859..a690e46b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -309,7 +309,8 @@ interface MastodonApi { @POST("api/v1/accounts/{id}/follow") fun followAccount( @Path("id") accountId: String, - @Field("reblogs") showReblogs: Boolean + @Field("reblogs") showReblogs: Boolean? = null, + @Field("notify") notify: Boolean? = null ): Single @POST("api/v1/accounts/{id}/unfollow") diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt index d49ff920..90e8026d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -33,7 +33,7 @@ class AccountViewModel @Inject constructor( val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> identityProofs.orEmpty().map { Either.Left(it) } - .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) + .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) } val isRefreshing = MutableLiveData().apply { value = false } @@ -128,7 +128,9 @@ class AccountViewModel @Inject constructor( } fun changeSubscribingState() { - if (relationshipData.value?.data?.subscribing == true) { + val relationship = relationshipData.value?.data + if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */ + || relationship?.subscribing == true /* Pleroma */ ) { changeRelationship(RelationShipAction.UNSUBSCRIBE) } else { changeRelationship(RelationShipAction.SUBSCRIBE) @@ -188,6 +190,7 @@ class AccountViewModel @Inject constructor( private fun changeRelationship(relationshipAction: RelationShipAction, parameter: Boolean? = null) { val relation = relationshipData.value?.data val account = accountData.value?.data + val isMastodon = relationshipData.value?.data?.notifying != null if (relation != null && account != null) { // optimistically post new state for faster response @@ -205,21 +208,37 @@ class AccountViewModel @Inject constructor( RelationShipAction.UNBLOCK -> relation.copy(blocking = false) RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) - RelationShipAction.SUBSCRIBE -> relation.copy(subscribing = true) - RelationShipAction.UNSUBSCRIBE -> relation.copy(subscribing = false) + RelationShipAction.SUBSCRIBE -> { + if(isMastodon) + relation.copy(notifying = true) + else relation.copy(subscribing = true) + } + RelationShipAction.UNSUBSCRIBE -> { + if(isMastodon) + relation.copy(notifying = false) + else relation.copy(subscribing = false) + } } relationshipData.postValue(Loading(newRelation)) } when (relationshipAction) { - RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true) + RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs = parameter ?: true) RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) - RelationShipAction.SUBSCRIBE -> mastodonApi.subscribeAccount(accountId) - RelationShipAction.UNSUBSCRIBE -> mastodonApi.unsubscribeAccount(accountId) + RelationShipAction.SUBSCRIBE -> { + if(isMastodon) + mastodonApi.followAccount(accountId, notify = true) + else mastodonApi.subscribeAccount(accountId) + } + RelationShipAction.UNSUBSCRIBE -> { + if(isMastodon) + mastodonApi.followAccount(accountId, notify = false) + else mastodonApi.unsubscribeAccount(accountId) + } }.subscribe( { relationship -> relationshipData.postValue(Success(relationship)) diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index eca33907..18d75766 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -164,8 +164,6 @@ android:layout_marginRight="14dp" android:layout_marginBottom="14dp" android:contentDescription="@string/action_view_profile" - android:paddingRight="12dp" - android:paddingBottom="12dp" android:scaleType="centerCrop" tools:ignore="RtlHardcoded,RtlSymmetry" tools:src="@drawable/avatar_default" />