Notification bell (#2012)

* Add notification bell button, API endpoints and new relationship field

* Add notification type for subscriptions

* Add subscriptions to legacy notification filtering

* Update schemas

* Fix build

* Make rewrite static method into method of Notification class, fix getNotificationText

* Mastodon wording for subscriptions
This commit is contained in:
Alibek Omarov 2020-12-23 14:52:39 +03:00 committed by GitHub
commit b91a0aceeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 970 additions and 33 deletions

View file

@ -84,6 +84,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private var muting: Boolean = false
private var blockingDomain: Boolean = false
private var showingReblogs: Boolean = false
private var subscribing: Boolean = false
private var loadedAccount: Account? = null
private var animateAvatar: Boolean = false
@ -116,7 +117,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadResources()
makeNotificationBarTransparent()
setContentView(R.layout.activity_account)
// Obtain information to fill out the profile.
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
@ -159,7 +160,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountMuteButton.hide()
accountFollowsYouTextView.hide()
// setup the RecyclerView for the account fields
accountFieldList.isNestedScrollingEnabled = false
accountFieldList.layoutManager = LinearLayoutManager(this)
@ -185,7 +185,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
poorTabView.isPressed = true
accountTabLayout.postDelayed({ poorTabView.isPressed = false }, 300)
}
}
/**
@ -374,7 +373,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFieldAdapter.emojis = account.emojis ?: emptyList()
accountFieldAdapter.notifyDataSetChanged()
accountLockedImageView.visible(account.locked)
accountBadgeTextView.visible(account.bot)
@ -538,6 +536,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowsYouTextView.visible(relation.followedBy)
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field
// 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()
}
if(relation.notifying != null)
subscribing = relation.notifying
else if(relation.subscribing != null)
subscribing = relation.subscribing
}
accountNoteTextInputLayout.visible(relation.note != null)
accountNoteTextInputLayout.editText?.setText(relation.note)
@ -574,6 +586,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowButton.setText(R.string.action_unfollow)
}
}
updateSubscribeButton()
}
private fun updateMuteButton() {
@ -584,6 +597,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
private fun updateSubscribeButton() {
if(followState != FollowState.FOLLOWING) {
accountSubscribeButton.hide()
}
if(subscribing) {
accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp)
} else {
accountSubscribeButton.setIconResource(R.drawable.ic_notifications_24dp)
}
}
private fun updateButtons() {
invalidateOptionsMenu()
@ -595,6 +620,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (blocking || viewModel.isSelf) {
accountFloatingActionButton.hide()
accountMuteButton.hide()
accountSubscribeButton.hide()
} else {
accountFloatingActionButton.show()
if (muting)
@ -608,6 +634,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFloatingActionButton.hide()
accountFollowButton.hide()
accountMuteButton.hide()
accountSubscribeButton.hide()
}
}

View file

@ -37,6 +37,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;
@ -198,8 +199,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);
@ -267,6 +272,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case POLL: {
return VIEW_TYPE_STATUS;
}
case STATUS:
case FAVOURITE:
case REBLOG: {
return VIEW_TYPE_STATUS_NOTIFICATION;
@ -373,6 +379,10 @@ 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);
@ -398,6 +408,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) {
@ -488,6 +502,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
format = context.getString(R.string.notification_reblog_format);
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);
}
format = context.getString(R.string.notification_subscription_format);
break;
}
}
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
String wholeMessage = String.format(format, displayName);
@ -526,19 +550,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

View file

@ -121,7 +121,7 @@ public class NotificationHelper {
public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
/**
* WorkManager Tag
@ -138,6 +138,7 @@ public class NotificationHelper {
*/
public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) {
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
if (!filterNotification(account, body, context)) {
return;
@ -355,6 +356,7 @@ public class NotificationHelper {
CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(),
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
};
int[] channelNames = {
R.string.notification_mention_name,
@ -362,7 +364,8 @@ public class NotificationHelper {
R.string.notification_follow_request_name,
R.string.notification_boost_name,
R.string.notification_favourite_name,
R.string.notification_poll_name
R.string.notification_poll_name,
R.string.notification_subscription_name,
};
int[] channelDescriptions = {
R.string.notification_mention_descriptions,
@ -370,7 +373,8 @@ public class NotificationHelper {
R.string.notification_follow_request_description,
R.string.notification_boost_description,
R.string.notification_favourite_description,
R.string.notification_poll_description
R.string.notification_poll_description,
R.string.notification_subscription_description,
};
List<NotificationChannel> channels = new ArrayList<>(6);
@ -516,6 +520,8 @@ public class NotificationHelper {
switch (notification.getType()) {
case MENTION:
return account.getNotificationsMentioned();
case STATUS:
return account.getNotificationsSubscriptions();
case FOLLOW:
return account.getNotificationsFollowed();
case FOLLOW_REQUEST:
@ -536,6 +542,8 @@ public class NotificationHelper {
switch (notification.getType()) {
case MENTION:
return CHANNEL_MENTION + account.getIdentifier();
case STATUS:
return CHANNEL_SUBSCRIPTIONS + account.getIdentifier();
case FOLLOW:
return CHANNEL_FOLLOW + account.getIdentifier();
case FOLLOW_REQUEST:
@ -606,6 +614,9 @@ public class NotificationHelper {
case MENTION:
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);
@ -636,6 +647,7 @@ public class NotificationHelper {
case MENTION:
case FAVOURITE:
case REBLOG:
case STATUS:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText();
} else {

View file

@ -111,6 +111,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true
}
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_subscriptions)
key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSubscriptions
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsSubscriptions = newValue as Boolean }
true
}
}
}
preferenceCategory(R.string.pref_title_notification_alerts) { category ->

View file

@ -43,6 +43,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var notificationsReblogged: Boolean = true,
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,

View file

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 23)
}, version = 24)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -339,5 +339,12 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER");
}
};
public static final Migration MIGRATION_23_24 = new Migration(23, 24) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSubscriptions` INTEGER NOT NULL DEFAULT 1");
}
};
}

View file

@ -80,7 +80,7 @@ class AppModule {
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23)
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24)
.build()
}
@ -88,4 +88,4 @@ class AppModule {
@Singleton
fun notifier(context: Context): Notifier = SystemNotifier(context)
}
}

View file

@ -32,7 +32,8 @@ data class Notification(
FAVOURITE("favourite"),
FOLLOW("follow"),
FOLLOW_REQUEST("follow_request"),
POLL("poll");
POLL("poll"),
STATUS("status");
companion object {
@ -44,7 +45,7 @@ data class Notification(
}
return UNKNOWN
}
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL)
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS)
}
override fun toString(): String {
@ -72,4 +73,14 @@ data class Notification(
}
}
// for Pleroma compatibility that uses Mention type
fun rewriteToStatusTypeIfNeeded(accountId: String) : Notification {
if (type == Type.MENTION && status != null) {
return if (status.mentions.any {
it.id == accountId
}) this else copy(type = Type.STATUS)
}
return this
}
}

View file

@ -26,6 +26,8 @@ data class Relationship (
@SerializedName("muting_notifications") val mutingNotifications: Boolean,
val requested: Boolean,
@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
)

View file

@ -179,7 +179,9 @@ public class NotificationsFragment extends SFragment implements
@Override
public NotificationViewData apply(Either<Placeholder, Notification> input) {
if (input.isRight()) {
Notification notification = input.asRight();
Notification notification = input.asRight()
.rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId());
return ViewDataUtils.notificationToViewData(
notification,
alwaysShowSensitiveMedia,
@ -770,6 +772,8 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_follow_request_name);
case POLL:
return getString(R.string.notification_poll_name);
case STATUS:
return getString(R.string.notification_subscription_name);
default:
return "Unknown";
}

View file

@ -307,7 +307,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<Relationship>
@POST("api/v1/accounts/{id}/unfollow")
@ -347,6 +348,16 @@ interface MastodonApi {
@Path("id") accountId: String
): Single<List<IdentityProof>>
@POST("api/v1/pleroma/accounts/{id}/subscribe")
fun subscribeAccount(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/pleroma/accounts/{id}/unsubscribe")
fun unsubscribeAccount(
@Path("id") accountId: String
): Single<Relationship>
@GET("api/v1/blocks")
fun blocks(
@Query("max_id") maxId: String?

View file

@ -53,6 +53,7 @@ object PrefKeys {
const val NOTIFICATION_FILTER_REBLOGS = "notificationFilterReblogs"
const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests"
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"

View file

@ -126,6 +126,16 @@ class AccountViewModel @Inject constructor(
fun unmuteAccount() {
changeRelationship(RelationShipAction.UNMUTE)
}
fun changeSubscribingState() {
val relationship = relationshipData.value?.data
if(relationship?.notifying == true /* Mastodon 3.3.0rc1 */
|| relationship?.subscribing == true /* Pleroma */ ) {
changeRelationship(RelationShipAction.UNSUBSCRIBE)
} else {
changeRelationship(RelationShipAction.SUBSCRIBE)
}
}
fun blockDomain(instance: String) {
mastodonApi.blockDomain(instance).enqueue(object: Callback<Any> {
@ -180,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
@ -197,17 +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 -> {
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 -> {
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))
@ -263,7 +294,6 @@ class AccountViewModel @Inject constructor(
if (!isSelf)
obtainRelationship(isReload)
}
}
fun setAccountInfo(accountId: String) {
@ -273,10 +303,10 @@ class AccountViewModel @Inject constructor(
}
enum class RelationShipAction {
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE
FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE
}
companion object {
const val TAG = "AccountViewModel"
}
}
}