From ff8dd37855d8926a59fedb63c7fb5198bdf988e5 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Sat, 11 Mar 2023 13:12:50 +0100 Subject: [PATCH] Support the mastodon 4 filter api (#3188) * Replace "warn"-filtered posts in timelines and thread view with placeholders * Adapt hashtag muting interface * Rework filter UI * Add icon for account preferences * Clean up UI * WIP: Use chips instead of a list. Adjust padding * Scroll the filter edit activity Nested scrolling views (e.g., an activity that scrolls with an embedded list that also scrolls) can be difficult UI. Since the list of contexts is fixed, replace it with a fixed collection of switches, so there's no need to scroll the list. Since the list of actions is only two (warn, hide), and are mutually exclusive, replace the spinner with two radio buttons. Use the accent colour and title styles on the different heading titles in the layout, to match the presentation in Preferences. Add an explicit "Cancel" button. The layout is a straightforward LinearLayout, so use that instead of ConstraintLayout, and remove some unncessary IDs. Update EditFilterActivity to handle the new layout. * Cleanup * Add more information to the filter list view * First pass on code review comments * Add view model to filters activity * Add view model to edit filters activity * Only use the status wrapper for filtered statuses * Relint --------- Co-authored-by: Nik Clayton --- .../48.json | 995 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 3 +- .../keylesspalace/tusky/FiltersActivity.kt | 183 ---- .../keylesspalace/tusky/StatusListActivity.kt | 143 ++- .../tusky/adapter/StatusBaseViewHolder.java | 49 +- .../tusky/adapter/StatusViewHolder.java | 3 +- .../conversation/ConversationEntity.kt | 3 +- .../conversation/ConversationsFragment.kt | 3 + .../components/filters/EditFilterActivity.kt | 272 +++++ .../components/filters/EditFilterViewModel.kt | 186 ++++ .../components/filters/FiltersActivity.kt | 106 ++ .../components/filters/FiltersAdapter.kt | 52 + .../components/filters/FiltersListener.kt | 8 + .../components/filters/FiltersViewModel.kt | 74 ++ .../notifications/NotificationsFragment.kt | 3 + .../preference/AccountPreferencesFragment.kt | 58 +- .../fragments/SearchStatusesFragment.kt | 2 + .../components/timeline/TimelineFragment.kt | 5 + .../timeline/TimelinePagingAdapter.kt | 22 +- .../timeline/TimelineTypeMappers.kt | 5 + .../viewmodel/CachedTimelineViewModel.kt | 9 +- .../viewmodel/NetworkTimelineViewModel.kt | 9 +- .../timeline/viewmodel/TimelineViewModel.kt | 100 +- .../trending/viewmodel/TrendingViewModel.kt | 8 +- .../components/viewthread/ThreadAdapter.kt | 19 +- .../viewthread/ViewThreadFragment.kt | 4 + .../viewthread/ViewThreadViewModel.kt | 56 +- .../keylesspalace/tusky/db/AppDatabase.java | 12 +- .../com/keylesspalace/tusky/db/Converters.kt | 11 + .../com/keylesspalace/tusky/db/TimelineDao.kt | 5 +- .../tusky/db/TimelineStatusEntity.kt | 2 + .../tusky/di/ActivitiesModule.kt | 6 +- .../com/keylesspalace/tusky/di/AppModule.kt | 3 +- .../tusky/di/ViewModelFactory.kt | 12 + .../com/keylesspalace/tusky/entity/Filter.kt | 66 +- .../tusky/entity/FilterKeyword.kt | 12 + .../tusky/entity/FilterResult.kt | 9 + .../keylesspalace/tusky/entity/FilterV1.kt | 65 ++ .../com/keylesspalace/tusky/entity/Status.kt | 1 + .../interfaces/StatusActionListener.java | 2 + .../tusky/network/FilterModel.kt | 55 +- .../tusky/network/MastodonApi.kt | 58 +- .../keylesspalace/tusky/view/FilterDialog.kt | 73 -- .../tusky/viewdata/StatusViewData.kt | 4 +- app/src/main/res/drawable/ic_filter_24dp.xml | 10 + .../main/res/layout/activity_edit_filter.xml | 167 +++ app/src/main/res/layout/activity_filters.xml | 9 +- app/src/main/res/layout/dialog_filter.xml | 9 +- app/src/main/res/layout/item_removable.xml | 54 + app/src/main/res/layout/item_status.xml | 1 + .../main/res/layout/item_status_filtered.xml | 32 + .../main/res/layout/item_status_wrapper.xml | 13 + app/src/main/res/values-ar/strings.xml | 4 +- app/src/main/res/values-be/strings.xml | 4 +- app/src/main/res/values-bg/strings.xml | 4 +- app/src/main/res/values-bn-rBD/strings.xml | 4 +- app/src/main/res/values-bn-rIN/strings.xml | 4 +- app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-ckb/strings.xml | 4 +- app/src/main/res/values-cs/strings.xml | 4 +- app/src/main/res/values-cy/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-eo/strings.xml | 4 +- app/src/main/res/values-es/strings.xml | 4 +- app/src/main/res/values-eu/strings.xml | 4 +- app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-fi/strings.xml | 4 +- app/src/main/res/values-fr/strings.xml | 4 +- app/src/main/res/values-fy/strings.xml | 4 +- app/src/main/res/values-ga/strings.xml | 4 +- app/src/main/res/values-gd/strings.xml | 4 +- app/src/main/res/values-gl/strings.xml | 4 +- app/src/main/res/values-hi/strings.xml | 4 +- app/src/main/res/values-hu/strings.xml | 4 +- app/src/main/res/values-is/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ja/strings.xml | 4 +- app/src/main/res/values-kab/strings.xml | 4 +- app/src/main/res/values-ko/strings.xml | 4 +- app/src/main/res/values-lv/strings.xml | 4 +- app/src/main/res/values-nb-rNO/strings.xml | 4 +- app/src/main/res/values-nl/strings.xml | 4 +- app/src/main/res/values-oc/strings.xml | 4 +- app/src/main/res/values-pl/strings.xml | 4 +- app/src/main/res/values-pt-rBR/strings.xml | 4 +- app/src/main/res/values-pt-rPT/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-sa/strings.xml | 4 +- app/src/main/res/values-si/strings.xml | 4 +- app/src/main/res/values-sl/strings.xml | 4 +- app/src/main/res/values-sv/strings.xml | 4 +- app/src/main/res/values-th/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values-vi/strings.xml | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rHK/strings.xml | 4 +- app/src/main/res/values-zh-rMO/strings.xml | 4 +- app/src/main/res/values-zh-rSG/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values/donottranslate.xml | 14 + app/src/main/res/values/string-arrays.xml | 12 + app/src/main/res/values/strings.xml | 23 +- .../tusky/BottomSheetActivityTest.kt | 1 + .../tusky/{FilterTest.kt => FilterV1Test.kt} | 83 +- .../NotificationsViewModelTestStatusAction.kt | 2 +- .../tusky/components/timeline/StatusMocker.kt | 1 + .../keylesspalace/tusky/db/TimelineDaoTest.kt | 1 + .../tusky/usecase/TimelineCasesTest.kt | 1 + 109 files changed, 2770 insertions(+), 631 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json delete mode 100644 app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt create mode 100644 app/src/main/res/drawable/ic_filter_24dp.xml create mode 100644 app/src/main/res/layout/activity_edit_filter.xml create mode 100644 app/src/main/res/layout/item_removable.xml create mode 100644 app/src/main/res/layout/item_status_filtered.xml create mode 100644 app/src/main/res/layout/item_status_wrapper.xml rename app/src/test/java/com/keylesspalace/tusky/{FilterTest.kt => FilterV1Test.kt} (85%) 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 00000000..8503ae7c --- /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 0d49febc..0eaaf655 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 bbb5bc6a..00000000 --- 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 f7fe5c1c..26092437 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 582abee4..3364b79d 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 b1881272..9d45bb05 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 c338a1c0..876a71ef 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 9ce9604b..2d68db93 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 00000000..41cd2fed --- /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 00000000..07e6d25c --- /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 00000000..e56a8253 --- /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 00000000..f6e6791a --- /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 00000000..a102b0d6 --- /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 00000000..7fb07a07 --- /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 b79156eb..be7ef910 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 61b759f1..fa1d7392 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 1b3c39f9..23ebbef6 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 ace770d3..b0ae1cf7 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 0ea0b958..09557a50 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 24bc544d..d154caaa 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 a8eaaf32..7bc47e92 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 f569b57f..91d28d80 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 968b2743..c31606a4 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 500313ef..5f8f0bfc 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 7f900de6..3abd47d0 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 c780ffeb..6a244c0a 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 5f497246..32793102 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 7e3ecf73..c64d29ed 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 9b858b32..6ef94254 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 18f2f4d4..17a25ac5 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 ff63faf8..0b5fcfb1 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 bbbd5337..2ceb9721 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 3dacc2d6..758f8d24 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 e3ce3a3e..af1972d5 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 af51a04b..3e529bfe 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 00000000..131540c1 --- /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 00000000..79179bd0 --- /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 00000000..c5b2d169 --- /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 61901761..b6c26f89 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 9171b420..e142683a 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 2707dbbf..0d13373a 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 a94c8a35..88f5480e 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 c6cea1e2..00000000 --- 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 07b7f3db..b4ce4185 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 00000000..ccb8fd22 --- /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 00000000..59a0658e --- /dev/null +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +