mirror of https://github.com/tuskyapp/Tusky.git
Browse Source
* 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 <nik@ngo.org.uk>pull/3439/head
109 changed files with 2769 additions and 630 deletions
@ -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')" |
||||
] |
||||
} |
||||
} |
||||
@ -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<Filter> |
||||
|
||||
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<String>, 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" |
||||
} |
||||
} |
||||
@ -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<SwitchMaterial, Filter.Kind> |
||||
|
||||
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<FilterKeyword>) { |
||||
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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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<FilterKeyword>()) |
||||
val action = MutableStateFlow(Filter.Action.WARN) |
||||
val duration = MutableStateFlow(0) |
||||
val contexts = MutableStateFlow(listOf<Filter.Kind>()) |
||||
|
||||
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<String>, 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<String>, 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<String>, 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<String>, 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 } |
||||
} |
||||
} |
||||
@ -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<Filter>) { |
||||
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) |
||||
} |
||||
} |
||||
@ -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<Filter>) : |
||||
RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() { |
||||
|
||||
override fun getItemCount(): Int = filters.size |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemRemovableBinding> { |
||||
return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)) |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemRemovableBinding>, 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) |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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<List<Filter>> = MutableStateFlow(listOf()) |
||||
val error: MutableStateFlow<Throwable?> = 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() |
||||
} |
||||
} |
||||
) |
||||
} |
||||
} |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
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<String>, |
||||
@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<FilterKeyword>, |
||||
// val statuses: List<FilterStatus>, |
||||
) : Parcelable { |
||||
enum class Action(val action: String) { |
||||
NONE("none"), |
||||
WARN("warn"), |
||||
HIDE("hide"); |
||||
|
||||
override fun hashCode(): Int { |
||||
return id.hashCode() |
||||
companion object { |
||||
fun from(action: String): Action = values().firstOrNull { it.action == action } ?: WARN |
||||
} |
||||
} |
||||
enum class Kind(val kind: String) { |
||||
HOME("home"), |
||||
NOTIFICATIONS("notifications"), |
||||
PUBLIC("public"), |
||||
THREAD("thread"), |
||||
ACCOUNT("account"); |
||||
|
||||
override fun equals(other: Any?): Boolean { |
||||
if (other !is Filter) { |
||||
return false |
||||
companion object { |
||||
fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC |
||||
} |
||||
val filter = other as Filter? |
||||
return filter?.id.equals(id) |
||||
} |
||||
|
||||
val action: Action |
||||
get() = Action.from(filterAction) |
||||
|
||||
val kinds: List<Kind> |
||||
get() = context.map { Kind.from(it) } |
||||
} |
||||
|
||||
@ -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 |
||||
@ -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<String>?, |
||||
@SerializedName("status_matches") val statusMatches: String?, |
||||
) |
||||
@ -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 <http://www.gnu.org/licenses>. */ |
||||
|
||||
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<String>, |
||||
@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, |
||||
) |
||||
) |
||||
) |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24" |
||||
android:tint="?attr/colorControlNormal"> |
||||
<path |
||||
android:fillColor="@android:color/white" |
||||
android:pathData="M4.25,5.61C6.27,8.2 10,13 10,13v6c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1v-6c0,0 3.72,-4.8 5.74,-7.39C20.25,4.95 19.78,4 18.95,4H5.04C4.21,4 3.74,4.95 4.25,5.61z"/> |
||||
</vector> |
||||
@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<LinearLayout |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:layout_marginBottom="8dp" |
||||
android:layout_marginTop="8dp" |
||||
android:orientation="vertical" |
||||
tools:context="com.keylesspalace.tusky.components.filters.EditFilterActivity"> |
||||
|
||||
<include |
||||
android:id="@+id/includedToolbar" |
||||
layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.core.widget.NestedScrollView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="0dp" |
||||
android:layout_weight="1" |
||||
android:fillViewport="true"> |
||||
|
||||
<LinearLayout |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
android:orientation="vertical" |
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart" |
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"> |
||||
|
||||
<com.google.android.material.textfield.TextInputLayout |
||||
android:id="@+id/filter_title_wrapper" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="16dp" |
||||
android:hint="@string/label_filter_title"> |
||||
<com.google.android.material.textfield.TextInputEditText |
||||
android:id="@+id/filterTitle" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:inputType="textNoSuggestions"/> |
||||
</com.google.android.material.textfield.TextInputLayout> |
||||
|
||||
<TextView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="16dp" |
||||
android:text="@string/label_filter_keywords" |
||||
style="@style/TextAppearance.Material3.TitleSmall" |
||||
android:textColor="?attr/colorAccent" /> |
||||
|
||||
<com.google.android.material.chip.ChipGroup |
||||
android:id="@+id/keywordChips" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content"> |
||||
|
||||
<com.google.android.material.chip.Chip |
||||
android:id="@+id/actionChip" |
||||
style="@style/Widget.MaterialComponents.Chip.Action" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:checkable="false" |
||||
android:text="@string/action_add" |
||||
app:chipIcon="@drawable/ic_plus_24dp" |
||||
app:chipSurfaceColor="@color/tusky_blue" /> |
||||
</com.google.android.material.chip.ChipGroup> |
||||
|
||||
<TextView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="16dp" |
||||
android:text="@string/label_filter_action" |
||||
style="@style/TextAppearance.Material3.TitleSmall" |
||||
android:textColor="?attr/colorAccent" /> |
||||
|
||||
<RadioGroup |
||||
android:id="@+id/filter_action_group" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content"> |
||||
<RadioButton |
||||
android:id="@+id/filter_action_warn" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/filter_description_warn"/> |
||||
<RadioButton |
||||
android:id="@+id/filter_action_hide" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/filter_description_hide"/> |
||||
</RadioGroup> |
||||
|
||||
<TextView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="16dp" |
||||
android:text="@string/label_duration" |
||||
style="@style/TextAppearance.Material3.TitleSmall" |
||||
android:textColor="?attr/colorAccent" /> |
||||
|
||||
<Spinner |
||||
android:id="@+id/filterDurationSpinner" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:entries="@array/filter_duration_names" |
||||
/> |
||||
|
||||
<TextView |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="16dp" |
||||
android:text="@string/label_filter_context" |
||||
style="@style/TextAppearance.Material3.TitleSmall" |
||||
android:textColor="?attr/colorAccent" /> |
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial |
||||
android:id="@+id/filter_context_home" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/title_home" /> |
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial |
||||
android:id="@+id/filter_context_notifications" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/title_notifications" /> |
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial |
||||
android:id="@+id/filter_context_public" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/pref_title_public_filter_keywords" /> |
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial |
||||
android:id="@+id/filter_context_thread" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/pref_title_thread_filter_keywords" /> |
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial |
||||
android:id="@+id/filter_context_account" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:minHeight="48dp" |
||||
android:text="@string/pref_title_account_filter_keywords" /> |
||||
</LinearLayout> |
||||
</androidx.core.widget.NestedScrollView> |
||||
|
||||
<LinearLayout |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:paddingStart="?android:attr/listPreferredItemPaddingStart" |
||||
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" |
||||
android:gravity="end"> |
||||
|
||||
<Button |
||||
android:id="@+id/filter_save_button" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:text="@string/action_save" /> |
||||
</LinearLayout> |
||||
</LinearLayout> |
||||
@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.constraintlayout.widget.ConstraintLayout |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:orientation="horizontal"> |
||||
|
||||
<TextView |
||||
android:id="@+id/textPrimary" |
||||
android:layout_width="0dp" |
||||
android:layout_height="match_parent" |
||||
android:layout_weight="0.91" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintEnd_toStartOf="@id/delete" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
android:paddingStart="8dp" |
||||
android:paddingEnd="8dp" |
||||
android:paddingTop="8dp" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:textColor="@color/textColorPrimary" |
||||
/> |
||||
|
||||
<TextView |
||||
android:id="@+id/textSecondary" |
||||
android:layout_width="0dp" |
||||
android:layout_height="match_parent" |
||||
android:layout_weight="0.91" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/textPrimary" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
android:paddingStart="8dp" |
||||
android:paddingEnd="8dp" |
||||
android:paddingBottom="8dp" |
||||
android:textSize="?attr/status_text_small" |
||||
android:textColor="@color/textColorTertiary" |
||||
/> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/delete" |
||||
style="@style/TuskyImageButton" |
||||
android:layout_width="32dp" |
||||
android:layout_height="32dp" |
||||
android:layout_gravity="center_vertical" |
||||
android:layout_margin="12dp" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
android:background="?attr/selectableItemBackgroundBorderless" |
||||
android:contentDescription="@string/action_delete" |
||||
android:padding="4dp" |
||||
app:srcCompat="@drawable/ic_clear_24dp" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
||||
@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:id="@+id/status_filtered_placeholder" |
||||
android:orientation="vertical" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
> |
||||
|
||||
<TextView |
||||
android:id="@+id/status_filter_label" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="8dp" |
||||
android:layout_marginBottom="0dp" |
||||
android:textColor="?android:textColorTertiary" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:textAlignment="center" |
||||
android:text="Filter: MyFilter" |
||||
/> |
||||
|
||||
<Button |
||||
android:id="@+id/status_filter_show_anyway" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="0dp" |
||||
style="@style/TuskyButton.TextButton" |
||||
android:textStyle="bold" |
||||
android:textSize="?attr/status_text_medium" |
||||
android:text="@string/status_filtered_show_anyway" |
||||
/> |
||||
|
||||
</LinearLayout> |
||||
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:orientation="vertical" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content"> |
||||
|
||||
<include layout="@layout/item_status" /> |
||||
|
||||
<include |
||||
layout="@layout/item_status_filtered" |
||||
android:visibility="gone" |
||||
/> |
||||
</FrameLayout> |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue