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" /> + +