From 83f1d7aaf1c73bc97df884c9026bff6b01f04b37 Mon Sep 17 00:00:00 2001 From: pandasoft0 Date: Tue, 9 Apr 2019 20:13:54 +0300 Subject: [PATCH] Add CLEAR and FILTER buttons to notifications (#1168) * Issue tuskyapp#762 add clear notifications button to the top of the Notifications adapter * Issue tuskyapp#764 add the notifications filter * Update notifications top bar buttons * Replace PopupMenu with PopupWindow. Save notifications filter to the account table * Disable hide top bar on empty content at the notification screen * Add app bar behavior to the sw640 notification layout * Fix issue with click on top notification tab --- .../14.json | 662 ++++++++++++++++++ .../keylesspalace/tusky/TuskyApplication.java | 3 +- .../tusky/adapter/NotificationsAdapter.java | 9 +- .../keylesspalace/tusky/db/AccountEntity.kt | 3 +- .../keylesspalace/tusky/db/AppDatabase.java | 9 +- .../tusky/entity/Notification.kt | 31 +- .../tusky/fragment/NotificationsFragment.java | 175 ++++- .../tusky/network/MastodonApi.java | 4 +- .../util/AppBarLayoutNoEmptyScrollBehavior.kt | 74 ++ .../tusky/util/NotificationTypeConverter.kt | 45 ++ .../fragment_timeline_notifications.xml | 107 +++ .../fragment_timeline_notifications.xml | 102 +++ .../main/res/layout/notifications_filter.xml | 17 + .../main/res/menu/notifications_filter.xml | 27 + app/src/main/res/values/strings.xml | 7 + 15 files changed, 1247 insertions(+), 28 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt create mode 100644 app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml create mode 100644 app/src/main/res/layout/fragment_timeline_notifications.xml create mode 100644 app/src/main/res/layout/notifications_filter.xml create mode 100644 app/src/main/res/menu/notifications_filter.xml diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json new file mode 100644 index 00000000..85c8028a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json @@ -0,0 +1,662 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "b9ca62605345d229ced2bb0c1f2db79b", + "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)", + "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 + } + ], + "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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` 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, `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": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "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": "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 `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, 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 + } + ], + "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, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` 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": "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 + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `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, 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 + } + ], + "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_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, 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.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 + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, \"b9ca62605345d229ced2bb0c1f2db79b\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 04e67a67..d799b14e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -78,7 +78,8 @@ public class TuskyApplication extends Application implements HasActivityInjector .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, - 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) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { 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 e4d7a512..f488216a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -99,24 +99,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter { @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); switch (viewType) { case VIEW_TYPE_MENTION: { - View view = LayoutInflater.from(parent.getContext()) + View view = inflater .inflate(R.layout.item_status, parent, false); return new StatusViewHolder(view, useAbsoluteTime); } case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = LayoutInflater.from(parent.getContext()) + View view = inflater .inflate(R.layout.item_status_notification, parent, false); return new StatusNotificationViewHolder(view, useAbsoluteTime); } case VIEW_TYPE_FOLLOW: { - View view = LayoutInflater.from(parent.getContext()) + View view = inflater .inflate(R.layout.item_follow, parent, false); return new FollowViewHolder(view); } case VIEW_TYPE_PLACEHOLDER: { - View view = LayoutInflater.from(parent.getContext()) + View view = inflater .inflate(R.layout.item_status_placeholder, parent, false); return new PlaceholderViewHolder(view); } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 5b3390e2..7e5f9cf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -51,7 +51,8 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long, var lastNotificationId: String = "0", var activeNotifications: String = "[]", var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs()) { + var tabPreferences: List = defaultTabs(), + var notificationsFilter: String = "[]") { val identifier: String get() = "$domain:$accountId" diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 83e7898a..a4c859c9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -30,7 +30,7 @@ import androidx.annotation.NonNull; @Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 13) + }, version = 14) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); @@ -256,6 +256,13 @@ public abstract class AppDatabase extends RoomDatabase { } }; + public static final Migration MIGRATION_13_14 = new Migration(13, 14) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); + } + }; + public static final Migration MIGRATION_10_13 = new Migration(10, 13) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { 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 20f83a8b..b731cd5a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,10 +15,7 @@ package com.keylesspalace.tusky.entity -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException +import com.google.gson.* import com.google.gson.annotations.JsonAdapter data class Notification( @@ -28,26 +25,28 @@ data class Notification( val status: Status?) { @JsonAdapter(NotificationTypeAdapter::class) - enum class Type { - UNKNOWN, - MENTION, - REBLOG, - FAVOURITE, - FOLLOW; + enum class Type(val presentation: String) { + UNKNOWN("unknown"), + MENTION("mention"), + REBLOG("reblog"), + FAVOURITE("favourite"), + FOLLOW("follow"); companion object { @JvmStatic fun byString(s: String): Type { - return when (s) { - "mention" -> MENTION - "reblog" -> REBLOG - "favourite" -> FAVOURITE - "follow" -> FOLLOW - else -> UNKNOWN + values().forEach { + if (s == it.presentation) + return it } + return UNKNOWN } + val asList = listOf(MENTION,REBLOG,FAVOURITE,FOLLOW) + } + override fun toString(): String { + return presentation } } 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 795d990a..0a45912f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -21,12 +21,19 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.preference.PreferenceManager; import android.util.Log; +import android.util.SparseBooleanArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; +import android.widget.PopupWindow; import android.widget.ProgressBar; +import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.tabs.TabLayout; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.NotificationsAdapter; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; @@ -48,6 +55,7 @@ import com.keylesspalace.tusky.util.Either; import com.keylesspalace.tusky.util.HttpHeaderLink; import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; import com.keylesspalace.tusky.util.ListUtils; +import com.keylesspalace.tusky.util.NotificationTypeConverterKt; import com.keylesspalace.tusky.util.PairedList; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ViewDataUtils; @@ -58,9 +66,11 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -84,6 +94,7 @@ import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import kotlin.Unit; import kotlin.collections.CollectionsKt; +import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; @@ -102,6 +113,9 @@ public class NotificationsFragment extends SFragment implements private static final int LOAD_AT_ONCE = 30; private int maxPlaceholderId = 0; + + private Set notificationFilter = new HashSet<>(); + private enum FetchEnd { TOP, BOTTOM, @@ -133,10 +147,13 @@ public class NotificationsFragment extends SFragment implements private RecyclerView recyclerView; private ProgressBar progressBar; private BackgroundMessageView statusView; + private AppBarLayout appBarOptions; private LinearLayoutManager layoutManager; private EndlessOnScrollListener scrollListener; private NotificationsAdapter adapter; + private TabLayout.OnTabSelectedListener onTabSelectedListener; + private Button buttonFilter; private boolean hideFab; private boolean topLoading; private boolean bottomLoading; @@ -171,7 +188,7 @@ public class NotificationsFragment extends SFragment implements @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); + View rootView = inflater.inflate(R.layout.fragment_timeline_notifications, container, false); @NonNull Context context = inflater.getContext(); // from inflater to silence warning // Setup the SwipeRefreshLayout. @@ -179,10 +196,14 @@ public class NotificationsFragment extends SFragment implements recyclerView = rootView.findViewById(R.id.recyclerView); progressBar = rootView.findViewById(R.id.progressBar); statusView = rootView.findViewById(R.id.statusView); + appBarOptions = rootView.findViewById(R.id.appBarOptions); swipeRefreshLayout.setOnRefreshListener(this); swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)); + + loadNotificationsFilter(); + // Setup the RecyclerView. recyclerView.setHasFixedSize(true); layoutManager = new LinearLayoutManager(context); @@ -215,6 +236,11 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); + Button buttonClear = rootView.findViewById(R.id.buttonClear); + buttonClear.setOnClickListener(v -> clearNotifications()); + buttonFilter = rootView.findViewById(R.id.buttonFilter); + buttonFilter.setOnClickListener(v -> showFilterMenu()); + if (notifications.isEmpty()) { sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); } else { @@ -511,6 +537,138 @@ public class NotificationsFragment extends SFragment implements onContentCollapsedChange(isCollapsed, position); } + private void clearNotifications() { + //Cancel all ongoing requests + swipeRefreshLayout.setRefreshing(false); + for (Call callItem : callList) { + callItem.cancel(); + } + callList.clear(); + bottomLoading = false; + topLoading = false; + + //Disable load more + bottomId = null; + + //Clear exists notifications + notifications.clear(); + + //Show friend elephant + this.statusView.setVisibility(View.VISIBLE); + this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + + //Update adapter + updateAdapter(); + + //Execute clear notifications request + Call call = mastodonApi.clearNotifications(); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (isAdded()) { + if (!response.isSuccessful()) { + //Reload notifications on failure + fullyRefreshWithProgressBar(true); + } + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + //Reload notifications on failure + fullyRefreshWithProgressBar(true); + } + }); + callList.add(call); + } + + + private void showFilterMenu() { + List notificationsList = Notification.Type.Companion.getAsList(); + List list = new ArrayList<>(); + for (Notification.Type type : notificationsList) { + list.add(getNotificationText(type)); + } + + ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); + PopupWindow window = new PopupWindow(getContext()); + View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); + final ListView listView = view.findViewById(R.id.listView); + view.findViewById(R.id.buttonApply) + .setOnClickListener(v -> { + SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); + Set excludes = new HashSet<>(); + for (int i = 0; i < notificationsList.size(); i++) { + if (!checkedItems.get(i, false)) + excludes.add(notificationsList.get(i)); + } + window.dismiss(); + applyFilterChanges(excludes); + + }); + + listView.setAdapter(adapter); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + for (int i = 0; i < notificationsList.size(); i++) { + if (!notificationFilter.contains(notificationsList.get(i))) + listView.setItemChecked(i, true); + } + window.setContentView(view); + window.setFocusable(true); + window.showAsDropDown(buttonFilter); + + } + + private String getNotificationText(Notification.Type type) { + switch (type) { + case MENTION: + return getString(R.string.filter_mentions); + case FAVOURITE: + return getString(R.string.filter_favorites); + case REBLOG: + return getString(R.string.filter_boosts); + case FOLLOW: + return getString(R.string.filter_follows); + default: + return "Unknown"; + } + } + + private void applyFilterChanges(Set newSet) { + List notifications = Notification.Type.Companion.getAsList(); + boolean isChanged = false; + for (Notification.Type type : notifications) { + if (notificationFilter.contains(type) && !newSet.contains(type)) { + notificationFilter.remove(type); + isChanged = true; + } else if (!notificationFilter.contains(type) && newSet.contains(type)) { + notificationFilter.add(type); + isChanged = true; + } + } + if (isChanged) { + saveNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + + } + + private void loadNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + notificationFilter.addAll(NotificationTypeConverterKt.deserialize( + account.getNotificationsFilter())); + } + } + + private void saveNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); + accountManager.saveAccount(account); + } + } + @Override public void onViewTag(String tag) { super.viewTag(tag); @@ -601,6 +759,7 @@ public class NotificationsFragment extends SFragment implements private void jumpToTop() { if (isAdded()) { + appBarOptions.setExpanded(true,false); layoutManager.scrollToPosition(0); scrollListener.reset(); } @@ -623,7 +782,7 @@ public class NotificationsFragment extends SFragment implements bottomLoading = true; } - Call> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE); + Call> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, notificationFilter); call.enqueue(new Callback>() { @Override @@ -670,7 +829,7 @@ public class NotificationsFragment extends SFragment implements updateAdapter(); } - if (adapter.getItemCount() > 0) { + if (adapter.getItemCount() > 1) { addItems(notifications, fromId); } else { update(notifications, fromId); @@ -820,12 +979,20 @@ public class NotificationsFragment extends SFragment implements return CollectionUtil.map(list, notificationLifter); } - private void fullyRefresh() { + private void fullyRefreshWithProgressBar(boolean isShow) { notifications.clear(); + if (isShow && notifications.isEmpty()) { + progressBar.setVisibility(View.VISIBLE); + statusView.setVisibility(View.GONE); + } updateAdapter(); sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); } + private void fullyRefresh() { + fullyRefreshWithProgressBar(false); + } + @Nullable private Pair findReplyPosition(@NonNull String statusId) { for (int i = 0; i < notifications.size(); i++) { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index 38574e14..2d80c4ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.StatusContext; import java.util.List; +import java.util.Set; import androidx.annotation.Nullable; import io.reactivex.Completable; @@ -101,7 +102,8 @@ public interface MastodonApi { Call> notifications( @Query("max_id") String maxId, @Query("since_id") String sinceId, - @Query("limit") Integer limit); + @Query("limit") Integer limit, + @Query("exclude_types[]") Set excludes); @GET("api/v1/notifications") Call> notificationsWithAuth( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt b/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt new file mode 100644 index 00000000..a6bbac80 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AppBarLayoutNoEmptyScrollBehavior.kt @@ -0,0 +1,74 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import com.google.android.material.appbar.AppBarLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + + +/** + * Disable AppBar scroll if content view empty or don't need to scroll + */ +class AppBarLayoutNoEmptyScrollBehavior : AppBarLayout.Behavior { + + constructor() : super() + + constructor (context: Context, attrs: AttributeSet) : super(context, attrs) + + private fun isRecyclerViewScrollable(appBar: AppBarLayout, recyclerView: RecyclerView?): Boolean { + if (recyclerView == null) + return false + var recyclerViewHeight = recyclerView.height // Height includes RecyclerView plus AppBarLayout at same level + val appCompatHeight = appBar.height + recyclerViewHeight -= appCompatHeight + + return recyclerView.computeVerticalScrollRange() > recyclerViewHeight + } + + override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean { + return if (isRecyclerViewScrollable(child, getRecyclerView(parent))) { + super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type) + } else false + } + + override fun onTouchEvent(parent: CoordinatorLayout, child: AppBarLayout, ev: MotionEvent): Boolean { + //Prevent scroll on app bar drag + return if (child.isShown && !isRecyclerViewScrollable(child, getRecyclerView(parent))) + true + else + super.onTouchEvent(parent, child, ev) + } + + private fun getRecyclerView(parent: ViewGroup): RecyclerView? { + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) + if (child is RecyclerView) + return child + else if (child is ViewGroup) { + val childRecyclerView = getRecyclerView(child) + if (childRecyclerView is RecyclerView) + return childRecyclerView + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt new file mode 100644 index 00000000..19efb116 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/NotificationTypeConverter.kt @@ -0,0 +1,45 @@ +/* Copyright 2019 Joel Pyska + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +import com.keylesspalace.tusky.entity.Notification +import org.json.JSONArray + +/** + * Serialize to string array and deserialize notifications type + */ + +fun serialize(data: Set?): String { + val array = JSONArray() + data?.forEach { + array.put(it.presentation) + } + return array.toString() +} + +fun deserialize(data: String?): Set { + val ret = HashSet() + data?.let { + val array = JSONArray(data) + for (i in 0..(array.length() - 1)) { + val item = array.getString(i) + val type = Notification.Type.byString(item) + if (type != Notification.Type.UNKNOWN) + ret.add(type) + } + } + return ret +} \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml new file mode 100644 index 00000000..bf467a79 --- /dev/null +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + +