diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json new file mode 100644 index 000000000..8503ae7c6 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json @@ -0,0 +1,995 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "a394ca5b45df9358fdc4d2eaae69cce3", + "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, `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, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "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": "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": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "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 + } + ], + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `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, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "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": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "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": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "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": [] + } + ], + "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, 'a394ca5b45df9358fdc4d2eaae69cce3')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d49febc8..0eaaf655f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -142,7 +142,7 @@ - + + diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt deleted file mode 100644 index bbb5bc6a1..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.keylesspalace.tusky - -import android.os.Bundle -import android.text.format.DateUtils -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.lifecycle.lifecycleScope -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.databinding.ActivityFiltersBinding -import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.getSecondsForDurationIndex -import com.keylesspalace.tusky.view.setupEditDialogForFilter -import com.keylesspalace.tusky.view.showAddFilterDialog -import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject - -class FiltersActivity : BaseActivity() { - @Inject - lateinit var api: MastodonApi - - @Inject - lateinit var eventHub: EventHub - - private val binding by viewBinding(ActivityFiltersBinding::inflate) - - private lateinit var context: String - private lateinit var filters: MutableList - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(binding.root) - setSupportActionBar(binding.includedToolbar.toolbar) - supportActionBar?.run { - // Back button - setDisplayHomeAsUpEnabled(true) - setDisplayShowHomeEnabled(true) - } - binding.addFilterButton.setOnClickListener { - showAddFilterDialog(this) - } - - title = intent?.getStringExtra(FILTERS_TITLE) - context = intent?.getStringExtra(FILTERS_CONTEXT)!! - loadFilters() - } - - fun updateFilter(id: String, phrase: String, filterContext: List, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) { - lifecycleScope.launch { - api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold( - { updatedFilter -> - if (updatedFilter.context.contains(context)) { - filters[itemIndex] = updatedFilter - } else { - filters.removeAt(itemIndex) - } - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show() - } - ) - } - } - - fun deleteFilter(itemIndex: Int) { - val filter = filters[itemIndex] - if (filter.context.size == 1) { - lifecycleScope.launch { - // This is the only context for this filter; delete it - api.deleteFilter(filters[itemIndex].id).fold( - { - filters.removeAt(itemIndex) - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() - } - ) - } - } else { - // Keep the filter, but remove it from this context - val oldFilter = filters[itemIndex] - val newFilter = Filter( - oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, - oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord - ) - updateFilter( - newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord, - getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex - ) - } - } - - fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) { - lifecycleScope.launch { - api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold( - { filter -> - filters.add(filter) - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - }, - { - Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() - } - ) - } - } - - private fun refreshFilterDisplay() { - binding.filtersView.adapter = ArrayAdapter( - this, - android.R.layout.simple_list_item_1, - filters.map { filter -> - if (filter.expiresAt == null) { - filter.phrase - } else { - getString( - R.string.filter_expiration_format, - filter.phrase, - DateUtils.getRelativeTimeSpanString( - filter.expiresAt.time, - System.currentTimeMillis(), - DateUtils.MINUTE_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - ) - } - } - ) - binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) } - } - - private fun loadFilters() { - - binding.filterMessageView.hide() - binding.filtersView.hide() - binding.addFilterButton.hide() - binding.filterProgressBar.show() - - lifecycleScope.launch { - val newFilters = api.getFilters().getOrElse { - binding.filterProgressBar.hide() - binding.filterMessageView.show() - if (it is IOException) { - binding.filterMessageView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { loadFilters() } - } else { - binding.filterMessageView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { loadFilters() } - } - return@launch - } - - filters = newFilters.filter { it.context.contains(context) }.toMutableList() - refreshFilterDisplay() - - binding.filtersView.show() - binding.addFilterButton.show() - binding.filterProgressBar.hide() - } - } - - companion object { - const val FILTERS_CONTEXT = "filters_context" - const val FILTERS_TITLE = "filters_title" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index f7fe5c1c5..260924376 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -31,10 +31,12 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -54,6 +56,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private var unmuteTagItem: MenuItem? = null /** The filter muting hashtag, null if unknown or hashtag is not filtered */ + private var mutedFilterV1: FilterV1? = null private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -174,49 +177,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { lifecycleScope.launch { mastodonApi.getFilters().fold( { filters -> - for (filter in filters) { - if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { - Log.d(TAG, "Tag $hashtag is filtered") - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - mutedFilter = filter - return@fold + mutedFilter = filters.firstOrNull { filter -> + filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { + it.keyword == tag } } - - Log.d(TAG, "Tag $hashtag is not filtered") - mutedFilter = null - muteTagItem?.isEnabled = true - muteTagItem?.isVisible = true - muteTagItem?.isVisible = true + updateTagMuteState(mutedFilter != null) }, { throwable -> - Log.e(TAG, "Error getting filters: $throwable") + if (throwable is HttpException && throwable.code() == 404) { + mastodonApi.getFiltersV1().fold( + { filters -> + mutedFilterV1 = filters.firstOrNull { filter -> + tag == filter.phrase && filter.context.contains(FilterV1.HOME) + } + updateTagMuteState(mutedFilterV1 != null) + }, + { throwable -> + Log.e(TAG, "Error getting filters: $throwable") + } + ) + } else { + Log.e(TAG, "Error getting filters: $throwable") + } } ) } } + private fun updateTagMuteState(muted: Boolean) { + if (muted) { + muteTagItem?.isVisible = false + muteTagItem?.isEnabled = false + unmuteTagItem?.isVisible = true + } else { + unmuteTagItem?.isVisible = false + muteTagItem?.isEnabled = true + muteTagItem?.isVisible = true + } + } + private fun muteTag(): Boolean { val tag = hashtag ?: return true lifecycleScope.launch { mastodonApi.createFilter( - tag, - listOf(Filter.HOME), - irreversible = false, - wholeWord = true, - expiresInSeconds = null + title = "#$tag", + context = listOf(FilterV1.HOME), + filterAction = Filter.Action.WARN.action, + expiresInSeconds = null, ).fold( { filter -> - mutedFilter = filter - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { + mutedFilter = filter + updateTagMuteState(true) + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + } else { + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag") + } }, - { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to mute #$tag", it) + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + mastodonApi.createFilterV1( + tag, + listOf(FilterV1.HOME), + irreversible = false, + wholeWord = true, + expiresInSeconds = null + ).fold( + { filter -> + mutedFilterV1 = filter + updateTagMuteState(true) + eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + }, + { throwable -> + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag", throwable) + } + ) + } else { + Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to mute #$tag", throwable) + } } ) } @@ -225,19 +268,49 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } private fun unmuteTag(): Boolean { - val filter = mutedFilter ?: return true - lifecycleScope.launch { - mastodonApi.deleteFilter(filter.id).fold( + val tag = hashtag + val result = if (mutedFilter != null) { + val filter = mutedFilter!! + if (filter.context.size > 1) { + // This filter exists in multiple contexts, just remove the home context + mastodonApi.updateFilter( + id = filter.id, + context = filter.context.filter { it != Filter.Kind.HOME.kind }, + ) + } else { + mastodonApi.deleteFilter(filter.id) + } + } else if (mutedFilterV1 != null) { + mutedFilterV1?.let { filter -> + if (filter.context.size > 1) { + // This filter exists in multiple contexts, just remove the home context + mastodonApi.updateFilterV1( + id = filter.id, + phrase = filter.phrase, + context = filter.context.filter { it != FilterV1.HOME }, + irreversible = null, + wholeWord = null, + expiresInSeconds = null, + ) + } else { + mastodonApi.deleteFilterV1(filter.id) + } + } + } else { + null + } + + result?.fold( { - muteTagItem?.isVisible = true - unmuteTagItem?.isVisible = false - eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + updateTagMuteState(false) + eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) + mutedFilterV1 = null mutedFilter = null }, - { - Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show() - Log.e(TAG, "Failed to unmute #${filter.phrase}", it) + { throwable -> + Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to unmute #$tag", throwable) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 582abee44..3364b79da 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -43,6 +43,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; +import com.keylesspalace.tusky.entity.FilterResult; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -108,6 +110,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView cardDescription; private final TextView cardUrl; private final PollAdapter pollAdapter; + protected LinearLayout filteredPlaceholder; + protected TextView filteredPlaceholderLabel; + protected Button filteredPlaceholderShowButton; + protected ConstraintLayout statusContainer; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); @@ -160,6 +166,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardDescription = itemView.findViewById(R.id.card_description); cardUrl = itemView.findViewById(R.id.card_link); + filteredPlaceholder = itemView.findViewById(R.id.status_filtered_placeholder); + filteredPlaceholderLabel = itemView.findViewById(R.id.status_filter_label); + filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway); + statusContainer = itemView.findViewById(R.id.status_container); + pollAdapter = new PollAdapter(); pollOptions.setAdapter(pollAdapter); pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); @@ -287,7 +298,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private void setAvatar(String url, - @Nullable String rebloggedUrl, + @Nullable String rebloggedUrl, boolean isBot, StatusDisplayOptions statusDisplayOptions) { @@ -765,6 +776,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setSpoilerAndContent(status, statusDisplayOptions, listener); + setupFilterPlaceholder(status, listener, statusDisplayOptions); + setDescriptionForStatus(status, statusDisplayOptions); // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 @@ -784,6 +797,31 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) { + if (status.getFilterAction() != Filter.Action.WARN) { + showFilteredPlaceholder(false); + return; + } + + showFilteredPlaceholder(true); + + String matchedKeyword = null; + + for (FilterResult result : status.getActionable().getFiltered()) { + Filter filter = result.getFilter(); + List keywords = result.getKeywordMatches(); + if (filter.getAction() == Filter.Action.WARN && !keywords.isEmpty()) { + matchedKeyword = keywords.get(0); + break; + } + } + + filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedKeyword)); + filteredPlaceholderShowButton.setOnClickListener(view -> { + listener.clearWarningAction(getBindingAdapterPosition()); + }); + } + protected static boolean hasPreviewableAttachment(List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { @@ -1170,4 +1208,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } + + public void showFilteredPlaceholder(boolean show) { + if (statusContainer != null) { + statusContainer.setVisibility(show ? View.GONE : View.VISIBLE); + } + if (filteredPlaceholder != null) { + filteredPlaceholder.setVisibility(show ? View.VISIBLE : View.GONE); + } + } } 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 b1881272d..9d45bb05e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -28,6 +28,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -66,7 +67,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { setupCollapsedState(sensitive, expanded, status, listener); Status reblogging = status.getRebloggingStatus(); - if (reblogging == null) { + if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) { hideStatusInfo(); } else { String rebloggedByDisplayName = reblogging.getAccount().getName(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index c338a1c03..876a71ef2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -130,10 +130,11 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, + filtered = null, ), isExpanded = expanded, isShowingContent = showingHiddenContent, - isCollapsed = collapsed + isCollapsed = collapsed, ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 9ce9604b2..2d68db934 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -352,6 +352,9 @@ class ConversationsFragment : } } + override fun clearWarningAction(position: Int) { + } + override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt new file mode 100644 index 000000000..41cd2fed4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -0,0 +1,272 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.core.view.size +import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope +import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.switchmaterial.SwitchMaterial +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding +import com.keylesspalace.tusky.databinding.DialogFilterBinding +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterKeyword +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import java.util.Date +import javax.inject.Inject + +class EditFilterActivity : BaseActivity() { + @Inject + lateinit var api: MastodonApi + + @Inject + lateinit var eventHub: EventHub + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityEditFilterBinding::inflate) + private val viewModel: EditFilterViewModel by viewModels { viewModelFactory } + + private lateinit var filter: Filter + private var originalFilter: Filter? = null + private lateinit var contextSwitches: Map + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT) + filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) + binding.apply { + contextSwitches = mapOf( + filterContextHome to Filter.Kind.HOME, + filterContextNotifications to Filter.Kind.NOTIFICATIONS, + filterContextPublic to Filter.Kind.PUBLIC, + filterContextThread to Filter.Kind.THREAD, + filterContextAccount to Filter.Kind.ACCOUNT, + ) + } + + setContentView(binding.root) + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + setTitle( + if (originalFilter == null) { + R.string.filter_addition_title + } else { + R.string.filter_edit_title + } + ) + + binding.actionChip.setOnClickListener { showAddKeywordDialog() } + binding.filterSaveButton.setOnClickListener { saveChanges() } + for (switch in contextSwitches.keys) { + switch.setOnCheckedChangeListener { _, isChecked -> + val context = contextSwitches[switch]!! + if (isChecked) { + viewModel.addContext(context) + } else { + viewModel.removeContext(context) + } + validateSaveButton() + } + } + binding.filterTitle.doAfterTextChanged { editable -> + viewModel.setTitle(editable.toString()) + validateSaveButton() + } + binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> + viewModel.setAction( + if (checked) { + Filter.Action.WARN + } else { + Filter.Action.HIDE + } + ) + } + binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + viewModel.setDuration( + if (originalFilter?.expiresAt == null) { + position + } else { + position - 1 + } + ) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + viewModel.setDuration(0) + } + } + validateSaveButton() + + if (originalFilter == null) { + binding.filterActionWarn.isChecked = true + } else { + loadFilter() + } + observeModel() + } + + private fun observeModel() { + lifecycleScope.launch { + viewModel.title.collect { title -> + if (title != binding.filterTitle.text.toString()) { + // We also get this callback when typing in the field, + // which messes with the cursor focus + binding.filterTitle.setText(title) + } + } + } + lifecycleScope.launch { + viewModel.keywords.collect { keywords -> + updateKeywords(keywords) + } + } + lifecycleScope.launch { + viewModel.contexts.collect { contexts -> + for (entry in contextSwitches) { + entry.key.isChecked = contexts.contains(entry.value) + } + } + } + lifecycleScope.launch { + viewModel.action.collect { action -> + when (action) { + Filter.Action.HIDE -> binding.filterActionHide.isChecked = true + else -> binding.filterActionWarn.isChecked = true + } + } + } + } + + // Populate the UI from the filter's members + private fun loadFilter() { + viewModel.load(filter) + if (filter.expiresAt != null) { + val durationNames = listOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) + binding.filterDurationSpinner.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, durationNames) + } + } + + private fun updateKeywords(newKeywords: List) { + newKeywords.forEachIndexed { index, filterKeyword -> + val chip = binding.keywordChips.getChildAt(index).takeUnless { + it.id == R.id.actionChip + } as Chip? ?: Chip(this).apply { + setCloseIconResource(R.drawable.ic_cancel_24dp) + isCheckable = false + binding.keywordChips.addView(this, binding.keywordChips.size - 1) + } + + chip.text = if (filterKeyword.wholeWord) { + binding.root.context.getString( + R.string.filter_keyword_display_format, + filterKeyword.keyword + ) + } else { + filterKeyword.keyword + } + chip.isCloseIconVisible = true + chip.setOnClickListener { + showEditKeywordDialog(newKeywords[index]) + } + chip.setOnCloseIconClickListener { + viewModel.deleteKeyword(newKeywords[index]) + } + } + + while (binding.keywordChips.size - 1 > newKeywords.size) { + binding.keywordChips.removeViewAt(newKeywords.size) + } + + filter = filter.copy(keywords = newKeywords) + validateSaveButton() + } + + private fun showAddKeywordDialog() { + val binding = DialogFilterBinding.inflate(layoutInflater) + binding.phraseWholeWord.isChecked = true + AlertDialog.Builder(this) + .setTitle(R.string.filter_keyword_addition_title) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.addKeyword( + FilterKeyword( + "", + binding.phraseEditText.text.toString(), + binding.phraseWholeWord.isChecked, + ) + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showEditKeywordDialog(keyword: FilterKeyword) { + val binding = DialogFilterBinding.inflate(layoutInflater) + binding.phraseEditText.setText(keyword.keyword) + binding.phraseWholeWord.isChecked = keyword.wholeWord + + AlertDialog.Builder(this) + .setTitle(R.string.filter_edit_keyword_title) + .setView(binding.root) + .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> + viewModel.modifyKeyword( + keyword, + keyword.copy( + keyword = binding.phraseEditText.text.toString(), + wholeWord = binding.phraseWholeWord.isChecked, + ) + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun validateSaveButton() { + binding.filterSaveButton.isEnabled = viewModel.validate() + } + + private fun saveChanges() { + lifecycleScope.launch { + if (viewModel.saveChanges(this@EditFilterActivity)) { + finish() + } else { + Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() + } + } + } + + companion object { + const val FILTER_TO_EDIT = "FilterToEdit" + + // Mastodon *stores* the absolute date in the filter, + // but create/edit take a number of seconds (relative to the time the operation is posted) + fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { + return when (index) { + -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } + 0 -> null + else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt new file mode 100644 index 000000000..07e6d25c8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -0,0 +1,186 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterKeyword +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import javax.inject.Inject + +class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { + private var originalFilter: Filter? = null + val title = MutableStateFlow("") + val keywords = MutableStateFlow(listOf()) + val action = MutableStateFlow(Filter.Action.WARN) + val duration = MutableStateFlow(0) + val contexts = MutableStateFlow(listOf()) + + fun load(filter: Filter) { + originalFilter = filter + title.value = filter.title + keywords.value = filter.keywords + action.value = filter.action + duration.value = if (filter.expiresAt == null) { + 0 + } else { + -1 + } + contexts.value = filter.kinds + } + + fun addKeyword(keyword: FilterKeyword) { + keywords.value += keyword + } + + fun deleteKeyword(keyword: FilterKeyword) { + keywords.value = keywords.value.filterNot { it == keyword } + } + + fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { + val index = keywords.value.indexOf(original) + if (index >= 0) { + keywords.value = keywords.value.toMutableList().apply { + set(index, updated) + } + } + } + + fun setTitle(title: String) { + this.title.value = title + } + + fun setDuration(index: Int) { + duration.value = index + } + + fun setAction(action: Filter.Action) { + this.action.value = action + } + + fun addContext(context: Filter.Kind) { + if (!contexts.value.contains(context)) { + contexts.value += context + } + } + + fun removeContext(context: Filter.Kind) { + contexts.value = contexts.value.filter { it != context } + } + + fun validate(): Boolean { + return title.value.isNotBlank() && + keywords.value.isNotEmpty() && + contexts.value.isNotEmpty() + } + + suspend fun saveChanges(context: Context): Boolean { + val contexts = contexts.value.map { it.kind } + val title = title.value + val durationIndex = duration.value + val action = action.value.action + + return withContext(viewModelScope.coroutineContext) { + originalFilter?.let { filter -> + updateFilter(filter, title, contexts, action, durationIndex, context) + } ?: createFilter(title, contexts, action, durationIndex, context) + } + } + + private suspend fun createFilter(title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + api.createFilter( + title = title, + context = contexts, + filterAction = action, + expiresInSeconds = expiresInSeconds, + ).fold( + { newFilter -> + // This is _terrible_, but the all-in-one update filter api Just Doesn't Work + return keywords.value.map { keyword -> + api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + }.none { it.isFailure } + }, + { throwable -> + return ( + throwable is HttpException && throwable.code() == 404 && + // Endpoint not found, fall back to v1 api + createFilterV1(contexts, expiresInSeconds) + ) + } + ) + } + + private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + api.updateFilter( + id = originalFilter.id, + title = title, + context = contexts, + filterAction = action, + expiresInSeconds = expiresInSeconds, + ).fold( + { + // This is _terrible_, but the all-in-one update filter api Just Doesn't Work + val results = keywords.value.map { keyword -> + if (keyword.id.isEmpty()) { + api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + } else { + api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + } + } + originalFilter.keywords.filter { keyword -> + // Deleted keywords + keywords.value.none { it.id == keyword.id } + }.map { api.deleteFilterKeyword(it.id) } + + return results.none { it.isFailure } + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + // Endpoint not found, fall back to v1 api + if (updateFilterV1(contexts, expiresInSeconds)) { + return true + } + } + return false + } + ) + } + + private suspend fun createFilterV1(context: List, expiresInSeconds: Int?): Boolean { + return keywords.value.map { keyword -> + api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds) + }.none { it.isFailure } + } + + private suspend fun updateFilterV1(context: List, expiresInSeconds: Int?): Boolean { + val results = keywords.value.map { keyword -> + if (originalFilter == null) { + api.createFilterV1( + phrase = keyword.keyword, + context = context, + irreversible = false, + wholeWord = keyword.wholeWord, + expiresInSeconds = expiresInSeconds + ) + } else { + api.updateFilterV1( + id = originalFilter!!.id, + phrase = keyword.keyword, + context = context, + irreversible = false, + wholeWord = keyword.wholeWord, + expiresInSeconds = expiresInSeconds, + ) + } + } + // Don't handle deleted keywords here because there's only one keyword per v1 filter anyway + + return results.none { it.isFailure } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt new file mode 100644 index 000000000..e56a82535 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -0,0 +1,106 @@ +package com.keylesspalace.tusky.components.filters + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityFiltersBinding +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class FiltersActivity : BaseActivity(), FiltersListener { + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(ActivityFiltersBinding::inflate) + private val viewModel: FiltersViewModel by viewModels { viewModelFactory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setSupportActionBar(binding.includedToolbar.toolbar) + supportActionBar?.run { + // Back button + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + binding.addFilterButton.setOnClickListener { + launchEditFilterActivity() + } + + setTitle(R.string.pref_title_timeline_filters) + } + + override fun onResume() { + super.onResume() + loadFilters() + observeViewModel() + } + + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.filters.collect { filters -> + binding.filtersView.show() + binding.addFilterButton.show() + binding.filterProgressBar.hide() + refreshFilterDisplay(filters) + } + } + + lifecycleScope.launch { + viewModel.error.collect { error -> + if (error is IOException) { + binding.filterMessageView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { loadFilters() } + } else { + binding.filterMessageView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { loadFilters() } + } + } + } + } + + private fun refreshFilterDisplay(filters: List) { + binding.filtersView.adapter = FiltersAdapter(this, filters) + } + + private fun loadFilters() { + binding.filterMessageView.hide() + binding.filtersView.hide() + binding.addFilterButton.hide() + binding.filterProgressBar.show() + + viewModel.load() + } + + private fun launchEditFilterActivity(filter: Filter? = null) { + val intent = Intent(this, EditFilterActivity::class.java).apply { + if (filter != null) { + putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) + } + } + startActivity(intent) + overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + override fun deleteFilter(filter: Filter) { + viewModel.deleteFilter(filter, binding.root) + } + + override fun updateFilter(updatedFilter: Filter) { + launchEditFilterActivity(updatedFilter) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt new file mode 100644 index 000000000..f6e6791a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt @@ -0,0 +1,52 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemRemovableBinding +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.getRelativeTimeSpanString + +class FiltersAdapter(val listener: FiltersListener, val filters: List) : + RecyclerView.Adapter>() { + + override fun getItemCount(): Int = filters.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val binding = holder.binding + val resources = binding.root.resources + val actions = resources.getStringArray(R.array.filter_actions) + val contexts = resources.getStringArray(R.array.filter_contexts) + + val filter = filters[position] + val context = binding.root.context + binding.textPrimary.text = if (filter.expiresAt == null) { + filter.title + } else { + context.getString( + R.string.filter_expiration_format, + filter.title, + getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis()) + ) + } + binding.textSecondary.text = context.getString( + R.string.filter_description_format, + actions.getOrNull(filter.action.ordinal - 1), + filter.context.map { contexts.getOrNull(Filter.Kind.from(it).ordinal) }.joinToString("/") + ) + + binding.delete.setOnClickListener { + listener.deleteFilter(filter) + } + + binding.root.setOnClickListener { + listener.updateFilter(filter) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt new file mode 100644 index 000000000..a102b0d69 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt @@ -0,0 +1,8 @@ +package com.keylesspalace.tusky.components.filters + +import com.keylesspalace.tusky.entity.Filter + +interface FiltersListener { + fun deleteFilter(filter: Filter) + fun updateFilter(updatedFilter: Filter) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt new file mode 100644 index 000000000..7fb07a075 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -0,0 +1,74 @@ +package com.keylesspalace.tusky.components.filters + +import android.view.View +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import retrofit2.HttpException +import javax.inject.Inject + +class FiltersViewModel @Inject constructor( + private val api: MastodonApi, + private val eventHub: EventHub +) : ViewModel() { + val filters: MutableStateFlow> = MutableStateFlow(listOf()) + val error: MutableStateFlow = MutableStateFlow(null) + + fun load() { + viewModelScope.launch { + api.getFilters().fold( + { filters -> + this@FiltersViewModel.filters.value = filters + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.getFiltersV1().fold( + { filters -> + this@FiltersViewModel.filters.value = filters.map { it.toFilter() } + }, + { throwable -> + error.value = throwable + } + ) + } else { + error.value = throwable + } + } + ) + } + } + + fun deleteFilter(filter: Filter, parent: View) { + viewModelScope.launch { + api.deleteFilter(filter.id).fold( + { + filters.value = filters.value.filter { it.id != filter.id } + for (context in filter.context) { + eventHub.dispatch(PreferenceChangedEvent(context)) + } + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + api.deleteFilterV1(filter.id).fold( + { + filters.value = filters.value.filter { it.id != filter.id } + }, + { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + }, + ) + } else { + Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + } + } + ) + } + } +} 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 b79156ebf..be7ef9101 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 @@ -555,6 +555,9 @@ class NotificationsFragment : onContentCollapsedChange(isCollapsed, position) } + override fun clearWarningAction(position: Int) { + } + private fun clearNotifications() { binding.swipeRefreshLayout.isRefreshing = false binding.progressBar.isVisible = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index 61b759f1c..fa1d7392f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -26,12 +26,12 @@ import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity @@ -39,7 +39,6 @@ import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigra import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.AccountPreferenceHandler @@ -177,6 +176,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + preference { + setTitle(R.string.pref_title_timeline_filters) + setIcon(R.drawable.ic_filter_24dp) + setOnPreferenceClickListener { + launchFilterActivity() + true + } + } + preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) @@ -261,48 +269,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceDataStore = accountPreferenceHandler } } - - preferenceCategory(R.string.pref_title_timeline_filters) { - preference { - setTitle(R.string.pref_title_public_filter_keywords) - setOnPreferenceClickListener { - launchFilterActivity(Filter.PUBLIC, R.string.pref_title_public_filter_keywords) - true - } - } - - preference { - setTitle(R.string.title_notifications) - setOnPreferenceClickListener { - launchFilterActivity(Filter.NOTIFICATIONS, R.string.title_notifications) - true - } - } - - preference { - setTitle(R.string.title_home) - setOnPreferenceClickListener { - launchFilterActivity(Filter.HOME, R.string.title_home) - true - } - } - - preference { - setTitle(R.string.pref_title_thread_filter_keywords) - setOnPreferenceClickListener { - launchFilterActivity(Filter.THREAD, R.string.pref_title_thread_filter_keywords) - true - } - } - - preference { - setTitle(R.string.title_accounts) - setOnPreferenceClickListener { - launchFilterActivity(Filter.ACCOUNT, R.string.title_accounts) - true - } - } - } } } @@ -383,10 +349,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private fun launchFilterActivity(filterContext: String, titleResource: Int) { + private fun launchFilterActivity() { val intent = Intent(context, FiltersActivity::class.java) - intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext) - intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource)) activity?.startActivity(intent) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 1b3c39f95..23ebbef60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -190,6 +190,8 @@ class SearchStatusesFragment : SearchFragment(), Status } } + override fun clearWarningAction(position: Int) {} + private fun removeItem(position: Int) { searchAdapter.peek(position)?.let { viewModel.removeItem(it) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index ace770d31..b0ae1cf77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -424,6 +424,11 @@ class TimelineFragment : viewModel.voteInPoll(choices, status) } + override fun clearWarningAction(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.clearWarning(status) + } + override fun onMore(view: View, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return super.more(status.status, view, position) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 0ea0b958a..09557a50e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData @@ -46,21 +47,16 @@ class TimelinePagingAdapter( } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { - VIEW_TYPE_STATUS -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status, viewGroup, false) - StatusViewHolder(view) + VIEW_TYPE_STATUS_FILTERED -> { + StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } VIEW_TYPE_PLACEHOLDER -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status_placeholder, viewGroup, false) - PlaceholderViewHolder(view) + PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)) } else -> { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_status, viewGroup, false) - StatusViewHolder(view) + StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) } } } @@ -98,8 +94,11 @@ class TimelinePagingAdapter( } override fun getItemViewType(position: Int): Int { - return if (getItem(position) is StatusViewData.Placeholder) { + val viewData = getItem(position) + return if (viewData is StatusViewData.Placeholder) { VIEW_TYPE_PLACEHOLDER + } else if (viewData?.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } @@ -107,6 +106,7 @@ class TimelinePagingAdapter( companion object { private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_FILTERED = 1 private const val VIEW_TYPE_PLACEHOLDER = 2 val TimelineDifferCallback = object : DiffUtil.ItemCallback() { 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 24bc544d8..d154caaab 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 @@ -105,6 +105,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { card = null, repliesCount = 0, language = null, + filtered = null, ) } @@ -149,6 +150,7 @@ fun Status.toEntity( card = actionableStatus.card?.let(gson::toJson), repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, + filtered = actionableStatus.filtered, ) } @@ -196,6 +198,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered, ) } val status = if (reblog != null) { @@ -228,6 +231,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = null, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered, ) } else { Status( @@ -259,6 +263,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false card = card, repliesCount = status.repliesCount, language = status.language, + filtered = status.filtered, ) } return StatusViewData.Concrete( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index a8eaaf326..7bc47e925 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -41,6 +41,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -100,7 +101,7 @@ class CachedTimelineViewModel @Inject constructor( pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> timelineStatus.toViewData(gson) }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) @@ -152,6 +153,12 @@ class CachedTimelineViewModel @Inject constructor( } } + override fun clearWarning(status: StatusViewData.Concrete) { + viewModelScope.launch { + db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + } + } + override fun removeStatusWithId(id: String) { // handled by CacheUpdater } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f569b57f0..91d28d803 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel @@ -82,7 +83,7 @@ class NetworkTimelineViewModel @Inject constructor( ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - !shouldFilterStatus(statusViewData) + shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) @@ -248,6 +249,12 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override fun clearWarning(status: StatusViewData.Concrete) { + updateActionableStatusById(status.actionableId) { + it.copy(filtered = null) + } + } + override suspend fun invalidate() { currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 968b27438..c31606a4d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -20,6 +20,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent @@ -38,6 +39,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesFragment.Reading import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.await +import retrofit2.HttpException abstract class TimelineViewModel( private val timelineCases: TimelineCases, @@ -82,6 +85,7 @@ abstract class TimelineViewModel( this.kind = kind this.id = id this.tags = tags + filterModel.kind = kind.toFilterKind() if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" @@ -178,14 +182,22 @@ abstract class TimelineViewModel( abstract fun fullReload() + abstract fun clearWarning(status: StatusViewData.Concrete) + /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() - protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { - val status = statusViewData.asStatusOrNull()?.status ?: return false - return status.inReplyToId != null && filterRemoveReplies || - status.reblog != null && filterRemoveReblogs || - filterModel.shouldFilterStatus(status.actionableStatus) + protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { + val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE + return if ( + (status.inReplyToId != null && filterRemoveReplies) || + (status.reblog != null && filterRemoveReblogs) + ) { + return Filter.Action.HIDE + } else { + statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) + statusViewData.filterAction + } } private fun onPreferenceChanged(key: String) { @@ -206,7 +218,7 @@ abstract class TimelineViewModel( fullReload() } } - Filter.HOME, Filter.NOTIFICATIONS, Filter.THREAD, Filter.PUBLIC, Filter.ACCOUNT -> { + FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { if (filterContextMatchesKind(kind, listOf(key))) { reloadFilters() } @@ -222,28 +234,6 @@ abstract class TimelineViewModel( } } - private fun filterContextMatchesKind( - kind: Kind, - filterContext: List - ): Boolean { - // home, notifications, public, thread - return when (kind) { - Kind.HOME, Kind.LIST -> filterContext.contains( - Filter.HOME - ) - Kind.PUBLIC_FEDERATED, Kind.PUBLIC_LOCAL, Kind.TAG -> filterContext.contains( - Filter.PUBLIC - ) - Kind.FAVOURITES -> filterContext.contains(Filter.PUBLIC) || filterContext.contains( - Filter.NOTIFICATIONS - ) - Kind.USER, Kind.USER_WITH_REPLIES, Kind.USER_PINNED -> filterContext.contains( - Filter.ACCOUNT - ) - else -> false - } - } - private fun handleEvent(event: Event) { when (event) { is FavoriteEvent -> handleFavEvent(event) @@ -288,27 +278,57 @@ abstract class TimelineViewModel( private fun reloadFilters() { viewModelScope.launch { - val filters = api.getFilters().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) - } + api.getFilters().fold( + { + // After the filters are loaded we need to reload displayed content to apply them. + // It can happen during the usage or at startup, when we get statuses before filters. + invalidate() + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + // Fallback to client-side filter code + val filters = api.getFiltersV1().getOrElse { + Log.e(TAG, "Failed to fetch filters", it) + return@launch + } + filterModel.initWithFilters( + filters.filter { + filterContextMatchesKind(kind, it.context) + } + ) + // After the filters are loaded we need to reload displayed content to apply them. + // It can happen during the usage or at startup, when we get statuses before filters. + invalidate() + } else { + Log.e(TAG, "Error getting filters", throwable) + } + }, ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() } } companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 + + fun filterContextMatchesKind( + kind: Kind, + filterContext: List + ): Boolean { + return filterContext.contains(kind.toFilterKind().kind) + } } enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS + HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; + + fun toFilterKind(): Filter.Kind { + return when (valueOf(name)) { + HOME, LIST -> Filter.Kind.HOME + PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC + USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT + else -> Filter.Kind.PUBLIC + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt index 500313eff..5f8f0bfc1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt @@ -80,11 +80,15 @@ class TrendingViewModel @Inject constructor( } val homeFilters = deferredFilters.await().getOrNull()?.filter { - it.context.contains(Filter.HOME) + it.context.contains(Filter.Kind.HOME.kind) } val tags = response.body()!! - .filter { homeFilters?.none { filter -> filter.phrase.equals(it.name, ignoreCase = true) } ?: false } + .filter { + homeFilters?.none { filter -> + filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) } + } ?: false + } .sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } .map { it.toViewData() } .asReversed() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 7f900de6c..3abd47d01 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -23,6 +23,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData @@ -33,16 +34,16 @@ class ThreadAdapter( ) : ListAdapter(ThreadDifferCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_STATUS -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status, parent, false) - StatusViewHolder(view) + StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) + } + VIEW_TYPE_STATUS_FILTERED -> { + StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false)) } VIEW_TYPE_STATUS_DETAILED -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status_detailed, parent, false) - StatusDetailedViewHolder(view) + StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false)) } else -> error("Unknown item type: $viewType") } @@ -54,8 +55,11 @@ class ThreadAdapter( } override fun getItemViewType(position: Int): Int { - return if (getItem(position).isDetailed) { + val viewData = getItem(position) + return if (viewData.isDetailed) { VIEW_TYPE_STATUS_DETAILED + } else if (viewData.filterAction == Filter.Action.WARN) { + VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } @@ -65,6 +69,7 @@ class ThreadAdapter( private const val TAG = "ThreadAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_DETAILED = 1 + private const val VIEW_TYPE_STATUS_FILTERED = 2 val ThreadDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index c780ffeb1..6a244c0a7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -436,6 +436,10 @@ class ViewThreadFragment : } } + override fun clearWarningAction(position: Int) { + viewModel.clearWarning(adapter.currentList[position]) + } + companion object { private const val TAG = "ViewThreadFragment" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 5f497246c..327931026 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.await +import retrofit2.HttpException import javax.inject.Inject class ViewThreadViewModel @Inject constructor( @@ -414,30 +416,48 @@ class ViewThreadViewModel @Inject constructor( private fun loadFilters() { viewModelScope.launch { - val filters = api.getFilters().getOrElse { - Log.w(TAG, "Failed to fetch filters", it) - return@launch - } + api.getFilters().fold( + { + filterModel.kind = Filter.Kind.THREAD + updateStatuses() + }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + val filters = api.getFiltersV1().getOrElse { + Log.w(TAG, "Failed to fetch filters", it) + return@launch + } - filterModel.initWithFilters( - filters.filter { filter -> - filter.context.contains(Filter.THREAD) + filterModel.initWithFilters( + filters.filter { filter -> filter.context.contains(FilterV1.THREAD) } + ) + updateStatuses() + } else { + Log.e(TAG, "Error getting filters", throwable) + } } ) + } + } - updateSuccess { uiState -> - val statuses = uiState.statusViewData.filter() - uiState.copy( - statusViewData = statuses, - revealButton = statuses.getRevealButtonState() - ) - } + private fun updateStatuses() { + updateSuccess { uiState -> + val statuses = uiState.statusViewData.filter() + uiState.copy( + statusViewData = statuses, + revealButton = statuses.getRevealButtonState() + ) } } private fun List.filter(): List { return filter { status -> - status.isDetailed || !filterModel.shouldFilterStatus(status.status) + if (status.isDetailed) { + true + } else { + status.filterAction = filterModel.shouldFilterStatus(status.status) + status.filterAction != Filter.Action.HIDE + } } } @@ -485,6 +505,12 @@ class ViewThreadViewModel @Inject constructor( } } + fun clearWarning(viewData: StatusViewData.Concrete) { + updateStatus(viewData.id) { status -> + status.copy(filtered = null) + } + } + companion object { private const val TAG = "ViewThreadViewModel" } 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 7e3ecf731..c64d29ed5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 47) + }, version = 48) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -339,7 +339,7 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER"); } }; - + public static final Migration MIGRATION_23_24 = new Migration(23, 24) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { @@ -644,6 +644,14 @@ public abstract class AppDatabase extends RoomDatabase { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); + } + }; + + public static final Migration MIGRATION_47_48 = new Migration(47, 48) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); } }; } 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 9b858b329..6ef942545 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll @@ -164,4 +165,14 @@ class Converters @Inject constructor( fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) } + + @TypeConverter + fun filterResultListToJson(filterResults: List?): String? { + return gson.toJson(filterResults) + } + + @TypeConverter + fun jsonToFilterResultList(filterResultListJson: String?): List? { + return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 18f2f4d44..17a25ac57 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', 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', @@ -203,6 +203,9 @@ AND timelineUserId = :accountId ) abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) + @Query("UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)") + abstract suspend fun clearWarning(accountId: Long, statusId: String): Int + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getTopId(accountId: Long): String? diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index ff63faf81..0b5fcfb1a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -20,6 +20,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.Status /** @@ -84,6 +85,7 @@ data class TimelineStatusEntity( val pinned: Boolean, val card: String?, val language: String?, + val filtered: List?, ) { val isPlaceholder: Boolean get() = this.authorServerId == null diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index bbbd53371..2ceb97213 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AboutActivity import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.EditProfileActivity -import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.MainActivity @@ -31,6 +30,8 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity +import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity @@ -128,4 +129,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesTrendingActivity(): TrendingActivity + + @ContributesAndroidInjector + abstract fun contributesEditFilterActivity(): EditFilterActivity } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 3dacc2d68..758f8d24c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -67,7 +67,8 @@ class AppModule { AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, - AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47 + AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, + AppDatabase.MIGRATION_47_48, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index e3ce3a3e6..af1972d5f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -28,6 +28,8 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel +import com.keylesspalace.tusky.components.filters.EditFilterViewModel +import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel import com.keylesspalace.tusky.components.notifications.NotificationsViewModel @@ -173,5 +175,15 @@ abstract class ViewModelModule { @ViewModelKey(TrendingViewModel::class) internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(FiltersViewModel::class) + internal abstract fun filtersViewModel(viewModel: FiltersViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(EditFilterViewModel::class) + internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index af51a04b9..3e529bfe0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -1,48 +1,44 @@ -/* Copyright 2018 Levi Bard - * - * 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 android.os.Parcelable import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize import java.util.Date +@Parcelize data class Filter( val id: String, - val phrase: String, + val title: String, val context: List, @SerializedName("expires_at") val expiresAt: Date?, - val irreversible: Boolean, - @SerializedName("whole_word") val wholeWord: Boolean -) { - companion object { - const val HOME = "home" - const val NOTIFICATIONS = "notifications" - const val PUBLIC = "public" - const val THREAD = "thread" - const val ACCOUNT = "account" - } + @SerializedName("filter_action") private val filterAction: String, + val keywords: List, + // val statuses: List, +) : Parcelable { + enum class Action(val action: String) { + NONE("none"), + WARN("warn"), + HIDE("hide"); - override fun hashCode(): Int { - return id.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is Filter) { - return false + companion object { + fun from(action: String): Action = values().firstOrNull { it.action == action } ?: WARN } - val filter = other as Filter? - return filter?.id.equals(id) } + enum class Kind(val kind: String) { + HOME("home"), + NOTIFICATIONS("notifications"), + PUBLIC("public"), + THREAD("thread"), + ACCOUNT("account"); + + companion object { + fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC + } + } + + val action: Action + get() = Action.from(filterAction) + + val kinds: List + get() = context.map { Kind.from(it) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt new file mode 100644 index 000000000..131540c14 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt @@ -0,0 +1,12 @@ +package com.keylesspalace.tusky.entity + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FilterKeyword( + val id: String, + val keyword: String, + @SerializedName("whole_word") val wholeWord: Boolean, +) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt new file mode 100644 index 000000000..79179bd05 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class FilterResult( + val filter: Filter, + @SerializedName("keyword_matches") val keywordMatches: List?, + @SerializedName("status_matches") val statusMatches: String?, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt new file mode 100644 index 000000000..c5b2d1695 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -0,0 +1,65 @@ +/* Copyright 2018 Levi Bard + * + * 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.google.gson.annotations.SerializedName +import java.util.Date + +data class FilterV1( + val id: String, + val phrase: String, + val context: List, + @SerializedName("expires_at") val expiresAt: Date?, + val irreversible: Boolean, + @SerializedName("whole_word") val wholeWord: Boolean +) { + companion object { + const val HOME = "home" + const val NOTIFICATIONS = "notifications" + const val PUBLIC = "public" + const val THREAD = "thread" + const val ACCOUNT = "account" + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is FilterV1) { + return false + } + val filter = other as FilterV1? + return filter?.id.equals(id) + } + + fun toFilter(): Filter { + return Filter( + id = id, + title = phrase, + context = context, + expiresAt = expiresAt, + filterAction = Filter.Action.WARN.action, + keywords = listOf( + FilterKeyword( + id = id, + keyword = phrase, + wholeWord = wholeWord, + ) + ) + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 619017614..b6c26f891 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -51,6 +51,7 @@ data class Status( val poll: Poll?, val card: Card?, val language: String?, + val filtered: List?, ) { val actionableId: String diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 9171b420a..e142683a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -64,5 +64,7 @@ public interface StatusActionListener extends LinkListener { void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} + + void clearWarningAction(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 2707dbbf6..0d13373a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -1,6 +1,7 @@ package com.keylesspalace.tusky.network import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date @@ -15,36 +16,48 @@ import javax.inject.Inject */ class FilterModel @Inject constructor() { private var pattern: Pattern? = null + private var v1 = false + lateinit var kind: Filter.Kind - fun initWithFilters(filters: List) { + fun initWithFilters(filters: List) { + v1 = true this.pattern = makeFilter(filters) } - fun shouldFilterStatus(status: Status): Boolean { - // Patterns are expensive and thread-safe, matchers are neither. - val matcher = pattern?.matcher("") ?: return false + fun shouldFilterStatus(status: Status): Filter.Action { + if (v1) { + // Patterns are expensive and thread-safe, matchers are neither. + val matcher = pattern?.matcher("") ?: return Filter.Action.NONE - if (status.poll != null) { - val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } - if (pollMatches) return true + if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) + return Filter.Action.HIDE + + val spoilerText = status.actionableStatus.spoilerText + val attachmentsDescriptions = status.attachments.mapNotNull { it.description } + + return if ( + matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || + (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || + (attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find()) + ) { + Filter.Action.HIDE + } else { + Filter.Action.NONE + } } - val spoilerText = status.actionableStatus.spoilerText - val attachmentsDescriptions = status.attachments - .mapNotNull { it.description } + val matchingKind = status.filtered?.filter { result -> + result.filter.kinds.contains(kind) + } - return ( - matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || - (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || - ( - attachmentsDescriptions.isNotEmpty() && - matcher.reset(attachmentsDescriptions.joinToString("\n")) - .find() - ) - ) + return if (matchingKind.isNullOrEmpty()) { + Filter.Action.NONE + } else { + matchingKind.maxOf { it.filter.action } + } } - private fun filterToRegexToken(filter: Filter): String? { + private fun filterToRegexToken(filter: FilterV1): String? { val phrase = filter.phrase val quotedPhrase = Pattern.quote(phrase) return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { @@ -54,7 +67,7 @@ class FilterModel @Inject constructor() { } } - private fun makeFilter(filters: List): Pattern? { + private fun makeFilter(filters: List): Pattern? { val now = Date() val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } if (nonExpiredFilters.isEmpty()) return null diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index a94c8a350..88f5480e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -25,6 +25,8 @@ import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterKeyword +import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Marker @@ -85,6 +87,9 @@ interface MastodonApi { suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") + suspend fun getFiltersV1(): NetworkResult> + + @GET("api/v2/filters") suspend fun getFilters(): NetworkResult> @GET("api/v1/timelines/home") @@ -572,30 +577,75 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/filters") - suspend fun createFilter( + suspend fun createFilterV1( @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresInSeconds: Int? - ): NetworkResult + ): NetworkResult @FormUrlEncoded @PUT("api/v1/filters/{id}") - suspend fun updateFilter( + suspend fun updateFilterV1( @Path("id") id: String, @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresInSeconds: Int? - ): NetworkResult + ): NetworkResult @DELETE("api/v1/filters/{id}") + suspend fun deleteFilterV1( + @Path("id") id: String + ): NetworkResult + + @FormUrlEncoded + @POST("api/v2/filters") + suspend fun createFilter( + @Field("title") title: String, + @Field("context[]") context: List, + @Field("filter_action") filterAction: String, + @Field("expires_in") expiresInSeconds: Int?, + ): NetworkResult + + @FormUrlEncoded + @PUT("api/v2/filters/{id}") + suspend fun updateFilter( + @Path("id") id: String, + @Field("title") title: String? = null, + @Field("context[]") context: List? = null, + @Field("filter_action") filterAction: String? = null, + @Field("expires_in") expiresInSeconds: Int? = null, + ): NetworkResult + + @DELETE("api/v2/filters/{id}") suspend fun deleteFilter( @Path("id") id: String ): NetworkResult + @FormUrlEncoded + @POST("api/v2/filters/{filterId}/keywords") + suspend fun addFilterKeyword( + @Path("filterId") filterId: String, + @Field("keyword") keyword: String, + @Field("whole_word") wholeWord: Boolean, + ): NetworkResult + + @FormUrlEncoded + @PUT("api/v2/filters/keywords/{keywordId}") + suspend fun updateFilterKeyword( + @Path("keywordId") keywordId: String, + @Field("keyword") keyword: String, + @Field("whole_word") wholeWord: Boolean, + ): NetworkResult + + @DELETE("api/v2/filters/keywords/{keywordId}") + suspend fun deleteFilterKeyword( + @Path("keywordId") keywordId: String, + ): NetworkResult + @FormUrlEncoded @POST("api/v1/polls/{id}/votes") fun voteInPoll( diff --git a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt deleted file mode 100644 index c6cea1e21..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.keylesspalace.tusky.view - -import android.content.Context -import android.widget.ArrayAdapter -import androidx.appcompat.app.AlertDialog -import com.keylesspalace.tusky.FiltersActivity -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.DialogFilterBinding -import com.keylesspalace.tusky.entity.Filter -import java.util.Date - -fun showAddFilterDialog(activity: FiltersActivity) { - val binding = DialogFilterBinding.inflate(activity.layoutInflater) - binding.phraseWholeWord.isChecked = true - binding.filterDurationSpinner.adapter = ArrayAdapter( - activity, - android.R.layout.simple_list_item_1, - activity.resources.getStringArray(R.array.filter_duration_names) - ) - AlertDialog.Builder(activity) - .setTitle(R.string.filter_addition_dialog_title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - activity.createFilter( - binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked, - getSecondsForDurationIndex(binding.filterDurationSpinner.selectedItemPosition, activity) - ) - } - .setNeutralButton(android.R.string.cancel, null) - .show() -} - -fun setupEditDialogForFilter(activity: FiltersActivity, filter: Filter, itemIndex: Int) { - val binding = DialogFilterBinding.inflate(activity.layoutInflater) - binding.phraseEditText.setText(filter.phrase) - binding.phraseWholeWord.isChecked = filter.wholeWord - val filterNames = activity.resources.getStringArray(R.array.filter_duration_names).toMutableList() - if (filter.expiresAt != null) { - filterNames.add(0, activity.getString(R.string.duration_no_change)) - } - binding.filterDurationSpinner.adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, filterNames) - - AlertDialog.Builder(activity) - .setTitle(R.string.filter_edit_dialog_title) - .setView(binding.root) - .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> - var index = binding.filterDurationSpinner.selectedItemPosition - if (filter.expiresAt != null) { - // We prepended "No changes", account for that here - --index - } - activity.updateFilter( - filter.id, binding.phraseEditText.text.toString(), filter.context, - filter.irreversible, binding.phraseWholeWord.isChecked, - getSecondsForDurationIndex(index, activity, filter.expiresAt), itemIndex - ) - } - .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> - activity.deleteFilter(itemIndex) - } - .setNeutralButton(android.R.string.cancel, null) - .show() -} - -// Mastodon *stores* the absolute date in the filter, -// but create/edit take a number of seconds (relative to the time the operation is posted) -fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { - return when (index) { - -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } - 0 -> null - else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 07b7f3db3..b4ce41857 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.viewdata import android.os.Build import android.text.Spanned +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.replaceCrashingCharacters @@ -29,6 +30,7 @@ import com.keylesspalace.tusky.util.shouldTrimStatus */ sealed class StatusViewData { abstract val id: String + var filterAction: Filter.Action = Filter.Action.NONE data class Concrete( val status: Status, @@ -41,7 +43,7 @@ sealed class StatusViewData { * @return Whether the post is collapsed or fully expanded. */ val isCollapsed: Boolean, - val isDetailed: Boolean = false + val isDetailed: Boolean = false, ) : StatusViewData() { override val id: String get() = status.id diff --git a/app/src/main/res/drawable/ic_filter_24dp.xml b/app/src/main/res/drawable/ic_filter_24dp.xml new file mode 100644 index 000000000..ccb8fd223 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml new file mode 100644 index 000000000..59a0658e5 --- /dev/null +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +