From d0b20cf06e0b0bacb866853c5879898cf037c38c Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 24 Feb 2025 14:53:05 +0100 Subject: [PATCH] various not push related notification improvements (#4929) - support new notification type `severed_relationships`, closes https://github.com/tuskyapp/Tusky/issues/4835, closes https://github.com/tuskyapp/Tusky/issues/4334 - support new notification type `moderation_warning` - the account note is now shown again for follow request and follow notifcations (was broken since https://github.com/tuskyapp/Tusky/pull/4026) - closes https://github.com/tuskyapp/Tusky/issues/4571 - The "unknown notification type" notification now shows the unknown type and a info dialog when you click it https://chaos.social/@ConnyDuck/113601791254050485 - The notification policy banner in the notification tab is now cached for better offline behavior (and less jumping of the list on every load) and updates when interacting with the requests - Fixes a bug where some notifications wouldn't be filtered correctly. Behavior should now match Mastodon. https://mastodon.social/@alm10965/113639206858728177 - Fixes a bug where some system notifications wouldn't have a body - For filters and channels, report and signup notifications are now grouped as "Admin", severed relationship events and moderation warnings as "other". These lists are super long already. - The icon for the "`` just posted" notification is now a bell instead of a home - Follow requests won't be filtered by default in the notification tab. No idea why this one got special treatment. This change will only affect new logins and not existing ones. - closes #4440 - Adds info about attached media or poll to StatusNotificationViewHolder. This is important context that has been missing before. - Adds (private) reply/(private) mention text above mention notification. (Partially?) closes https://github.com/tuskyapp/Tusky/issues/3883 Some screenshots: ![follow](https://github.com/user-attachments/assets/5f962116-c16f-4574-aae1-b1f931ce1508) ![moderation_warning](https://github.com/user-attachments/assets/55a2ee7e-ebcd-4ae8-9170-f07f9f5df5d2) ![severed_relationship](https://github.com/user-attachments/assets/a8d6b898-eb44-43b4-9b6d-3fb5f7aeb852) ![unknown](https://github.com/user-attachments/assets/c74ee33e-6926-42b1-b952-dc888b72fd27) ![unknown_info](https://github.com/user-attachments/assets/19ff11bf-aaff-4219-87e2-ea980ebbd118) ![notifications](https://github.com/user-attachments/assets/b5021cbb-f6c0-4a17-9e15-73e669504647) --- .../68.json | 1399 +++++++++++++++++ .../com/keylesspalace/tusky/MainActivity.kt | 2 +- .../com/keylesspalace/tusky/MainViewModel.kt | 2 +- .../keylesspalace/tusky/TuskyApplication.kt | 13 + .../tusky/adapter/FollowRequestViewHolder.kt | 1 + .../tusky/adapter/StatusViewHolder.java | 19 +- .../notifications/FollowViewHolder.kt | 25 +- .../ModerationWarningViewHolder.kt | 47 + .../NotificationPolicySummaryAdapter.kt | 17 +- .../notifications/NotificationTypeMappers.kt | 12 +- .../notifications/NotificationsFragment.kt | 23 +- .../NotificationsPagingAdapter.kt | 46 +- .../NotificationsRemoteMediator.kt | 3 +- .../notifications/NotificationsViewModel.kt | 17 +- ...veredRelationshipNotificationViewHolder.kt | 46 + .../StatusNotificationViewHolder.kt | 37 +- .../notifications/StatusViewHolder.kt | 43 +- .../UnknownNotificationViewHolder.kt | 13 +- .../requests/NotificationRequestsAdapter.kt | 5 +- .../requests/NotificationRequestsViewModel.kt | 11 +- .../NotificationRequestDetailsFragment.kt | 12 +- .../NotificationPreferencesFragment.kt | 58 +- .../preference/PreferencesFragment.kt | 14 +- .../NotificationChannelData.kt | 86 + .../NotificationService.kt | 262 ++- .../timeline/TimelineTypeMappers.kt | 3 +- .../keylesspalace/tusky/db/AppDatabase.java | 13 +- .../com/keylesspalace/tusky/db/Converters.kt | 48 +- .../tusky/db/dao/NotificationPolicyDao.kt | 43 + .../tusky/db/dao/NotificationsDao.kt | 10 +- .../keylesspalace/tusky/db/dao/TimelineDao.kt | 6 +- .../tusky/db/entity/AccountEntity.kt | 10 +- .../tusky/db/entity/NotificationEntity.kt | 7 + .../db/entity/NotificationPolicyEntity.kt | 11 + .../tusky/db/entity/TimelineAccountEntity.kt | 2 + .../keylesspalace/tusky/di/NetworkModule.kt | 4 +- .../tusky/entity/AccountWarning.kt | 52 + .../tusky/entity/Notification.kt | 78 +- .../tusky/entity/NotificationRequest.kt | 2 +- .../entity/RelationshipSeveranceEvent.kt | 41 + .../tusky/json/NotificationTypeAdapter.kt | 35 + .../receiver/SendStatusBroadcastReceiver.kt | 7 +- .../tusky/settings/SettingsConstants.kt | 11 +- .../usecase/NotificationPolicyUsecase.kt | 22 +- .../tusky/viewdata/NotificationViewData.kt | 6 +- app/src/main/res/drawable/heart_broken_24.xml | 10 + app/src/main/res/drawable/help_24dp.xml | 11 + app/src/main/res/drawable/ic_at_18dp.xml | 10 + app/src/main/res/drawable/ic_gavel_24dp.xml | 11 + ...c_reply_all_18dp.xml => ic_reply_18dp.xml} | 2 +- app/src/main/res/layout/item_follow.xml | 61 +- .../main/res/layout/item_follow_request.xml | 7 +- .../item_moderation_warning_notification.xml | 51 + ...item_severed_relationship_notification.xml | 37 + .../res/layout/item_status_notification.xml | 23 +- .../res/layout/item_unknown_notification.xml | 47 +- app/src/main/res/values/strings.xml | 55 +- .../keylesspalace/tusky/MainActivityTest.kt | 4 +- .../notifications/NotificationFaker.kt | 12 +- .../tusky/db/dao/NotificationsDaoTest.kt | 6 +- 60 files changed, 2569 insertions(+), 402 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt create mode 100644 app/src/main/res/drawable/heart_broken_24.xml create mode 100644 app/src/main/res/drawable/help_24dp.xml create mode 100644 app/src/main/res/drawable/ic_at_18dp.xml create mode 100644 app/src/main/res/drawable/ic_gavel_24dp.xml rename app/src/main/res/drawable/{ic_reply_all_18dp.xml => ic_reply_18dp.xml} (69%) create mode 100644 app/src/main/res/layout/item_moderation_warning_notification.xml create mode 100644 app/src/main/res/layout/item_severed_relationship_notification.xml diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json new file mode 100644 index 000000000..d07482495 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json @@ -0,0 +1,1399 @@ +{ + "formatVersion": 1, + "database": { + "version": 68, + "identityHash": "45583265bb92757d39163ee6c19dc4e5", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "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, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsAdmin` INTEGER NOT NULL DEFAULT true, `notificationsOther` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER 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": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "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": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "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": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsAdmin", + "columnName": "notificationsAdmin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationsOther", + "columnName": "notificationsOther", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "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": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "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, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `translationEnabled` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "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": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `note` TEXT NOT NULL DEFAULT '', `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "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": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` 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_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "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.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "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.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `event` TEXT, `moderationWarning` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "moderationWarning", + "columnName": "moderationWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationPolicyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `pendingRequestsCount` INTEGER NOT NULL, `pendingNotificationsCount` INTEGER NOT NULL, PRIMARY KEY(`tuskyAccountId`))", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequestsCount", + "columnName": "pendingRequestsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingNotificationsCount", + "columnName": "pendingNotificationsCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tuskyAccountId" + ] + }, + "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, '45583265bb92757d39163ee6c19dc4e5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index b08644732..0c72251fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -446,7 +446,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) { // user clicked a notification, show follow requests for type FOLLOW_REQUEST, // otherwise show notification tab - if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) { + if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FollowRequest.name) { val accountListIntent = AccountListActivity.newIntent( this, AccountListActivity.Type.FOLLOW_REQUESTS diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index eeb821144..696db0d7c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -132,7 +132,7 @@ class MainViewModel @Inject constructor( if (event.accountId == activeAccount.accountId) { val hasDirectMessageNotification = event.notifications.any { - it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT + it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT } if (hasDirectMessageNotification) { diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index fc5ae3ea7..8d3efbee0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,7 +16,9 @@ package com.keylesspalace.tusky import android.app.Application +import android.app.NotificationManager import android.content.SharedPreferences +import android.os.Build import android.util.Log import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration @@ -53,6 +55,9 @@ class TuskyApplication : Application(), Configuration.Provider { @Inject lateinit var preferences: SharedPreferences + @Inject + lateinit var notificationManager: NotificationManager + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -80,6 +85,14 @@ class TuskyApplication : Application(), Configuration.Provider { // A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one workManager.cancelAllWorkByTag("pullNotifications") } + if (oldVersion < 2025022001 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // delete old now unused notification channels + for (channel in notificationManager.notificationChannels) { + if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT")) { + notificationManager.deleteNotificationChannel(channel.id) + } + } + } upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index f900dcb4b..388f1a270 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -119,5 +119,6 @@ class FollowRequestViewHolder( } } itemView.setOnClickListener { listener.onViewAccount(accountId) } + binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index d64fe250e..8c78871f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -23,7 +23,6 @@ import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -40,9 +39,6 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.Collections; import java.util.List; -import java.util.Objects; - -import at.connyduck.sparkbutton.helpers.Utils; public class StatusViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; @@ -134,16 +130,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusDisplayOptions.animateEmojis() ); statusInfo.setText(emojifiedText); - statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_all_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0); - statusInfo.setVisibility(View.VISIBLE); - } - - // don't use this on the same ViewHolder as setStatusInfoContent, will cause recycling issues as paddings are changed - protected void setPollInfo(final boolean ownPoll) { - statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); - statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); - statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); - statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.getContext(), 28), 0, 0, 0); + statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_reblog_18dp, 0, 0, 0); statusInfo.setVisibility(View.VISIBLE); } @@ -159,6 +146,10 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.GONE); } + protected TextView getStatusInfo() { + return statusInfo; + } + private void setupCollapsedState(boolean sensitive, boolean expanded, final StatusViewData.Concrete status, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt index 5284f64d2..485ec0ef7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -20,15 +20,22 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFollowBinding import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowViewHolder( private val binding: ItemFollowBinding, private val listener: AccountActionListener, + private val linkListener: LinkListener ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( @@ -42,7 +49,7 @@ class FollowViewHolder( val context = itemView.context val account = viewData.account val messageTemplate = - context.getString(if (viewData.type == Notification.Type.SIGN_UP) R.string.notification_sign_up_format else R.string.notification_follow_format) + context.getString(if (viewData.type == Notification.Type.SignUp) R.string.notification_sign_up_format else R.string.notification_follow_format) val wrappedDisplayName = account.name.unicodeWrap() binding.notificationText.text = messageTemplate.format(wrappedDisplayName) @@ -57,16 +64,28 @@ class FollowViewHolder( ) binding.notificationDisplayName.text = emojifiedDisplayName + if (account.note.isEmpty()) { + binding.accountNote.hide() + } else { + binding.accountNote.show() + + val emojifiedNote = account.note.parseAsMastodonHtml() + .emojify(account.emojis, binding.accountNote, statusDisplayOptions.animateEmojis) + setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener) + } + val avatarRadius = context.resources .getDimensionPixelSize(R.dimen.avatar_radius_42dp) loadAvatar( account.avatar, binding.notificationAvatar, avatarRadius, - statusDisplayOptions.animateAvatars, - null + statusDisplayOptions.animateAvatars ) + binding.avatarBadge.visible(statusDisplayOptions.showBotOverlay && account.bot) + itemView.setOnClickListener { listener.onViewAccount(account.id) } + binding.accountNote.setOnClickListener { listener.onViewAccount(account.id) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt new file mode 100644 index 000000000..7cfdf39e6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt @@ -0,0 +1,47 @@ +/* Copyright 2025 Tusky Contributors + * + * 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.components.notifications + +import android.content.Intent +import androidx.core.net.toUri +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class ModerationWarningViewHolder( + private val binding: ItemModerationWarningNotificationBinding, + private val instanceDomain: String +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + val warning = viewData.moderationWarning!! + + binding.moderationWarningDescription.setText(warning.action.text) + + binding.root.setOnClickListener { + val intent = Intent(Intent.ACTION_VIEW, "https://$instanceDomain/disputes/strikes/${warning.id}".toUri()) + binding.root.context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt index cde2a6afa..c51c08475 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt @@ -20,7 +20,7 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding -import com.keylesspalace.tusky.usecase.NotificationPolicyState +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.util.BindingHolder import java.text.NumberFormat @@ -28,9 +28,9 @@ class NotificationPolicySummaryAdapter( private val onOpenDetails: () -> Unit ) : RecyclerView.Adapter>() { - private var state: NotificationPolicyState = NotificationPolicyState.Loading + private var state: NotificationPolicyEntity? = null - fun updateState(newState: NotificationPolicyState) { + fun updateState(newState: NotificationPolicyEntity?) { val oldShowInfo = state.shouldShowInfo() val newShowInfo = newState.shouldShowInfo() state = newState @@ -58,16 +58,15 @@ class NotificationPolicySummaryAdapter( override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0 override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val policySummary = (state as? NotificationPolicyState.Loaded)?.policy?.summary - if (policySummary != null) { + state?.let { policyState -> val binding = holder.binding val context = holder.binding.root.context - binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policySummary.pendingRequestsCount) - binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policySummary.pendingNotificationsCount) + binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policyState.pendingRequestsCount) + binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policyState.pendingNotificationsCount) } } - private fun NotificationPolicyState.shouldShowInfo(): Boolean { - return this is NotificationPolicyState.Loaded && this.policy.summary.pendingNotificationsCount > 0 + private fun NotificationPolicyEntity?.shouldShowInfo(): Boolean { + return this != null && this.pendingNotificationsCount > 0 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt index 66404e7b9..851f815cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt @@ -38,6 +38,8 @@ fun Placeholder.toNotificationEntity( accountId = null, statusId = null, reportId = null, + event = null, + moderationWarning = null, loading = loading ) @@ -50,6 +52,8 @@ fun Notification.toEntity( accountId = account.id, statusId = status?.reblog?.id ?: status?.id, reportId = report?.id, + event = event, + moderationWarning = moderationWarning, loading = false ) @@ -66,7 +70,9 @@ fun Notification.toViewData( isExpanded = isExpanded, isCollapsed = isCollapsed ), - report = report + report = report, + moderationWarning = moderationWarning, + event = event ) fun Report.toEntity( @@ -106,7 +112,9 @@ fun NotificationDataEntity.toViewData( report.toReport(reportTargetAccount) } else { null - } + }, + event = event, + moderationWarning = moderationWarning ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 5512287f8..c4909e920 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -51,10 +51,10 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding import com.keylesspalace.tusky.databinding.NotificationsFilterBinding -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.ReselectableFragment @@ -115,9 +115,11 @@ class NotificationsFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val activeAccount = accountManager.activeAccount ?: return + val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), @@ -131,8 +133,8 @@ class NotificationsFragment : hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, - openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler + showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, + openSpoiler = activeAccount.alwaysOpenSpoiler ) binding.recyclerView.ensureBottomPadding(fab = true) @@ -149,11 +151,12 @@ class NotificationsFragment : // Setup the RecyclerView. binding.recyclerView.setHasFixedSize(true) val adapter = NotificationsPagingAdapter( - accountId = accountManager.activeAccount!!.accountId, + accountId = activeAccount.accountId, statusListener = this, notificationActionListener = this, accountActionListener = this, - statusDisplayOptions = statusDisplayOptions + statusDisplayOptions = statusDisplayOptions, + instanceName = activeAccount.domain ) this.notificationsAdapter = adapter binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -447,8 +450,8 @@ class NotificationsFragment : } private fun showFilterMenu() { - val notificationTypeList = Notification.Type.visibleTypes.map { type -> - getString(type.uiString) + val notificationTypeList = NotificationChannelData.entries.map { type -> + getString(type.title) } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList) @@ -457,7 +460,7 @@ class NotificationsFragment : menuBinding.buttonApply.setOnClickListener { val checkedItems = menuBinding.listView.getCheckedItemPositions() - val excludes = Notification.Type.visibleTypes.filterIndexed { index, _ -> + val excludes = NotificationChannelData.entries.filterIndexed { index, _ -> !checkedItems[index, false] } window.dismiss() @@ -467,7 +470,7 @@ class NotificationsFragment : menuBinding.listView.setAdapter(adapter) menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE) - Notification.Type.visibleTypes.forEachIndexed { index, type -> + NotificationChannelData.entries.forEachIndexed { index, type -> menuBinding.listView.setItemChecked(index, !viewModel.excludes.value.contains(type)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt index 2464d2a10..394791b04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -27,7 +27,9 @@ import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.databinding.ItemFollowBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding @@ -57,7 +59,8 @@ class NotificationsPagingAdapter( private var statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, - private val accountActionListener: AccountActionListener + private val accountActionListener: AccountActionListener, + private val instanceName: String ) : PagingDataAdapter(NotificationsDifferCallback) { var mediaPreviewEnabled: Boolean @@ -79,20 +82,26 @@ class NotificationsPagingAdapter( return when (val notification = getItem(position)) { is NotificationViewData.Concrete -> { when (notification.type) { - Notification.Type.MENTION, - Notification.Type.POLL -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + Notification.Type.Mention, + Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } - Notification.Type.STATUS, - Notification.Type.FAVOURITE, - Notification.Type.REBLOG, - Notification.Type.UPDATE -> VIEW_TYPE_STATUS_NOTIFICATION - Notification.Type.FOLLOW, - Notification.Type.SIGN_UP -> VIEW_TYPE_FOLLOW - Notification.Type.FOLLOW_REQUEST -> VIEW_TYPE_FOLLOW_REQUEST - Notification.Type.REPORT -> VIEW_TYPE_REPORT + Notification.Type.Status, + Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED + } else { + VIEW_TYPE_STATUS_NOTIFICATION + } + Notification.Type.Favourite, + Notification.Type.Reblog -> VIEW_TYPE_STATUS_NOTIFICATION + Notification.Type.Follow, + Notification.Type.SignUp -> VIEW_TYPE_FOLLOW + Notification.Type.FollowRequest -> VIEW_TYPE_FOLLOW_REQUEST + Notification.Type.Report -> VIEW_TYPE_REPORT + Notification.Type.SeveredRelationship -> VIEW_TYPE_SEVERED_RELATIONSHIP + Notification.Type.ModerationWarning -> VIEW_TYPE_MODERATION_WARNING else -> VIEW_TYPE_UNKNOWN } } @@ -119,7 +128,8 @@ class NotificationsPagingAdapter( ) VIEW_TYPE_FOLLOW -> FollowViewHolder( ItemFollowBinding.inflate(inflater, parent, false), - accountActionListener + accountActionListener, + statusListener ) VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder( ItemFollowRequestBinding.inflate(inflater, parent, false), @@ -136,6 +146,14 @@ class NotificationsPagingAdapter( notificationActionListener, accountActionListener ) + VIEW_TYPE_SEVERED_RELATIONSHIP -> SeveredRelationshipNotificationViewHolder( + ItemSeveredRelationshipNotificationBinding.inflate(inflater, parent, false), + instanceName + ) + VIEW_TYPE_MODERATION_WARNING -> ModerationWarningViewHolder( + ItemModerationWarningNotificationBinding.inflate(inflater, parent, false), + instanceName + ) else -> UnknownNotificationViewHolder( ItemUnknownNotificationBinding.inflate(inflater, parent, false) ) @@ -166,7 +184,9 @@ class NotificationsPagingAdapter( private const val VIEW_TYPE_FOLLOW_REQUEST = 4 private const val VIEW_TYPE_PLACEHOLDER = 5 private const val VIEW_TYPE_REPORT = 6 - private const val VIEW_TYPE_UNKNOWN = 7 + private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7 + private const val VIEW_TYPE_MODERATION_WARNING = 8 + private const val VIEW_TYPE_UNKNOWN = 9 val NotificationsDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index 152662e08..57c6f0411 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -21,6 +21,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction +import com.keylesspalace.tusky.components.systemnotifications.toTypes import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected @@ -57,7 +58,7 @@ class NotificationsRemoteMediator( return MediatorResult.Success(endOfPaginationReached = true) } - val excludes = viewModel.excludes.value + val excludes = viewModel.excludes.value.toTypes() try { var dbEmpty = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 6e1bc7584..254e44722 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -34,18 +34,20 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData +import com.keylesspalace.tusky.components.systemnotifications.toTypes import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -56,6 +58,7 @@ import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -84,7 +87,7 @@ class NotificationsViewModel @Inject constructor( private val refreshTrigger = MutableStateFlow(0L) - val excludes: StateFlow> = activeAccountFlow + val excludes: StateFlow> = activeAccountFlow .map { account -> account?.notificationsFilter.orEmpty() } .stateIn(viewModelScope, SharingStarted.Eagerly, activeAccountFlow.value?.notificationsFilter.orEmpty()) @@ -119,7 +122,7 @@ class NotificationsViewModel @Inject constructor( } .flowOn(Dispatchers.Default) - val notificationPolicy: StateFlow = notificationPolicyUsecase.state + val notificationPolicy: Flow = notificationPolicyUsecase.info init { viewModelScope.launch { @@ -148,7 +151,7 @@ class NotificationsViewModel @Inject constructor( } } - fun updateNotificationFilters(newFilters: Set) { + fun updateNotificationFilters(newFilters: Set) { val account = activeAccountFlow.value if (newFilters != excludes.value && account != null) { viewModelScope.launch { @@ -163,7 +166,7 @@ class NotificationsViewModel @Inject constructor( private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { - Notification.Type.MENTION, Notification.Type.POLL -> { + Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> { val account = activeAccountFlow.value notificationViewData.statusViewData?.let { statusViewData -> if (statusViewData.status.account.id == account?.accountId) { @@ -320,7 +323,7 @@ class NotificationsViewModel @Inject constructor( maxId = idAbovePlaceholder, minId = idBelowPlaceholder, limit = TimelineViewModel.LOAD_AT_ONCE, - excludes = excludes.value + excludes = excludes.value.toTypes() ) // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before // maxId, and no smaller than minId. @@ -328,7 +331,7 @@ class NotificationsViewModel @Inject constructor( maxId = idAbovePlaceholder, sinceId = idBelowPlaceholder, limit = TimelineViewModel.LOAD_AT_ONCE, - excludes = excludes.value + excludes = excludes.value.toTypes() ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt new file mode 100644 index 000000000..541585ee3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt @@ -0,0 +1,46 @@ +/* Copyright 2025 Tusky Contributors + * + * 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.components.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.components.systemnotifications.NotificationService +import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class SeveredRelationshipNotificationViewHolder( + private val binding: ItemSeveredRelationshipNotificationBinding, + private val instanceName: String +) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { + + override fun bind( + viewData: NotificationViewData.Concrete, + payloads: List<*>, + statusDisplayOptions: StatusDisplayOptions + ) { + if (payloads.isNotEmpty()) { + return + } + val event = viewData.event!! + val context = binding.root.context + + binding.severedRelationshipText.text = NotificationService.severedRelationShipText( + context, + event, + instanceName + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt index d18dcc706..2a57e5909 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter @@ -44,8 +45,10 @@ import com.keylesspalace.tusky.util.SmartLengthInputFilter import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -84,8 +87,8 @@ internal class StatusNotificationViewHolder( setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) setUsername(account.username) setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) - if (viewData.type == Notification.Type.STATUS || - viewData.type == Notification.Type.UPDATE + if (viewData.type == Notification.Type.Status || + viewData.type == Notification.Type.Update ) { setAvatar( account.avatar, @@ -136,6 +139,7 @@ internal class StatusNotificationViewHolder( binding.notificationContent.visible(show) binding.notificationStatusAvatar.visible(show) binding.notificationNotificationAvatar.visible(show) + binding.notificationAttachmentInfo.visible(show) } private fun setDisplayName(name: String, emojis: List, animateEmojis: Boolean) { @@ -230,19 +234,19 @@ internal class StatusNotificationViewHolder( val format: String val icon: Drawable? when (type) { - Notification.Type.FAVOURITE -> { + Notification.Type.Favourite -> { icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) format = context.getString(R.string.notification_favourite_format) } - Notification.Type.REBLOG -> { + Notification.Type.Reblog -> { icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) format = context.getString(R.string.notification_reblog_format) } - Notification.Type.STATUS -> { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) + Notification.Type.Status -> { + icon = getIconWithColor(context, R.drawable.ic_notifications_active_24dp, R.color.tusky_blue) format = context.getString(R.string.notification_subscription_format) } - Notification.Type.UPDATE -> { + Notification.Type.Update -> { icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue) format = context.getString(R.string.notification_update_format) } @@ -330,15 +334,18 @@ internal class StatusNotificationViewHolder( R.string.post_content_warning_show_more ) binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + binding.notificationAttachmentInfo.hide() } else { binding.buttonToggleNotificationContent.setText( R.string.post_content_warning_show_less ) binding.notificationContent.filters = NO_INPUT_FILTER + setupAttachmentInfo(statusViewData.status) } } else { binding.buttonToggleNotificationContent.visibility = View.GONE binding.notificationContent.filters = NO_INPUT_FILTER + setupAttachmentInfo(statusViewData.status) } val emojifiedText = content.emojify( emojis = emojis, @@ -360,6 +367,22 @@ internal class StatusNotificationViewHolder( binding.notificationContentWarningDescription.text = emojifiedContentWarning } + private fun setupAttachmentInfo(status: Status) { + if (status.attachments.isNotEmpty()) { + binding.notificationAttachmentInfo.show() + binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_attach_file_24dp, 0, 0, 0) + val attachmentCount = status.attachments.size + val attachmentText = binding.root.context.resources.getQuantityString(R.plurals.media_attachments, attachmentCount, attachmentCount) + binding.notificationAttachmentInfo.text = attachmentText + } else if (status.poll != null) { + binding.notificationAttachmentInfo.show() + binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) + binding.notificationAttachmentInfo.setText(R.string.poll) + } else { + binding.notificationAttachmentInfo.hide() + } + } + companion object { private val COLLAPSE_INPUT_FILTER: Array = arrayOf(SmartLengthInputFilter) private val NO_INPUT_FILTER: Array = arrayOf() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt index 0d2360193..f80083d5e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.notifications import android.view.View +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.NotificationViewData internal class StatusViewHolder( @@ -50,11 +54,40 @@ internal class StatusViewHolder( payloads, false ) - } - if (viewData.type == Notification.Type.POLL) { - setPollInfo(accountId == viewData.account.id) - } else { - hideStatusInfo() + if (payloads.isNotEmpty()) { + return + } + + if (viewData.type == Notification.Type.Poll) { + statusInfo.setText(if (accountId == viewData.account.id) R.string.poll_ended_created else R.string.poll_ended_voted) + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) + statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.context, 10)) + statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.context, 28), 0, 0, 0) + statusInfo.show() + } else if (viewData.type == Notification.Type.Mention) { + statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.context, 6)) + statusInfo.setPaddingRelative(Utils.dpToPx(statusInfo.context, 38), 0, 0, 0) + statusInfo.show() + if (viewData.statusViewData.status.inReplyToAccountId == accountId) { + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_reply_18dp, 0, 0, 0) + + if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { + statusInfo.setText(R.string.notification_info_private_reply) + } else { + statusInfo.setText(R.string.notification_info_reply) + } + } else { + statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_at_18dp, 0, 0, 0) + + if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { + statusInfo.setText(R.string.notification_info_private_mention) + } else { + statusInfo.setText(R.string.notification_info_mention) + } + } + } else { + hideStatusInfo() + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt index b4bfcf8b1..bb4841d35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt @@ -18,12 +18,14 @@ package com.keylesspalace.tusky.components.notifications import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.NotificationViewData internal class UnknownNotificationViewHolder( - binding: ItemUnknownNotificationBinding, + private val binding: ItemUnknownNotificationBinding, ) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { override fun bind( @@ -31,6 +33,13 @@ internal class UnknownNotificationViewHolder( payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { - // nothing to do + binding.unknownNotificationType.text = viewData.type.name + + binding.root.setOnClickListener { + MaterialAlertDialogBuilder(binding.root.context) + .setMessage(R.string.unknown_notification_type_explanation) + .setPositiveButton(android.R.string.ok, null) + .show() + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt index 6d0594fb6..1f88e8990 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar +import java.text.NumberFormat class NotificationRequestsAdapter( private val onAcceptRequest: (notificationRequestId: String) -> Unit, @@ -36,6 +37,8 @@ class NotificationRequestsAdapter( private val animateEmojis: Boolean, ) : PagingDataAdapter>(NOTIFICATION_REQUEST_COMPARATOR) { + private val numberFormat: NumberFormat = NumberFormat.getNumberInstance() + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int @@ -58,7 +61,7 @@ class NotificationRequestsAdapter( val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar) - binding.notificationRequestBadge.text = notificationRequest.notificationsCount + binding.notificationRequestBadge.text = numberFormat.format(notificationRequest.notificationsCount) val emojifiedName = account.name.emojify( account.emojis, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt index b929b5ca7..f7be0dc13 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt @@ -28,6 +28,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow @@ -39,7 +40,8 @@ import kotlinx.coroutines.launch @HiltViewModel class NotificationRequestsViewModel @Inject constructor( private val api: MastodonApi, - private val eventHub: EventHub + private val eventHub: EventHub, + private val notificationPolicyUsecase: NotificationPolicyUsecase ) : ViewModel() { var currentSource: NotificationRequestsPagingSource? = null @@ -108,6 +110,13 @@ class NotificationRequestsViewModel @Inject constructor( } fun removeNotificationRequest(id: String) { + requestData.forEach { request -> + if (request.id == id) { + viewModelScope.launch { + notificationPolicyUsecase.updateCounts(request.notificationsCount) + } + } + } requestData.removeAll { request -> request.id == id } currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt index 5ad208388..5ddc93da9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt @@ -102,9 +102,10 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat } private fun setupAdapter(): NotificationsPagingAdapter { + val activeAccount = accountManager.activeAccount!! val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), @@ -118,16 +119,17 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, - openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler + showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, + openSpoiler = activeAccount.alwaysOpenSpoiler ) return NotificationsPagingAdapter( - accountId = accountManager.activeAccount!!.accountId, + accountId = activeAccount.accountId, statusDisplayOptions = statusDisplayOptions, statusListener = this, notificationActionListener = this, - accountActionListener = this + accountActionListener = this, + instanceName = activeAccount.domain ).apply { addLoadStateListener { loadState -> binding.progressBar.visible( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 47808468b..fcc42e852 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -62,8 +62,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { category.isIconSpaceReserved = false switchPreference { - setTitle(R.string.pref_title_notification_filter_follows) - key = PrefKeys.NOTIFICATIONS_FILTER_FOLLOWS + setTitle(R.string.notification_follow_name) + setSummary(R.string.notification_follow_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> @@ -73,8 +73,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_follow_requests) - key = PrefKeys.NOTIFICATION_FILTER_FOLLOW_REQUESTS + setTitle(R.string.notification_follow_request_name) + setSummary(R.string.notification_follow_request_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> @@ -84,8 +84,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_reblogs) - key = PrefKeys.NOTIFICATION_FILTER_REBLOGS + setTitle(R.string.notification_boost_name) + setSummary(R.string.notification_boost_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> @@ -95,8 +95,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_favourites) - key = PrefKeys.NOTIFICATION_FILTER_FAVS + setTitle(R.string.notification_favourite_name) + setSummary(R.string.notification_favourite_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> @@ -106,8 +106,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_poll) - key = PrefKeys.NOTIFICATION_FILTER_POLLS + setTitle(R.string.notification_poll_name) + setSummary(R.string.notification_poll_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> @@ -117,8 +117,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_subscriptions) - key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS + setTitle(R.string.notification_subscription_name) + setSummary(R.string.notification_subscription_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> @@ -128,19 +128,8 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_sign_ups) - key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS - isIconSpaceReserved = false - isChecked = activeAccount.notificationsSignUps - setOnPreferenceChangeListener { _, newValue -> - updateAccount { copy(notificationsSignUps = newValue as Boolean) } - true - } - } - - switchPreference { - setTitle(R.string.pref_title_notification_filter_updates) - key = PrefKeys.NOTIFICATION_FILTER_UPDATES + setTitle(R.string.notification_update_name) + setSummary(R.string.notification_update_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> @@ -150,12 +139,23 @@ class NotificationPreferencesFragment : BasePreferencesFragment() { } switchPreference { - setTitle(R.string.pref_title_notification_filter_reports) - key = PrefKeys.NOTIFICATION_FILTER_REPORTS + setTitle(R.string.notification_channel_admin) + setSummary(R.string.notification_channel_admin_description) isIconSpaceReserved = false - isChecked = activeAccount.notificationsReports + isChecked = activeAccount.notificationsAdmin setOnPreferenceChangeListener { _, newValue -> - updateAccount { copy(notificationsReports = newValue as Boolean) } + updateAccount { copy(notificationsAdmin = newValue as Boolean) } + true + } + } + + switchPreference { + setTitle(R.string.notification_channel_other) + setSummary(R.string.notification_channel_other_description) + isIconSpaceReserved = false + isChecked = activeAccount.notificationsOther + setOnPreferenceChangeListener { _, newValue -> + updateAccount { copy(notificationsOther = newValue as Boolean) } true } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 66de6335f..845ef7b5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -19,8 +19,8 @@ import android.os.Bundle import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.emojiPreference @@ -245,13 +245,13 @@ class PreferencesFragment : BasePreferencesFragment() { val notificationFilter = account.notificationsFilter.toMutableSet() if (value == true) { - notificationFilter.add(Notification.Type.FAVOURITE) - notificationFilter.add(Notification.Type.FOLLOW) - notificationFilter.add(Notification.Type.REBLOG) + notificationFilter.add(NotificationChannelData.FAVOURITE) + notificationFilter.add(NotificationChannelData.FOLLOW) + notificationFilter.add(NotificationChannelData.REBLOG) } else { - notificationFilter.remove(Notification.Type.FAVOURITE) - notificationFilter.remove(Notification.Type.FOLLOW) - notificationFilter.remove(Notification.Type.REBLOG) + notificationFilter.remove(NotificationChannelData.FAVOURITE) + notificationFilter.remove(NotificationChannelData.FOLLOW) + notificationFilter.remove(NotificationChannelData.REBLOG) } lifecycleScope.launch { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt new file mode 100644 index 000000000..827ad89bf --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt @@ -0,0 +1,86 @@ +package com.keylesspalace.tusky.components.systemnotifications + +import androidx.annotation.Keep +import androidx.annotation.StringRes +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.entity.Notification + +@Keep +enum class NotificationChannelData( + val notificationTypes: List, + @StringRes val title: Int, + @StringRes val description: Int, +) { + MENTION( + listOf(Notification.Type.Mention), + R.string.notification_mention_name, + R.string.notification_mention_descriptions, + ), + + REBLOG( + listOf(Notification.Type.Reblog), + R.string.notification_boost_name, + R.string.notification_boost_description + ), + + FAVOURITE( + listOf(Notification.Type.Favourite), + R.string.notification_favourite_name, + R.string.notification_favourite_description + ), + + FOLLOW( + listOf(Notification.Type.Follow), + R.string.notification_follow_name, + R.string.notification_follow_description + ), + + FOLLOW_REQUEST( + listOf(Notification.Type.FollowRequest), + R.string.notification_follow_request_name, + R.string.notification_follow_request_description + ), + + POLL( + listOf(Notification.Type.Poll), + R.string.notification_poll_name, + R.string.notification_poll_description + ), + + STATUS( + listOf(Notification.Type.Status), + R.string.notification_subscription_name, + R.string.notification_subscription_description + ), + + UPDATE( + listOf(Notification.Type.Update), + R.string.notification_update_name, + R.string.notification_update_description + ), + + ADMIN( + listOf(Notification.Type.SignUp, Notification.Type.Report), + R.string.notification_channel_admin, + R.string.notification_channel_admin_description + ), + + OTHER( + listOf(Notification.Type.SeveredRelationship, Notification.Type.ModerationWarning), + R.string.notification_channel_other, + R.string.notification_channel_other_description + ); + + fun getChannelId(account: AccountEntity): String { + return getChannelId(account.identifier) + } + + fun getChannelId(accountIdentifier: String): String { + return "CHANNEL_${name}_$accountIdentifier" + } +} + +fun Set.toTypes(): Set { + return flatMap { channelData -> channelData.notificationTypes }.toSet() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt index a21cd2763..fd15cb6a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt @@ -14,15 +14,16 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import android.service.notification.StatusBarNotification -import android.text.TextUtils import android.util.Log import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag import androidx.core.app.RemoteInput import androidx.core.app.TaskStackBuilder +import androidx.core.net.toUri import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy @@ -48,6 +49,8 @@ import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.NotificationSubscribeResult +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent +import com.keylesspalace.tusky.entity.visibleNotificationTypes import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import com.keylesspalace.tusky.settings.PrefKeys @@ -58,6 +61,7 @@ import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import com.keylesspalace.tusky.worker.NotificationWorker import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.NumberFormat import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -138,71 +142,15 @@ class NotificationService @Inject constructor( fun createNotificationChannelsForAccount(account: AccountEntity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - data class ChannelData( - val id: String, - @StringRes val name: Int, - @StringRes val description: Int, - ) - - val channelData = arrayOf( - ChannelData( - getChannelId(account, Notification.Type.MENTION)!!, - R.string.notification_mention_name, - R.string.notification_mention_descriptions, - ), - ChannelData( - getChannelId(account, Notification.Type.FOLLOW)!!, - R.string.notification_follow_name, - R.string.notification_follow_description, - ), - ChannelData( - getChannelId(account, Notification.Type.FOLLOW_REQUEST)!!, - R.string.notification_follow_request_name, - R.string.notification_follow_request_description, - ), - ChannelData( - getChannelId(account, Notification.Type.REBLOG)!!, - R.string.notification_boost_name, - R.string.notification_boost_description, - ), - ChannelData( - getChannelId(account, Notification.Type.FAVOURITE)!!, - R.string.notification_favourite_name, - R.string.notification_favourite_description, - ), - ChannelData( - getChannelId(account, Notification.Type.POLL)!!, - R.string.notification_poll_name, - R.string.notification_poll_description, - ), - ChannelData( - getChannelId(account, Notification.Type.STATUS)!!, - R.string.notification_subscription_name, - R.string.notification_subscription_description, - ), - ChannelData( - getChannelId(account, Notification.Type.SIGN_UP)!!, - R.string.notification_sign_up_name, - R.string.notification_sign_up_description, - ), - ChannelData( - getChannelId(account, Notification.Type.UPDATE)!!, - R.string.notification_update_name, - R.string.notification_update_description, - ), - ChannelData( - getChannelId(account, Notification.Type.REPORT)!!, - R.string.notification_report_name, - R.string.notification_report_description, - ), - ) - // TODO enumerate all keys of Notification.Type and check if one is missing here? - val channelGroup = NotificationChannelGroup(account.identifier, account.fullName) notificationManager.createNotificationChannelGroup(channelGroup) - val channels = channelData.map { - NotificationChannel(it.id, context.getString(it.name), NotificationManager.IMPORTANCE_DEFAULT).apply { + val channels = NotificationChannelData.entries.map { + NotificationChannel( + it.getChannelId(account), + context.getString(it.title), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { description = context.getString(it.description) enableLights(true) lightColor = -0xd46f27 @@ -260,17 +208,17 @@ class NotificationService @Inject constructor( } return when (type) { - Notification.Type.MENTION -> account.notificationsMentioned - Notification.Type.STATUS -> account.notificationsSubscriptions - Notification.Type.FOLLOW -> account.notificationsFollowed - Notification.Type.FOLLOW_REQUEST -> account.notificationsFollowRequested - Notification.Type.REBLOG -> account.notificationsReblogged - Notification.Type.FAVOURITE -> account.notificationsFavorited - Notification.Type.POLL -> account.notificationsPolls - Notification.Type.SIGN_UP -> account.notificationsSignUps - Notification.Type.UPDATE -> account.notificationsUpdates - Notification.Type.REPORT -> account.notificationsReports - else -> false + Notification.Type.Mention -> account.notificationsMentioned + Notification.Type.Status -> account.notificationsSubscriptions + Notification.Type.Follow -> account.notificationsFollowed + Notification.Type.FollowRequest -> account.notificationsFollowRequested + Notification.Type.Reblog -> account.notificationsReblogged + Notification.Type.Favourite -> account.notificationsFavorited + Notification.Type.Poll -> account.notificationsPolls + Notification.Type.SignUp -> account.notificationsAdmin + Notification.Type.Update -> account.notificationsUpdates + Notification.Type.Report -> account.notificationsAdmin + else -> account.notificationsOther } } @@ -315,7 +263,7 @@ class NotificationService @Inject constructor( ) } - // Only public for one test... + @VisibleForTesting fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? { val channelId = getChannelId(account, apiNotification.type) ?: return null @@ -333,41 +281,43 @@ class NotificationService @Inject constructor( notificationId++ val builder = if (existingAndroidNotification == null) { - getNotificationBuilder(body.type, account, channelId) + getNotificationBuilder(body, account, channelId) } else { NotificationCompat.Builder(context, existingAndroidNotification) } builder .setContentTitle(titleForType(body, account)) - .setContentText(bodyForType(body, account.alwaysOpenSpoiler)) + .setContentText(bodyForType(body, account)) - if (body.type == Notification.Type.MENTION || body.type == Notification.Type.POLL) { + if (body.type == Notification.Type.Mention || body.type == Notification.Type.Poll) { builder.setStyle( NotificationCompat.BigTextStyle() - .bigText(bodyForType(body, account.alwaysOpenSpoiler)) + .bigText(bodyForType(body, account)) ) } - val accountAvatar = try { - Glide.with(context) - .asBitmap() - .load(body.account.avatar) - .transform(RoundedCorners(20)) - .submit() - .get() - } catch (e: ExecutionException) { - Log.d(TAG, "Error loading account avatar", e) - BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) - } catch (e: InterruptedException) { - Log.d(TAG, "Error loading account avatar", e) - BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) + if (body.type != Notification.Type.SeveredRelationship && body.type != Notification.Type.ModerationWarning) { + val accountAvatar = try { + Glide.with(context) + .asBitmap() + .load(body.account.avatar) + .transform(RoundedCorners(20)) + .submit() + .get() + } catch (e: ExecutionException) { + Log.d(TAG, "Error loading account avatar", e) + BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) + } catch (e: InterruptedException) { + Log.d(TAG, "Error loading account avatar", e) + BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) + } + + builder.setLargeIcon(accountAvatar) } - builder.setLargeIcon(accountAvatar) - // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat - if (body.type == Notification.Type.MENTION) { + if (body.type == Notification.Type.Mention) { val replyRemoteInput = RemoteInput.Builder(KEY_REPLY) .setLabel(context.getString(R.string.label_quick_reply)) .build() @@ -471,19 +421,9 @@ class NotificationService @Inject constructor( } private fun getChannelId(account: AccountEntity, type: Notification.Type): String? { - return when (type) { - Notification.Type.MENTION -> CHANNEL_MENTION + account.identifier - Notification.Type.STATUS -> "CHANNEL_SUBSCRIPTIONS" + account.identifier - Notification.Type.FOLLOW -> "CHANNEL_FOLLOW" + account.identifier - Notification.Type.FOLLOW_REQUEST -> "CHANNEL_FOLLOW_REQUEST" + account.identifier - Notification.Type.REBLOG -> "CHANNEL_BOOST" + account.identifier - Notification.Type.FAVOURITE -> "CHANNEL_FAVOURITE" + account.identifier - Notification.Type.POLL -> "CHANNEL_POLL" + account.identifier - Notification.Type.SIGN_UP -> "CHANNEL_SIGN_UP" + account.identifier - Notification.Type.UPDATE -> "CHANNEL_UPDATES" + account.identifier - Notification.Type.REPORT -> "CHANNEL_REPORT" + account.identifier - else -> null - } + return NotificationChannelData.entries.find { data -> + data.notificationTypes.contains(type) + }?.getChannelId(account) } /** @@ -499,17 +439,24 @@ class NotificationService @Inject constructor( } } - private fun getNotificationBuilder(notificationType: Notification.Type, account: AccountEntity, channelId: String): NotificationCompat.Builder { - val eventResultIntent = openNotificationIntent(context, account.id, notificationType) + private fun getNotificationBuilder(notification: Notification, account: AccountEntity, channelId: String): NotificationCompat.Builder { + val notificationType = notification.type + val eventResultPendingIntent = if (notificationType == Notification.Type.ModerationWarning) { + val warning = notification.moderationWarning!! + val intent = Intent(Intent.ACTION_VIEW, "https://${account.domain}/disputes/strikes/${warning.id}".toUri()) + PendingIntent.getActivity(context, account.id.toInt(), intent, pendingIntentFlags(false)) + } else { + val eventResultIntent = openNotificationIntent(context, account.id, notificationType) - val eventStackBuilder = TaskStackBuilder.create(context) - eventStackBuilder.addParentStack(MainActivity::class.java) - eventStackBuilder.addNextIntent(eventResultIntent) + val eventStackBuilder = TaskStackBuilder.create(context) + eventStackBuilder.addParentStack(MainActivity::class.java) + eventStackBuilder.addNextIntent(eventResultIntent) - val eventResultPendingIntent = eventStackBuilder.getPendingIntent( - account.id.toInt(), - pendingIntentFlags(false) - ) + eventStackBuilder.getPendingIntent( + account.id.toInt(), + pendingIntentFlags(false) + ) + } val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_notify) @@ -530,46 +477,42 @@ class NotificationService @Inject constructor( } private fun titleForType(notification: Notification, account: AccountEntity): String? { - if (notification.status == null) { - return null - } - val accountName = notification.account.name.unicodeWrap() when (notification.type) { - Notification.Type.MENTION -> return context.getString(R.string.notification_mention_format, accountName) - Notification.Type.STATUS -> return context.getString(R.string.notification_subscription_format, accountName) - Notification.Type.FOLLOW -> return context.getString(R.string.notification_follow_format, accountName) - Notification.Type.FOLLOW_REQUEST -> return context.getString(R.string.notification_follow_request_format, accountName) - Notification.Type.FAVOURITE -> return context.getString(R.string.notification_favourite_format, accountName) - Notification.Type.REBLOG -> return context.getString(R.string.notification_reblog_format, accountName) - Notification.Type.POLL -> return if (notification.status.account.id == account.accountId) { + Notification.Type.Mention -> return context.getString(R.string.notification_mention_format, accountName) + Notification.Type.Status -> return context.getString(R.string.notification_subscription_format, accountName) + Notification.Type.Follow -> return context.getString(R.string.notification_follow_format, accountName) + Notification.Type.FollowRequest -> return context.getString(R.string.notification_follow_request_format, accountName) + Notification.Type.Favourite -> return context.getString(R.string.notification_favourite_format, accountName) + Notification.Type.Reblog -> return context.getString(R.string.notification_reblog_format, accountName) + Notification.Type.Poll -> return if (notification.status!!.account.id == account.accountId) { context.getString(R.string.poll_ended_created) } else { context.getString(R.string.poll_ended_voted) } - Notification.Type.SIGN_UP -> return context.getString(R.string.notification_sign_up_format, accountName) - Notification.Type.UPDATE -> return context.getString(R.string.notification_update_format, accountName) - Notification.Type.REPORT -> return context.getString(R.string.notification_report_format, account.domain) - Notification.Type.UNKNOWN -> return null + Notification.Type.SignUp -> return context.getString(R.string.notification_sign_up_format, accountName) + Notification.Type.Update -> return context.getString(R.string.notification_update_format, accountName) + Notification.Type.Report -> return context.getString(R.string.notification_report_format, account.domain) + Notification.Type.SeveredRelationship -> return context.getString(R.string.relationship_severance_event_title) + Notification.Type.ModerationWarning -> return context.getString(R.string.moderation_warning) + is Notification.Type.Unknown -> return null } } - private fun bodyForType(notification: Notification, alwaysOpenSpoiler: Boolean): String? { - if (notification.status == null) { - return null - } + private fun bodyForType(notification: Notification, account: AccountEntity): String? { + val alwaysOpenSpoiler = account.alwaysOpenSpoiler when (notification.type) { - Notification.Type.FOLLOW, Notification.Type.FOLLOW_REQUEST, Notification.Type.SIGN_UP -> return "@" + notification.account.username - Notification.Type.MENTION, Notification.Type.FAVOURITE, Notification.Type.REBLOG, Notification.Type.STATUS -> return if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) { + Notification.Type.Follow, Notification.Type.FollowRequest, Notification.Type.SignUp -> return "@" + notification.account.username + Notification.Type.Mention, Notification.Type.Favourite, Notification.Type.Reblog, Notification.Type.Status -> return if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { notification.status.spoilerText } else { - notification.status.content.parseAsMastodonHtml().toString() + notification.status?.content?.parseAsMastodonHtml()?.toString() } - Notification.Type.POLL -> if (!TextUtils.isEmpty(notification.status.spoilerText) && !alwaysOpenSpoiler) { + Notification.Type.Poll -> if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { return notification.status.spoilerText } else { - val poll = notification.status.poll ?: return null + val poll = notification.status?.poll ?: return null val builder = StringBuilder(notification.status.content.parseAsMastodonHtml()) builder.append('\n') @@ -588,11 +531,13 @@ class NotificationService @Inject constructor( return builder.toString() } - Notification.Type.REPORT -> return context.getString( + Notification.Type.Report -> return context.getString( R.string.notification_header_report_format, notification.account.name.unicodeWrap(), notification.report!!.targetAccount.name.unicodeWrap() ) + Notification.Type.SeveredRelationship -> return severedRelationShipText(context, notification.event!!, account.domain) + Notification.Type.ModerationWarning -> return context.getString(notification.moderationWarning!!.action.text) else -> return null } } @@ -908,8 +853,8 @@ class NotificationService @Inject constructor( private fun buildAlertsMap(account: AccountEntity): Map = buildMap { - Notification.Type.visibleTypes.forEach { - put(it.presentation, filterNotification(account, it)) + visibleNotificationTypes.forEach { + put(it.name, filterNotification(account, it)) } } @@ -1010,7 +955,6 @@ class NotificationService @Inject constructor( companion object { const val TAG = "NotificationService" - const val CHANNEL_MENTION: String = "CHANNEL_MENTION" const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID" const val KEY_MENTIONS: String = "KEY_MENTIONS" const val KEY_REPLY: String = "KEY_REPLY" @@ -1029,5 +973,33 @@ class NotificationService @Inject constructor( private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type" private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary" private const val NOTIFICATION_PULL_NAME = "pullNotifications" + + private val numberFormat = NumberFormat.getNumberInstance() + + fun severedRelationShipText( + context: Context, + event: RelationshipSeveranceEvent, + instanceName: String + ): String { + return when (event.type) { + RelationshipSeveranceEvent.Type.DOMAIN_BLOCK -> { + val followers = numberFormat.format(event.followersCount) + val following = numberFormat.format(event.followingCount) + val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) + context.getString(R.string.relationship_severance_event_domain_block, instanceName, event.targetName, followers, followingText) + } + + RelationshipSeveranceEvent.Type.USER_DOMAIN_BLOCK -> { + val followers = numberFormat.format(event.followersCount) + val following = numberFormat.format(event.followingCount) + val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) + context.getString(R.string.relationship_severance_event_user_domain_block, event.targetName, followers, followingText) + } + + RelationshipSeveranceEvent.Type.ACCOUNT_SUSPENSION -> { + context.getString(R.string.relationship_severance_event_account_suspension, instanceName, event.targetName) + } + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index e3c5fcefc..b342dc2f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -40,6 +40,7 @@ fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity { url = url, avatar = avatar, emojis = emojis, + note = note, bot = bot ) } @@ -50,7 +51,7 @@ fun TimelineAccountEntity.toAccount(): TimelineAccount { localUsername = localUsername, username = username, displayName = displayName, - note = "", + note = note, url = url, avatar = avatar, bot = bot, 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 1b1a49832..776446025 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationEntity; import com.keylesspalace.tusky.db.dao.AccountDao; import com.keylesspalace.tusky.db.dao.DraftDao; import com.keylesspalace.tusky.db.dao.InstanceDao; +import com.keylesspalace.tusky.db.dao.NotificationPolicyDao; import com.keylesspalace.tusky.db.dao.NotificationsDao; import com.keylesspalace.tusky.db.dao.TimelineAccountDao; import com.keylesspalace.tusky.db.dao.TimelineDao; @@ -39,6 +40,7 @@ import com.keylesspalace.tusky.db.entity.DraftEntity; import com.keylesspalace.tusky.db.entity.HomeTimelineEntity; import com.keylesspalace.tusky.db.entity.InstanceEntity; import com.keylesspalace.tusky.db.entity.NotificationEntity; +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity; import com.keylesspalace.tusky.db.entity.NotificationReportEntity; import com.keylesspalace.tusky.db.entity.TimelineAccountEntity; import com.keylesspalace.tusky.db.entity.TimelineStatusEntity; @@ -58,11 +60,12 @@ import java.io.File; ConversationEntity.class, NotificationEntity.class, NotificationReportEntity.class, - HomeTimelineEntity.class + HomeTimelineEntity.class, + NotificationPolicyEntity.class }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 66, + version = 68, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -72,6 +75,7 @@ import java.io.File; @AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity @AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity + @AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity } ) public abstract class AppDatabase extends RoomDatabase { @@ -84,6 +88,7 @@ public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract NotificationsDao notificationsDao(); @NonNull public abstract TimelineStatusDao timelineStatusDao(); @NonNull public abstract TimelineAccountDao timelineAccountDao(); + @NonNull public abstract NotificationPolicyDao notificationPolicyDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -850,4 +855,8 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultReplyPrivacy` INTEGER NOT NULL DEFAULT 0"); } }; + + @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsSignUps") + @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsReports") + static class MIGRATION_66_68 implements AutoMigrationSpec { } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index a5d8f6db6..3696b0c88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -19,8 +19,10 @@ import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.db.entity.DraftAttachment +import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult @@ -29,7 +31,9 @@ import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PreviewCard +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.notificationTypeFromString import com.keylesspalace.tusky.settings.DefaultReplyVisibility import com.squareup.moshi.Moshi import com.squareup.moshi.adapter @@ -229,27 +233,59 @@ class Converters @Inject constructor( } @TypeConverter - fun notificationTypeListToJson(data: Set?): String { + fun notificationChannelDataListToJson(data: Set?): String { val array = JSONArray() data?.forEach { - array.put(it.presentation) + array.put(it.name) } return array.toString() } @TypeConverter - fun jsonToNotificationTypeList(data: String?): Set { - val ret = HashSet() + fun jsonToNotificationChannelDataList(data: String?): Set { + val ret = HashSet() data?.let { val array = JSONArray(data) for (i in 0 until array.length()) { val item = array.getString(i) - val type = Notification.Type.byString(item) - if (type != Notification.Type.UNKNOWN) { + try { + val type = NotificationChannelData.valueOf(item) ret.add(type) + } catch (_: IllegalArgumentException) { + // ignore, this can happen because we stored individual notification types and not channels before } } } return ret } + + @TypeConverter + fun relationshipSeveranceEventToJson(event: RelationshipSeveranceEvent?): String { + return moshi.adapter().toJson(event) + } + + @TypeConverter + fun jsonToRelationshipSeveranceEvent(eventJson: String?): RelationshipSeveranceEvent? { + return eventJson?.let { moshi.adapter().fromJson(it) } + } + + @TypeConverter + fun accountWarningToJson(accountWarning: AccountWarning?): String { + return moshi.adapter().toJson(accountWarning) + } + + @TypeConverter + fun jsonToAccountWarning(accountWarningJson: String?): AccountWarning? { + return accountWarningJson?.let { moshi.adapter().fromJson(it) } + } + + @TypeConverter + fun accountWarningToJson(notificationType: Notification.Type): String { + return notificationType.name + } + + @TypeConverter + fun jsonToNotificationType(notificationTypeJson: String): Notification.Type { + return notificationTypeFromString(notificationTypeJson) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt new file mode 100644 index 000000000..7fb2381f0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt @@ -0,0 +1,43 @@ +/* Copyright 2018 Conny Duck + * + * 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.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface NotificationPolicyDao { + @Query("SELECT * FROM NotificationPolicyEntity WHERE tuskyAccountId = :accountId") + fun notificationPolicyForAccount(accountId: Long): Flow + + @Insert(onConflict = REPLACE) + suspend fun update(entity: NotificationPolicyEntity) + + @Query( + "UPDATE NotificationPolicyEntity " + + "SET pendingRequestsCount = max(0, pendingRequestsCount - 1)," + + "pendingNotificationsCount = max(0, pendingNotificationsCount - :notificationCount) " + + "WHERE tuskyAccountId = :accountId" + ) + suspend fun updateCounts( + accountId: Long, + notificationCount: Int + ) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt index 9029ae760..0c3929c77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt @@ -35,11 +35,11 @@ abstract class NotificationsDao { @Query( """ -SELECT n.tuskyAccountId, n.type, n.id, n.loading, +SELECT n.tuskyAccountId, n.type, n.id, n.loading, n.event, n.moderationWarning, a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', +a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId', s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId', s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount', @@ -51,14 +51,14 @@ s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered', sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId', sa.localUsername as 'sa_localUsername', sa.username as 'sa_username', sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar', -sa.emojis as 'sa_emojis', sa.bot as 'sa_bot', +sa.note as 'sa_note', sa.emojis as 'sa_emojis', sa.bot as 'sa_bot', r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId', r.category as 'r_category', r.statusIds as 'r_statusIds', r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId', ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId', ra.localUsername as 'ra_localUsername', ra.username as 'ra_username', ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar', -ra.emojis as 'ra_emojis', ra.bot as 'ra_bot' +ra.note as 'ra_note', ra.emojis as 'ra_emojis', ra.bot as 'ra_bot' FROM NotificationEntity n LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId) LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId) @@ -111,7 +111,7 @@ AND (accountId = :userId OR statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId = :userId) ) - AND type != "SIGN_UP" AND type != "REPORT" + AND type != "admin.sign_up" AND type != "admin.report" """ ) abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt index 38165b8c0..6107e73d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt @@ -39,15 +39,15 @@ s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', -a.emojis as 'a_emojis', a.bot as 'a_bot', +a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId', rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', -rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', +rb.note as 'rb_note', rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', replied.serverId as 'replied_serverId', replied.tuskyAccountId 'replied_tuskyAccountId', replied.localUsername as 'replied_localUsername', replied.username as 'replied_username', replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar', -replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', +replied.note as 'replied_note', replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', h.loading FROM HomeTimelineEntity h LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt index 389a597b6..da8fe0b0a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt @@ -21,10 +21,10 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.defaultTabs import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.DefaultReplyVisibility @@ -54,14 +54,14 @@ data class AccountEntity( val notificationsEnabled: Boolean = true, val notificationsMentioned: Boolean = true, val notificationsFollowed: Boolean = true, - val notificationsFollowRequested: Boolean = false, + val notificationsFollowRequested: Boolean = true, val notificationsReblogged: Boolean = true, val notificationsFavorited: Boolean = true, val notificationsPolls: Boolean = true, val notificationsSubscriptions: Boolean = true, - val notificationsSignUps: Boolean = true, val notificationsUpdates: Boolean = true, - val notificationsReports: Boolean = true, + @ColumnInfo(defaultValue = "true") val notificationsAdmin: Boolean = true, + @ColumnInfo(defaultValue = "true") val notificationsOther: Boolean = true, val notificationSound: Boolean = true, val notificationVibration: Boolean = true, val notificationLight: Boolean = true, @@ -94,7 +94,7 @@ data class AccountEntity( val notificationMarkerId: String = "0", val emojis: List = emptyList(), val tabPreferences: List = defaultTabs(), - val notificationsFilter: Set = setOf(Notification.Type.FOLLOW_REQUEST), + val notificationsFilter: Set = emptySet(), // Scope cannot be changed without re-login, so store it in case // the scope needs to be changed in the future val oauthScopes: String = "", diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt index bb47daaca..05c0de606 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt @@ -21,9 +21,12 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import java.util.Date +@TypeConverters(Converters::class) data class NotificationDataEntity( // id of the account logged into Tusky this notifications belongs to val tuskyAccountId: Long, @@ -35,6 +38,8 @@ data class NotificationDataEntity( @Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?, @Embedded(prefix = "r_") val report: NotificationReportEntity?, @Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning?, // relevant when it is a placeholder val loading: Boolean = false ) @@ -76,6 +81,8 @@ data class NotificationEntity( val accountId: String?, val statusId: String?, val reportId: String?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning?, // relevant when it is a placeholder val loading: Boolean = false ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt new file mode 100644 index 000000000..bf01dfbee --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class NotificationPolicyEntity( + @PrimaryKey val tuskyAccountId: Long, + val pendingRequestsCount: Int, + val pendingNotificationsCount: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt index 12499dbe2..2e8656355 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.db.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters @@ -32,6 +33,7 @@ data class TimelineAccountEntity( val displayName: String, val url: String, val avatar: String, + @ColumnInfo(defaultValue = "") val note: String, val emojis: List, val bot: Boolean ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index db887585c..2a81d55eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.json.GuardedAdapter +import com.keylesspalace.tusky.json.NotificationTypeAdapter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.apiForAccount @@ -91,8 +92,7 @@ object NetworkModule { ) .add( Notification.Type::class.java, - EnumJsonAdapter.create(Notification.Type::class.java) - .withUnknownFallback(Notification.Type.UNKNOWN) + NotificationTypeAdapter() ) .add( Status.Visibility::class.java, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt new file mode 100644 index 000000000..9d01891f5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt @@ -0,0 +1,52 @@ +/* Copyright 2025 Tusky Contributors + * + * 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.entity + +import androidx.annotation.StringRes +import com.keylesspalace.tusky.R +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AccountWarning( + val id: String, + val action: Action +) { + + @JsonClass(generateAdapter = false) + enum class Action(@StringRes val text: Int) { + @Json(name = "none") + NONE(R.string.moderation_warning_action_none), + + @Json(name = "disable") + DISABLE(R.string.moderation_warning_action_disable), + + @Json(name = "mark_statuses_as_sensitive") + MARK_STATUSES_AS_SENSITIVE(R.string.moderation_warning_action_mark_statuses_as_sensitive), + + @Json(name = "delete_statuses") + DELETE_STATUSES(R.string.moderation_warning_action_delete_statuses), + + @Json(name = "sensitive") + SENSITIVE(R.string.moderation_warning_action_sensitive), + + @Json(name = "silence") + SILENCE(R.string.moderation_warning_action_silence), + + @Json(name = "suspend") + SUSPEND(R.string.moderation_warning_action_suspend), + } +} 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 a3e426ea8..b06b2f92f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,8 +15,17 @@ package com.keylesspalace.tusky.entity -import androidx.annotation.StringRes -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Notification.Type +import com.keylesspalace.tusky.entity.Notification.Type.Favourite +import com.keylesspalace.tusky.entity.Notification.Type.Follow +import com.keylesspalace.tusky.entity.Notification.Type.FollowRequest +import com.keylesspalace.tusky.entity.Notification.Type.Mention +import com.keylesspalace.tusky.entity.Notification.Type.ModerationWarning +import com.keylesspalace.tusky.entity.Notification.Type.Reblog +import com.keylesspalace.tusky.entity.Notification.Type.SeveredRelationship +import com.keylesspalace.tusky.entity.Notification.Type.SignUp +import com.keylesspalace.tusky.entity.Notification.Type.Unknown +import com.keylesspalace.tusky.entity.Notification.Type.Update import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -28,78 +37,77 @@ data class Notification( val status: Status? = null, val report: Report? = null, val filtered: Boolean = false, + val event: RelationshipSeveranceEvent? = null, + @Json(name = "moderation_warning") val moderationWarning: AccountWarning? = null ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonClass(generateAdapter = false) - enum class Type(val presentation: String, @StringRes val uiString: Int) { - UNKNOWN("unknown", R.string.notification_unknown_name), + sealed class Type(val name: String) { + data class Unknown(val unknownName: String) : Type(unknownName) /** Someone mentioned you */ - @Json(name = "mention") - MENTION("mention", R.string.notification_mention_name), + object Mention : Type("mention") /** Someone boosted one of your statuses */ - @Json(name = "reblog") - REBLOG("reblog", R.string.notification_boost_name), + object Reblog : Type("reblog") /** Someone favourited one of your statuses */ - @Json(name = "favourite") - FAVOURITE("favourite", R.string.notification_favourite_name), + object Favourite : Type("favourite") /** Someone followed you */ - @Json(name = "follow") - FOLLOW("follow", R.string.notification_follow_name), + object Follow : Type("follow") /** Someone requested to follow you */ - @Json(name = "follow_request") - FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + object FollowRequest : Type("follow_request") /** A poll you have voted in or created has ended */ - @Json(name = "poll") - POLL("poll", R.string.notification_poll_name), + object Poll : Type("poll") /** Someone you enabled notifications for has posted a status */ - @Json(name = "status") - STATUS("status", R.string.notification_subscription_name), + object Status : Type("status") /** Someone signed up (optionally sent to admins) */ - @Json(name = "admin.sign_up") - SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + object SignUp : Type("admin.sign_up") /** A status you interacted with has been updated */ - @Json(name = "update") - UPDATE("update", R.string.notification_update_name), + object Update : Type("update") /** A new report has been filed */ - @Json(name = "admin.report") - REPORT("admin.report", R.string.notification_report_name); + object Report : Type("admin.report") - companion object { - fun byString(s: String): Type { - return entries.firstOrNull { it.presentation == s } ?: UNKNOWN - } + /** Some of your follow relationships have been severed as a result of a moderation or block event **/ + object SeveredRelationship : Type("severed_relationships") - /** Notification types for UI display (omits UNKNOWN) */ - val visibleTypes = - listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) - } + /** moderation_warning = A moderator has taken action against your account or has sent you a warning **/ + object ModerationWarning : Type("moderation_warning") - override fun toString() = presentation + // can't use data objects or this wouldn't work + override fun toString() = name } // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { - if (type == Type.MENTION && status != null) { + if (type == Mention && status != null) { return if (status.mentions.any { it.id == accountId } ) { this } else { - copy(type = Type.STATUS) + copy(type = Type.Status) } } return this } } + +/** Notification types for UI display (omits UNKNOWN) */ +/** this is not in a companion object so it gets initialized earlier, + * otherwise it might get initialized when a subclass is loaded, + * which leds to crash since those subclasses are referenced here */ +val visibleNotificationTypes = listOf(Mention, Reblog, Favourite, Follow, FollowRequest, Type.Poll, Type.Status, SignUp, Update, Type.Report, SeveredRelationship, ModerationWarning) + +fun notificationTypeFromString(s: String): Type { + return visibleNotificationTypes.firstOrNull { it.name == s.lowercase() } ?: Unknown(s) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt index bfea79c9a..eb8c7a67f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt @@ -22,5 +22,5 @@ import com.squareup.moshi.JsonClass data class NotificationRequest( val id: String, val account: Account, - @Json(name = "notifications_count") val notificationsCount: String + @Json(name = "notifications_count") val notificationsCount: Int ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt b/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt new file mode 100644 index 000000000..9007d4b44 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt @@ -0,0 +1,41 @@ +/* Copyright 2025 Tusky Contributors + * + * 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.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RelationshipSeveranceEvent( + val id: String, + val type: Type, + @Json(name = "target_name") val targetName: String, + @Json(name = "followers_count") val followersCount: Int, + @Json(name = "following_count") val followingCount: Int +) { + + @JsonClass(generateAdapter = false) + enum class Type { + @Json(name = "domain_block") + DOMAIN_BLOCK, + + @Json(name = "user_domain_block") + USER_DOMAIN_BLOCK, + + @Json(name = "account_suspension") + ACCOUNT_SUSPENSION, + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt new file mode 100644 index 000000000..6d3a1263e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Tusky Contributors + * + * 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.json + +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.notificationTypeFromString +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter + +class NotificationTypeAdapter : JsonAdapter() { + + override fun fromJson(reader: JsonReader): Notification.Type { + return notificationTypeFromString(reader.nextString()) + } + + override fun toJson(writer: JsonWriter, value: Notification.Type?) { + writer.value(value?.name) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index f463efc6e..7facfaaf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -24,6 +24,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status @@ -47,7 +48,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1) val senderIdentifier = intent.getStringExtra( NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER - ) + )!! val senderFullName = intent.getStringExtra( NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME ) @@ -68,7 +69,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val notification = NotificationCompat.Builder( context, - NotificationService.CHANNEL_MENTION + senderIdentifier + NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.tusky_blue)) @@ -113,7 +114,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { // Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically val notification = NotificationCompat.Builder( context, - NotificationService.CHANNEL_MENTION + senderIdentifier + NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.notification_color)) diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 8b27b5ff5..ccac9347c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -45,7 +45,7 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2025021701 +const val SCHEMA_VERSION = 2025022001 /** The schema version for fresh installs */ const val NEW_INSTALL_SCHEMA_VERSION = 0 @@ -98,15 +98,6 @@ object PrefKeys { const val NOTIFICATION_ALERT_LIGHT = "notificationAlertLight" const val NOTIFICATION_ALERT_VIBRATE = "notificationAlertVibrate" const val NOTIFICATION_ALERT_SOUND = "notificationAlertSound" - const val NOTIFICATION_FILTER_POLLS = "notificationFilterPolls" - const val NOTIFICATION_FILTER_FAVS = "notificationFilterFavourites" - const val NOTIFICATION_FILTER_REBLOGS = "notificationFilterReblogs" - const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" - const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" - const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" - const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" - const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" - const val NOTIFICATION_FILTER_REPORTS = "notificationFilterReports" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt index bac350285..06036fcc3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/NotificationPolicyUsecase.kt @@ -3,21 +3,31 @@ package com.keylesspalace.tusky.usecase import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onSuccess +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.entity.NotificationPolicy import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import retrofit2.HttpException class NotificationPolicyUsecase @Inject constructor( - private val api: MastodonApi + private val api: MastodonApi, + private val db: AppDatabase, + accountManager: AccountManager ) { + private val accountId = accountManager.activeAccount!!.id + private val _state: MutableStateFlow = MutableStateFlow(NotificationPolicyState.Loading) val state: StateFlow = _state.asStateFlow() + val info: Flow = db.notificationPolicyDao().notificationPolicyForAccount(accountId) + suspend fun getNotificationPolicy() { _state.value.let { state -> if (state is NotificationPolicyState.Loaded) { @@ -29,6 +39,13 @@ class NotificationPolicyUsecase @Inject constructor( api.notificationPolicy().fold( { policy -> + db.notificationPolicyDao().update( + NotificationPolicyEntity( + tuskyAccountId = accountId, + pendingRequestsCount = policy.summary.pendingRequestsCount, + pendingNotificationsCount = policy.summary.pendingNotificationsCount, + ) + ) _state.value = NotificationPolicyState.Loaded(refreshing = false, policy = policy) }, { t -> @@ -58,6 +75,9 @@ class NotificationPolicyUsecase @Inject constructor( _state.value = NotificationPolicyState.Loaded(false, notificationPolicy) } } + + suspend fun updateCounts(notificationCount: Int) = + db.notificationPolicyDao().updateCounts(accountId, notificationCount) } sealed interface NotificationPolicyState { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt index 54e58c063..ed53bf603 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -14,7 +14,9 @@ * see . */ package com.keylesspalace.tusky.viewdata +import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount @@ -30,7 +32,9 @@ sealed class NotificationViewData { val type: Notification.Type, val account: TimelineAccount, val statusViewData: StatusViewData.Concrete?, - val report: Report? + val report: Report?, + val event: RelationshipSeveranceEvent?, + val moderationWarning: AccountWarning? ) : NotificationViewData() { override fun asStatusOrNull() = statusViewData diff --git a/app/src/main/res/drawable/heart_broken_24.xml b/app/src/main/res/drawable/heart_broken_24.xml new file mode 100644 index 000000000..bf5c73062 --- /dev/null +++ b/app/src/main/res/drawable/heart_broken_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/help_24dp.xml b/app/src/main/res/drawable/help_24dp.xml new file mode 100644 index 000000000..7f116ea42 --- /dev/null +++ b/app/src/main/res/drawable/help_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_at_18dp.xml b/app/src/main/res/drawable/ic_at_18dp.xml new file mode 100644 index 000000000..ba41e91d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_at_18dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_gavel_24dp.xml b/app/src/main/res/drawable/ic_gavel_24dp.xml new file mode 100644 index 000000000..674603600 --- /dev/null +++ b/app/src/main/res/drawable/ic_gavel_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reply_all_18dp.xml b/app/src/main/res/drawable/ic_reply_18dp.xml similarity index 69% rename from app/src/main/res/drawable/ic_reply_all_18dp.xml rename to app/src/main/res/drawable/ic_reply_18dp.xml index 4b61a8d21..234bc07dc 100644 --- a/app/src/main/res/drawable/ic_reply_all_18dp.xml +++ b/app/src/main/res/drawable/ic_reply_18dp.xml @@ -6,5 +6,5 @@ android:viewportWidth="24.0"> + android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" /> diff --git a/app/src/main/res/layout/item_follow.xml b/app/src/main/res/layout/item_follow.xml index 6dcbf60d9..920f6d15e 100644 --- a/app/src/main/res/layout/item_follow.xml +++ b/app/src/main/res/layout/item_follow.xml @@ -4,13 +4,12 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="14dp" - android:paddingRight="14dp" - android:paddingBottom="10dp"> + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingBottom="8dp"> + + + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/notificationAvatar" + app:layout_constraintTop_toBottomOf="@id/notificationText" + tools:text="Display name" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/notificationAvatar" + app:layout_constraintTop_toBottomOf="@id/notificationDisplayName" + tools:text="\@username" /> diff --git a/app/src/main/res/layout/item_follow_request.xml b/app/src/main/res/layout/item_follow_request.xml index ce84c3cc4..fbd9a9d92 100644 --- a/app/src/main/res/layout/item_follow_request.xml +++ b/app/src/main/res/layout/item_follow_request.xml @@ -23,8 +23,8 @@ app:drawableStartCompat="@drawable/ic_person_add_24dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:text="Someone requested to follow you" - tools:ignore="RtlSymmetry" /> + tools:ignore="RtlSymmetry" + tools:text="Someone requested to follow you" /> + + + + + + + + + diff --git a/app/src/main/res/layout/item_severed_relationship_notification.xml b/app/src/main/res/layout/item_severed_relationship_notification.xml new file mode 100644 index 000000000..c80e72da2 --- /dev/null +++ b/app/src/main/res/layout/item_severed_relationship_notification.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_status_notification.xml b/app/src/main/res/layout/item_status_notification.xml index 1b1a7ab05..352ae40f8 100644 --- a/app/src/main/res/layout/item_status_notification.xml +++ b/app/src/main/res/layout/item_status_notification.xml @@ -140,6 +140,25 @@ app:layout_constraintTop_toBottomOf="@id/notification_content_warning_button" tools:text="Example status here" /> + +