Add notifications for follow requests (#1729)

* Add notifications for follow requests
Issue #1719

* Revert item_follow_request layout, create new layout for follow request notifications

* Migrate follow request interaction from notification to observable pattern

* Filter follow request notifications by default

* Add missing cases for system notification generation

* Format code
This commit is contained in:
Levi Bard 2020-03-19 22:02:10 +01:00 committed by GitHub
parent 43162789c1
commit 4a4dd4f30f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1003 additions and 104 deletions

View file

@ -0,0 +1,735 @@
{
"formatVersion": 1,
"database": {
"version": 22,
"identityHash": "eaa3c4d012fe743948343983fe1ae493",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eaa3c4d012fe743948343983fe1ae493')"
]
}
}

View file

@ -0,0 +1,51 @@
package com.keylesspalace.tusky.adapter
import android.view.View
import androidx.core.text.BidiFormatter
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.CustomEmojiHelper
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.visible
import kotlinx.android.synthetic.main.item_follow_request_notification.view.*
internal class FollowRequestViewHolder(itemView: View, private val showHeader: Boolean) : RecyclerView.ViewHolder(itemView) {
private var id: String? = null
private val animateAvatar: Boolean = PreferenceManager.getDefaultSharedPreferences(itemView.context)
.getBoolean("animateGifAvatars", false)
fun setupWithAccount(account: Account, formatter: BidiFormatter?) {
id = account.id
val wrappedName = formatter?.unicodeWrap(account.name) ?: account.name
val emojifiedName: CharSequence = CustomEmojiHelper.emojifyString(wrappedName, account.emojis, itemView)
itemView.displayNameTextView.text = emojifiedName
if (showHeader) {
itemView.notificationTextView.text = itemView.context.getString(R.string.notification_follow_request_format, emojifiedName)
}
itemView.notificationTextView.visible(showHeader)
val format = itemView.context.getString(R.string.status_username_format)
val formattedUsername = String.format(format, account.username)
itemView.usernameTextView.text = formattedUsername
val avatarRadius = itemView.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, itemView.avatar, avatarRadius, animateAvatar)
}
fun setupActionListener(listener: AccountActionListener) {
itemView.acceptButton.setOnClickListener {
val position = adapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onRespondToFollowRequest(true, id, position)
}
}
itemView.rejectButton.setOnClickListener {
val position = adapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onRespondToFollowRequest(false, id, position)
}
}
itemView.avatar.setOnClickListener { listener.onViewAccount(id) }
}
}

View file

@ -18,19 +18,12 @@ package com.keylesspalace.tusky.adapter;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
public class FollowRequestsAdapter extends AccountAdapter { public class FollowRequestsAdapter extends AccountAdapter {
@ -46,7 +39,7 @@ public class FollowRequestsAdapter extends AccountAdapter {
case VIEW_TYPE_ACCOUNT: { case VIEW_TYPE_ACCOUNT: {
View view = LayoutInflater.from(parent.getContext()) View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_follow_request, parent, false); .inflate(R.layout.item_follow_request, parent, false);
return new FollowRequestViewHolder(view); return new FollowRequestViewHolder(view, false);
} }
case VIEW_TYPE_FOOTER: { case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext()) View view = LayoutInflater.from(parent.getContext())
@ -60,57 +53,8 @@ public class FollowRequestsAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position)); holder.setupWithAccount(accountList.get(position), null);
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }
static class FollowRequestViewHolder extends RecyclerView.ViewHolder {
private ImageView avatar;
private TextView username;
private TextView displayName;
private ImageButton accept;
private ImageButton reject;
private String id;
private boolean animateAvatar;
FollowRequestViewHolder(View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.avatar);
username = itemView.findViewById(R.id.usernameTextView);
displayName = itemView.findViewById(R.id.displayNameTextView);
accept = itemView.findViewById(R.id.acceptButton);
reject = itemView.findViewById(R.id.rejectButton);
animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext())
.getBoolean("animateGifAvatars", false);
}
void setupWithAccount(Account account) {
id = account.getId();
CharSequence emojifiedName = CustomEmojiHelper.emojifyString(account.getName(), account.getEmojis(), displayName);
displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_48dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
}
void setupActionListener(final AccountActionListener listener) {
accept.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onRespondToFollowRequest(true, id, position);
}
});
reject.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onRespondToFollowRequest(false, id, position);
}
});
avatar.setOnClickListener(v -> listener.onViewAccount(id));
}
}
} }

View file

@ -42,6 +42,7 @@ import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
@ -72,8 +73,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_STATUS = 0; private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1;
private static final int VIEW_TYPE_FOLLOW = 2; private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_PLACEHOLDER = 3; private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
private static final int VIEW_TYPE_UNKNOWN = 4; private static final int VIEW_TYPE_PLACEHOLDER = 4;
private static final int VIEW_TYPE_UNKNOWN = 5;
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
@ -82,6 +84,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private StatusDisplayOptions statusDisplayOptions; private StatusDisplayOptions statusDisplayOptions;
private StatusActionListener statusListener; private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener; private NotificationActionListener notificationActionListener;
private AccountActionListener accountActionListener;
private BidiFormatter bidiFormatter; private BidiFormatter bidiFormatter;
private AdapterDataSource<NotificationViewData> dataSource; private AdapterDataSource<NotificationViewData> dataSource;
@ -89,13 +92,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
AdapterDataSource<NotificationViewData> dataSource, AdapterDataSource<NotificationViewData> dataSource,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
StatusActionListener statusListener, StatusActionListener statusListener,
NotificationActionListener notificationActionListener) { NotificationActionListener notificationActionListener,
AccountActionListener accountActionListener) {
this.accountId = accountId; this.accountId = accountId;
this.dataSource = dataSource; this.dataSource = dataSource;
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
this.statusListener = statusListener; this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener; this.notificationActionListener = notificationActionListener;
this.accountActionListener = accountActionListener;
bidiFormatter = BidiFormatter.getInstance(); bidiFormatter = BidiFormatter.getInstance();
} }
@ -119,6 +124,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_follow, parent, false); .inflate(R.layout.item_follow, parent, false);
return new FollowViewHolder(view, statusDisplayOptions); return new FollowViewHolder(view, statusDisplayOptions);
} }
case VIEW_TYPE_FOLLOW_REQUEST: {
View view = inflater
.inflate(R.layout.item_follow_request_notification, parent, false);
return new FollowRequestViewHolder(view, true);
}
case VIEW_TYPE_PLACEHOLDER: { case VIEW_TYPE_PLACEHOLDER: {
View view = inflater View view = inflater
.inflate(R.layout.item_status_placeholder, parent, false); .inflate(R.layout.item_status_placeholder, parent, false);
@ -215,6 +225,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
break; break;
} }
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotificaton.getAccount(), bidiFormatter);
holder.setupActionListener(accountActionListener);
}
}
default: default:
} }
} }
@ -258,6 +275,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case FOLLOW: { case FOLLOW: {
return VIEW_TYPE_FOLLOW; return VIEW_TYPE_FOLLOW;
} }
case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST;
}
default: { default: {
return VIEW_TYPE_UNKNOWN; return VIEW_TYPE_UNKNOWN;
} }

View file

@ -39,6 +39,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var notificationsEnabled: Boolean = true, var notificationsEnabled: Boolean = true,
var notificationsMentioned: Boolean = true, var notificationsMentioned: Boolean = true,
var notificationsFollowed: Boolean = true, var notificationsFollowed: Boolean = true,
var notificationsFollowRequested: Boolean = false,
var notificationsReblogged: Boolean = true, var notificationsReblogged: Boolean = true,
var notificationsFavorited: Boolean = true, var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true, var notificationsPolls: Boolean = true,
@ -54,7 +55,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var activeNotifications: String = "[]", var activeNotifications: String = "[]",
var emojis: List<Emoji> = emptyList(), var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs(), var tabPreferences: List<TabData> = defaultTabs(),
var notificationsFilter: String = "[]") { var notificationsFilter: String = "[\"follow_request\"]") {
val identifier: String val identifier: String
get() = "$domain:$accountId" get() = "$domain:$accountId"

View file

@ -30,7 +30,7 @@ import androidx.annotation.NonNull;
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 21) }, version = 22)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao(); public abstract TootDao tootDao();
@ -326,4 +326,11 @@ public abstract class AppDatabase extends RoomDatabase {
} }
}; };
public static final Migration MIGRATION_21_22 = new Migration(21, 22) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0");
}
};
} }

View file

@ -79,7 +79,7 @@ class AppModule {
AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13,
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, 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_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21) AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22)
.build() .build()
} }

View file

@ -31,6 +31,7 @@ data class Notification(
REBLOG("reblog"), REBLOG("reblog"),
FAVOURITE("favourite"), FAVOURITE("favourite"),
FOLLOW("follow"), FOLLOW("follow"),
FOLLOW_REQUEST("follow_request"),
POLL("poll"); POLL("poll");
companion object { companion object {
@ -43,7 +44,7 @@ data class Notification(
} }
return UNKNOWN return UNKNOWN
} }
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, POLL) val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL)
} }
override fun toString(): String { override fun toString(): String {

View file

@ -65,7 +65,9 @@ import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.ReselectableFragment; import com.keylesspalace.tusky.interfaces.ReselectableFragment;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -98,6 +100,7 @@ import javax.inject.Inject;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Observable; import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit; import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
@ -115,6 +118,7 @@ public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
NotificationsAdapter.NotificationActionListener, NotificationsAdapter.NotificationActionListener,
AccountActionListener,
Injectable, ReselectableFragment { Injectable, ReselectableFragment {
private static final String TAG = "NotificationF"; // logging tag private static final String TAG = "NotificationF"; // logging tag
@ -251,7 +255,7 @@ public class NotificationsFragment extends SFragment implements
); );
adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(),
dataSource, statusDisplayOptions, this, this); dataSource, statusDisplayOptions, this, this, this);
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
recyclerView.setAdapter(adapter); recyclerView.setAdapter(adapter);
@ -765,6 +769,8 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_boost_name); return getString(R.string.notification_boost_name);
case FOLLOW: case FOLLOW:
return getString(R.string.notification_follow_name); return getString(R.string.notification_follow_name);
case FOLLOW_REQUEST:
return getString(R.string.notification_follow_request_name);
case POLL: case POLL:
return getString(R.string.notification_poll_name); return getString(R.string.notification_poll_name);
default: default:
@ -817,6 +823,29 @@ public class NotificationsFragment extends SFragment implements
super.viewAccount(id); super.viewAccount(id);
} }
@Override
public void onMute(boolean mute, String id, int position) {
// No muting from notifications yet
}
@Override
public void onBlock(boolean block, String id, int position) {
// No blocking from notifications yet
}
@Override
public void onRespondToFollowRequest(boolean accept, String id, int position) {
Single<Relationship> request = accept ?
mastodonApi.authorizeFollowRequestObservable(id) :
mastodonApi.rejectFollowRequestObservable(id);
request.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
(relationship) -> fullyRefreshWithProgressBar(true),
(error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))
);
}
@Override @Override
public void onViewStatusForNotificationId(String notificationId) { public void onViewStatusForNotificationId(String notificationId) {
for (Either<Placeholder, Notification> either : notifications) { for (Either<Placeholder, Notification> either : notifications) {

View file

@ -41,42 +41,23 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Preference.O
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
if (activeAccount != null) { if (activeAccount != null) {
for (pair in mapOf(
val notificationPref = requirePreference("notificationsEnabled") as SwitchPreferenceCompat "notificationsEnabled" to activeAccount.notificationsEnabled,
notificationPref.isChecked = activeAccount.notificationsEnabled "notificationFilterMentions" to activeAccount.notificationsMentioned,
notificationPref.onPreferenceChangeListener = this "notificationFilterFollows" to activeAccount.notificationsFollowed,
"notificationFilterFollowRequests" to activeAccount.notificationsFollowRequested,
val mentionedPref = requirePreference("notificationFilterMentions") as SwitchPreferenceCompat "notificationFilterReblogs" to activeAccount.notificationsReblogged,
mentionedPref.isChecked = activeAccount.notificationsMentioned "notificationFilterFavourites" to activeAccount.notificationsFavorited,
mentionedPref.onPreferenceChangeListener = this "notificationFilterPolls" to activeAccount.notificationsPolls,
"notificationAlertSound" to activeAccount.notificationSound,
val followedPref = requirePreference("notificationFilterFollows") as SwitchPreferenceCompat "notificationAlertVibrate" to activeAccount.notificationVibration,
followedPref.isChecked = activeAccount.notificationsFollowed "notificationAlertLight" to activeAccount.notificationLight
followedPref.onPreferenceChangeListener = this )) {
(requirePreference(pair.key) as SwitchPreferenceCompat).apply {
val boostedPref = requirePreference("notificationFilterReblogs") as SwitchPreferenceCompat isChecked = pair.value
boostedPref.isChecked = activeAccount.notificationsReblogged onPreferenceChangeListener = this@NotificationPreferencesFragment
boostedPref.onPreferenceChangeListener = this }
}
val favoritedPref = requirePreference("notificationFilterFavourites") as SwitchPreferenceCompat
favoritedPref.isChecked = activeAccount.notificationsFavorited
favoritedPref.onPreferenceChangeListener = this
val pollsPref = requirePreference("notificationFilterPolls") as SwitchPreferenceCompat
pollsPref.isChecked = activeAccount.notificationsPolls
pollsPref.onPreferenceChangeListener = this
val soundPref = requirePreference("notificationAlertSound") as SwitchPreferenceCompat
soundPref.isChecked = activeAccount.notificationSound
soundPref.onPreferenceChangeListener = this
val vibrationPref = requirePreference("notificationAlertVibrate") as SwitchPreferenceCompat
vibrationPref.isChecked = activeAccount.notificationVibration
vibrationPref.onPreferenceChangeListener = this
val lightPref = requirePreference("notificationAlertLight") as SwitchPreferenceCompat
lightPref.isChecked = activeAccount.notificationLight
lightPref.onPreferenceChangeListener = this
} }
} }
@ -96,6 +77,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Preference.O
} }
"notificationFilterMentions" -> activeAccount.notificationsMentioned = newValue as Boolean "notificationFilterMentions" -> activeAccount.notificationsMentioned = newValue as Boolean
"notificationFilterFollows" -> activeAccount.notificationsFollowed = newValue as Boolean "notificationFilterFollows" -> activeAccount.notificationsFollowed = newValue as Boolean
"notificationFilterFollowRequests" -> activeAccount.notificationsFollowRequested = newValue as Boolean
"notificationFilterReblogs" -> activeAccount.notificationsReblogged = newValue as Boolean "notificationFilterReblogs" -> activeAccount.notificationsReblogged = newValue as Boolean
"notificationFilterFavourites" -> activeAccount.notificationsFavorited = newValue as Boolean "notificationFilterFavourites" -> activeAccount.notificationsFavorited = newValue as Boolean
"notificationFilterPolls" -> activeAccount.notificationsPolls = newValue as Boolean "notificationFilterPolls" -> activeAccount.notificationsPolls = newValue as Boolean

View file

@ -383,6 +383,16 @@ interface MastodonApi {
@Path("id") accountId: String @Path("id") accountId: String
): Call<Relationship> ): Call<Relationship>
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequestObservable(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/follow_requests/{id}/reject")
fun rejectFollowRequestObservable(
@Path("id") accountId: String
): Single<Relationship>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/apps") @POST("api/v1/apps")
fun authenticateApp( fun authenticateApp(

View file

@ -113,6 +113,7 @@ public class NotificationHelper {
**/ **/
public static final String CHANNEL_MENTION = "CHANNEL_MENTION"; public static final String CHANNEL_MENTION = "CHANNEL_MENTION";
public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW"; public static final String CHANNEL_FOLLOW = "CHANNEL_FOLLOW";
public static final String CHANNEL_FOLLOW_REQUEST = "CHANNEL_FOLLOW_REQUEST";
public static final String CHANNEL_BOOST = "CHANNEL_BOOST"; public static final String CHANNEL_BOOST = "CHANNEL_BOOST";
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_POLL = "CHANNEL_POLL";
@ -348,6 +349,7 @@ public class NotificationHelper {
String[] channelIds = new String[]{ String[] channelIds = new String[]{
CHANNEL_MENTION + account.getIdentifier(), CHANNEL_MENTION + account.getIdentifier(),
CHANNEL_FOLLOW + account.getIdentifier(), CHANNEL_FOLLOW + account.getIdentifier(),
CHANNEL_FOLLOW_REQUEST + account.getIdentifier(),
CHANNEL_BOOST + account.getIdentifier(), CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier(), CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(), CHANNEL_POLL + account.getIdentifier(),
@ -355,6 +357,7 @@ public class NotificationHelper {
int[] channelNames = { int[] channelNames = {
R.string.notification_mention_name, R.string.notification_mention_name,
R.string.notification_follow_name, R.string.notification_follow_name,
R.string.notification_follow_request_name,
R.string.notification_boost_name, R.string.notification_boost_name,
R.string.notification_favourite_name, R.string.notification_favourite_name,
R.string.notification_poll_name R.string.notification_poll_name
@ -362,12 +365,13 @@ public class NotificationHelper {
int[] channelDescriptions = { int[] channelDescriptions = {
R.string.notification_mention_descriptions, R.string.notification_mention_descriptions,
R.string.notification_follow_description, R.string.notification_follow_description,
R.string.notification_follow_request_description,
R.string.notification_boost_description, R.string.notification_boost_description,
R.string.notification_favourite_description, R.string.notification_favourite_description,
R.string.notification_poll_description R.string.notification_poll_description
}; };
List<NotificationChannel> channels = new ArrayList<>(5); List<NotificationChannel> channels = new ArrayList<>(6);
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
@ -508,6 +512,8 @@ public class NotificationHelper {
return account.getNotificationsMentioned(); return account.getNotificationsMentioned();
case FOLLOW: case FOLLOW:
return account.getNotificationsFollowed(); return account.getNotificationsFollowed();
case FOLLOW_REQUEST:
return account.getNotificationsFollowRequested();
case REBLOG: case REBLOG:
return account.getNotificationsReblogged(); return account.getNotificationsReblogged();
case FAVOURITE: case FAVOURITE:
@ -525,6 +531,8 @@ public class NotificationHelper {
return CHANNEL_MENTION + account.getIdentifier(); return CHANNEL_MENTION + account.getIdentifier();
case FOLLOW: case FOLLOW:
return CHANNEL_FOLLOW + account.getIdentifier(); return CHANNEL_FOLLOW + account.getIdentifier();
case FOLLOW_REQUEST:
return CHANNEL_FOLLOW_REQUEST + account.getIdentifier();
case REBLOG: case REBLOG:
return CHANNEL_BOOST + account.getIdentifier(); return CHANNEL_BOOST + account.getIdentifier();
case FAVOURITE: case FAVOURITE:
@ -594,6 +602,9 @@ public class NotificationHelper {
case FOLLOW: case FOLLOW:
return String.format(context.getString(R.string.notification_follow_format), return String.format(context.getString(R.string.notification_follow_format),
accountName); accountName);
case FOLLOW_REQUEST:
return String.format(context.getString(R.string.notification_follow_request_format),
accountName);
case FAVOURITE: case FAVOURITE:
return String.format(context.getString(R.string.notification_favourite_format), return String.format(context.getString(R.string.notification_favourite_format),
accountName); accountName);
@ -613,6 +624,7 @@ public class NotificationHelper {
private static String bodyForType(Notification notification, Context context) { private static String bodyForType(Notification notification, Context context) {
switch (notification.getType()) { switch (notification.getType()) {
case FOLLOW: case FOLLOW:
case FOLLOW_REQUEST:
return "@" + notification.getAccount().getUsername(); return "@" + notification.getAccount().getUsername();
case MENTION: case MENTION:
case FAVOURITE: case FAVOURITE:

View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingBottom="10dp">
<androidx.emoji.widget.EmojiTextView
android:id="@+id/notificationTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawableStart="@drawable/ic_person_add_24dp"
android:drawablePadding="10dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:paddingStart="28dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Someone requested to follow you" />
<ImageView
android:id="@+id/avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_marginTop="10dp"
android:contentDescription="@string/action_view_profile"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/notificationTextView" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/displayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/notificationTextView"
tools:text="Display name" />
<TextView
android:id="@+id/usernameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/displayNameTextView"
tools:text="\@username" />
<ImageButton
android:id="@+id/acceptButton"
style="@style/TuskyImageButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginStart="12dp"
android:layout_marginTop="14dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_accept"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/rejectButton"
app:layout_constraintTop_toBottomOf="@id/notificationTextView"
app:srcCompat="@drawable/ic_check_24dp" />
<ImageButton
android:id="@+id/rejectButton"
style="@style/TuskyImageButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginStart="12dp"
android:layout_marginTop="14dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_reject"
android:padding="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/notificationTextView"
app:srcCompat="@drawable/ic_reject_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -60,6 +60,7 @@
<string name="notification_reblog_format">%s boosted your toot</string> <string name="notification_reblog_format">%s boosted your toot</string>
<string name="notification_favourite_format">%s favorited your toot</string> <string name="notification_favourite_format">%s favorited your toot</string>
<string name="notification_follow_format">%s followed you</string> <string name="notification_follow_format">%s followed you</string>
<string name="notification_follow_request_format">%s requested to follow you</string>
<string name="report_username_format">Report @%s</string> <string name="report_username_format">Report @%s</string>
<string name="report_comment_hint">Additional comments?</string> <string name="report_comment_hint">Additional comments?</string>
@ -208,6 +209,7 @@
<string name="pref_title_notification_filters">Notify me when</string> <string name="pref_title_notification_filters">Notify me when</string>
<string name="pref_title_notification_filter_mentions">mentioned</string> <string name="pref_title_notification_filter_mentions">mentioned</string>
<string name="pref_title_notification_filter_follows">followed</string> <string name="pref_title_notification_filter_follows">followed</string>
<string name="pref_title_notification_filter_follow_requests">follow requested</string>
<string name="pref_title_notification_filter_reblogs">my posts are boosted</string> <string name="pref_title_notification_filter_reblogs">my posts are boosted</string>
<string name="pref_title_notification_filter_favourites">my posts are favorited</string> <string name="pref_title_notification_filter_favourites">my posts are favorited</string>
<string name="pref_title_notification_filter_poll">polls have ended</string> <string name="pref_title_notification_filter_poll">polls have ended</string>
@ -262,6 +264,8 @@
<string name="notification_mention_descriptions">Notifications about new mentions</string> <string name="notification_mention_descriptions">Notifications about new mentions</string>
<string name="notification_follow_name">New Followers</string> <string name="notification_follow_name">New Followers</string>
<string name="notification_follow_description">Notifications about new followers</string> <string name="notification_follow_description">Notifications about new followers</string>
<string name="notification_follow_request_name">Follow Requests</string>
<string name="notification_follow_request_description">Notifications about follow requests</string>
<string name="notification_boost_name">Boosts</string> <string name="notification_boost_name">Boosts</string>
<string name="notification_boost_description">Notifications when your toots get boosted</string> <string name="notification_boost_description">Notifications when your toots get boosted</string>
<string name="notification_favourite_name">Favorites</string> <string name="notification_favourite_name">Favorites</string>

View file

@ -27,6 +27,12 @@
android:title="@string/pref_title_notification_filter_follows" android:title="@string/pref_title_notification_filter_follows"
app:iconSpaceReserved="false" /> app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="notificationFilterFollowRequests"
android:title="@string/pref_title_notification_filter_follow_requests"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="true" android:defaultValue="true"
android:key="notificationFilterReblogs" android:key="notificationFilterReblogs"

View file

@ -68,6 +68,7 @@ class ComposeActivityTest {
notificationsEnabled = true, notificationsEnabled = true,
notificationsMentioned = true, notificationsMentioned = true,
notificationsFollowed = true, notificationsFollowed = true,
notificationsFollowRequested = false,
notificationsReblogged = true, notificationsReblogged = true,
notificationsFavorited = true, notificationsFavorited = true,
notificationSound = true, notificationSound = true,