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 <nik@ngo.org.uk>
This commit is contained in:
Levi Bard 2023-03-11 13:12:50 +01:00 committed by GitHub
parent b9be125c95
commit ff8dd37855
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 2770 additions and 631 deletions

View File

@ -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')"
]
}
}

View File

@ -142,7 +142,7 @@
</activity> </activity>
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" /> <activity android:name=".components.filters.FiltersActivity" />
<activity android:name=".components.trending.TrendingActivity" /> <activity android:name=".components.trending.TrendingActivity" />
<activity android:name=".components.followedtags.FollowedTagsActivity" /> <activity android:name=".components.followedtags.FollowedTagsActivity" />
<activity <activity
@ -152,6 +152,7 @@
<activity android:name=".components.scheduled.ScheduledStatusActivity" /> <activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" /> <activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" /> <activity android:name=".components.drafts.DraftsActivity" />
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" <receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" /> android:exported="false" />

View File

@ -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"
}
}

View File

@ -31,10 +31,12 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@ -54,6 +56,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private var unmuteTagItem: MenuItem? = null private var unmuteTagItem: MenuItem? = null
/** The filter muting hashtag, null if unknown or hashtag is not filtered */ /** The filter muting hashtag, null if unknown or hashtag is not filtered */
private var mutedFilterV1: FilterV1? = null
private var mutedFilter: Filter? = null private var mutedFilter: Filter? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -174,49 +177,89 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
lifecycleScope.launch { lifecycleScope.launch {
mastodonApi.getFilters().fold( mastodonApi.getFilters().fold(
{ filters -> { filters ->
for (filter in filters) { mutedFilter = filters.firstOrNull { filter ->
if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any {
Log.d(TAG, "Tag $hashtag is filtered") it.keyword == tag
muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = true
mutedFilter = filter
return@fold
} }
} }
updateTagMuteState(mutedFilter != null)
Log.d(TAG, "Tag $hashtag is not filtered")
mutedFilter = null
muteTagItem?.isEnabled = true
muteTagItem?.isVisible = true
muteTagItem?.isVisible = true
}, },
{ throwable -> { 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 { private fun muteTag(): Boolean {
val tag = hashtag ?: return true val tag = hashtag ?: return true
lifecycleScope.launch { lifecycleScope.launch {
mastodonApi.createFilter( mastodonApi.createFilter(
tag, title = "#$tag",
listOf(Filter.HOME), context = listOf(FilterV1.HOME),
irreversible = false, filterAction = Filter.Action.WARN.action,
wholeWord = true, expiresInSeconds = null,
expiresInSeconds = null
).fold( ).fold(
{ filter -> { filter ->
mutedFilter = filter if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) {
muteTagItem?.isVisible = false mutedFilter = filter
unmuteTagItem?.isVisible = true updateTagMuteState(true)
eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) 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")
}
}, },
{ { throwable ->
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() if (throwable is HttpException && throwable.code() == 404) {
Log.e(TAG, "Failed to mute #$tag", it) 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 { private fun unmuteTag(): Boolean {
val filter = mutedFilter ?: return true
lifecycleScope.launch { 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 updateTagMuteState(false)
unmuteTagItem?.isVisible = false eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind))
eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) mutedFilterV1 = null
mutedFilter = null mutedFilter = null
}, },
{ { throwable ->
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show() Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to unmute #${filter.phrase}", it) Log.e(TAG, "Failed to unmute #$tag", throwable)
} }
) )
} }

View File

@ -43,6 +43,8 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji; 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.HashTag;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -108,6 +110,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private final TextView cardDescription; private final TextView cardDescription;
private final TextView cardUrl; private final TextView cardUrl;
private final PollAdapter pollAdapter; private final PollAdapter pollAdapter;
protected LinearLayout filteredPlaceholder;
protected TextView filteredPlaceholderLabel;
protected Button filteredPlaceholderShowButton;
protected ConstraintLayout statusContainer;
private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
@ -160,6 +166,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardDescription = itemView.findViewById(R.id.card_description); cardDescription = itemView.findViewById(R.id.card_description);
cardUrl = itemView.findViewById(R.id.card_link); 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(); pollAdapter = new PollAdapter();
pollOptions.setAdapter(pollAdapter); pollOptions.setAdapter(pollAdapter);
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
@ -287,7 +298,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
private void setAvatar(String url, private void setAvatar(String url,
@Nullable String rebloggedUrl, @Nullable String rebloggedUrl,
boolean isBot, boolean isBot,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
@ -765,6 +776,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setSpoilerAndContent(status, statusDisplayOptions, listener); setSpoilerAndContent(status, statusDisplayOptions, listener);
setupFilterPlaceholder(status, listener, statusDisplayOptions);
setDescriptionForStatus(status, statusDisplayOptions); setDescriptionForStatus(status, statusDisplayOptions);
// Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // 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<String> 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<Attachment> attachments) { protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
for (Attachment attachment : attachments) { for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { 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); bookmarkButton.setVisibility(visibility);
moreButton.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);
}
}
} }

View File

@ -28,6 +28,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -66,7 +67,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
setupCollapsedState(sensitive, expanded, status, listener); setupCollapsedState(sensitive, expanded, status, listener);
Status reblogging = status.getRebloggingStatus(); Status reblogging = status.getRebloggingStatus();
if (reblogging == null) { if (reblogging == null || status.getFilterAction() == Filter.Action.WARN) {
hideStatusInfo(); hideStatusInfo();
} else { } else {
String rebloggedByDisplayName = reblogging.getAccount().getName(); String rebloggedByDisplayName = reblogging.getAccount().getName();

View File

@ -130,10 +130,11 @@ data class ConversationStatusEntity(
poll = poll, poll = poll,
card = null, card = null,
language = language, language = language,
filtered = null,
), ),
isExpanded = expanded, isExpanded = expanded,
isShowingContent = showingHiddenContent, isShowingContent = showingHiddenContent,
isCollapsed = collapsed isCollapsed = collapsed,
) )
} }
} }

View File

@ -352,6 +352,9 @@ class ConversationsFragment :
} }
} }
override fun clearWarningAction(position: Int) {
}
override fun onReselect() { override fun onReselect() {
if (isAdded) { if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.layoutManager?.scrollToPosition(0)

View File

@ -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)
}
}
}
}

View File

@ -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 }
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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()
}
}
)
}
}
}

View File

@ -555,6 +555,9 @@ class NotificationsFragment :
onContentCollapsedChange(isCollapsed, position) onContentCollapsedChange(isCollapsed, position)
} }
override fun clearWarningAction(position: Int) {
}
private fun clearNotifications() { private fun clearNotifications() {
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.isVisible = false binding.progressBar.isVisible = false

View File

@ -26,12 +26,12 @@ import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.accountlist.AccountListActivity 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.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity 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.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.AccountPreferenceHandler 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) { preferenceCategory(R.string.pref_publishing) {
listPreference { listPreference {
setTitle(R.string.pref_default_post_privacy) setTitle(R.string.pref_default_post_privacy)
@ -261,48 +269,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceDataStore = accountPreferenceHandler 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) val intent = Intent(context, FiltersActivity::class.java)
intent.putExtra(FiltersActivity.FILTERS_CONTEXT, filterContext)
intent.putExtra(FiltersActivity.FILTERS_TITLE, getString(titleResource))
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
} }

View File

@ -190,6 +190,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
} }
} }
override fun clearWarningAction(position: Int) {}
private fun removeItem(position: Int) { private fun removeItem(position: Int) {
searchAdapter.peek(position)?.let { searchAdapter.peek(position)?.let {
viewModel.removeItem(it) viewModel.removeItem(it)

View File

@ -424,6 +424,11 @@ class TimelineFragment :
viewModel.voteInPoll(choices, status) 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) { override fun onMore(view: View, position: Int) {
val status = adapter.peek(position)?.asStatusOrNull() ?: return val status = adapter.peek(position)?.asStatusOrNull() ?: return
super.more(status.status, view, position) super.more(status.status, view, position)

View File

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -46,21 +47,16 @@ class TimelinePagingAdapter(
} }
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(viewGroup.context)
return when (viewType) { return when (viewType) {
VIEW_TYPE_STATUS -> { VIEW_TYPE_STATUS_FILTERED -> {
val view = LayoutInflater.from(viewGroup.context) StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false))
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
} }
VIEW_TYPE_PLACEHOLDER -> { VIEW_TYPE_PLACEHOLDER -> {
val view = LayoutInflater.from(viewGroup.context) PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false))
.inflate(R.layout.item_status_placeholder, viewGroup, false)
PlaceholderViewHolder(view)
} }
else -> { else -> {
val view = LayoutInflater.from(viewGroup.context) StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false))
.inflate(R.layout.item_status, viewGroup, false)
StatusViewHolder(view)
} }
} }
} }
@ -98,8 +94,11 @@ class TimelinePagingAdapter(
} }
override fun getItemViewType(position: Int): Int { 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 VIEW_TYPE_PLACEHOLDER
} else if (viewData?.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else { } else {
VIEW_TYPE_STATUS VIEW_TYPE_STATUS
} }
@ -107,6 +106,7 @@ class TimelinePagingAdapter(
companion object { companion object {
private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_FILTERED = 1
private const val VIEW_TYPE_PLACEHOLDER = 2 private const val VIEW_TYPE_PLACEHOLDER = 2
val TimelineDifferCallback = object : DiffUtil.ItemCallback<StatusViewData>() { val TimelineDifferCallback = object : DiffUtil.ItemCallback<StatusViewData>() {

View File

@ -105,6 +105,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
card = null, card = null,
repliesCount = 0, repliesCount = 0,
language = null, language = null,
filtered = null,
) )
} }
@ -149,6 +150,7 @@ fun Status.toEntity(
card = actionableStatus.card?.let(gson::toJson), card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount, repliesCount = actionableStatus.repliesCount,
language = actionableStatus.language, language = actionableStatus.language,
filtered = actionableStatus.filtered,
) )
} }
@ -196,6 +198,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = card, card = card,
repliesCount = status.repliesCount, repliesCount = status.repliesCount,
language = status.language, language = status.language,
filtered = status.filtered,
) )
} }
val status = if (reblog != null) { val status = if (reblog != null) {
@ -228,6 +231,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = null, card = null,
repliesCount = status.repliesCount, repliesCount = status.repliesCount,
language = status.language, language = status.language,
filtered = status.filtered,
) )
} else { } else {
Status( Status(
@ -259,6 +263,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false
card = card, card = card,
repliesCount = status.repliesCount, repliesCount = status.repliesCount,
language = status.language, language = status.language,
filtered = status.filtered,
) )
} }
return StatusViewData.Concrete( return StatusViewData.Concrete(

View File

@ -41,6 +41,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
@ -100,7 +101,7 @@ class CachedTimelineViewModel @Inject constructor(
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson) timelineStatus.toViewData(gson)
}.filter(Dispatchers.Default.asExecutor()) { statusViewData -> }.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData) shouldFilterStatus(statusViewData) != Filter.Action.HIDE
} }
} }
.flowOn(Dispatchers.Default) .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) { override fun removeStatusWithId(id: String) {
// handled by CacheUpdater // handled by CacheUpdater
} }

View File

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
@ -82,7 +83,7 @@ class NetworkTimelineViewModel @Inject constructor(
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData) shouldFilterStatus(statusViewData) != Filter.Action.HIDE
} }
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
@ -248,6 +249,12 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate() currentSource?.invalidate()
} }
override fun clearWarning(status: StatusViewData.Concrete) {
updateActionableStatusById(status.actionableId) {
it.copy(filtered = null)
}
}
override suspend fun invalidate() { override suspend fun invalidate() {
currentSource?.invalidate() currentSource?.invalidate()
} }

View File

@ -20,6 +20,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent 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.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
abstract class TimelineViewModel( abstract class TimelineViewModel(
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
@ -82,6 +85,7 @@ abstract class TimelineViewModel(
this.kind = kind this.kind = kind
this.id = id this.id = id
this.tags = tags this.tags = tags
filterModel.kind = kind.toFilterKind()
if (kind == Kind.HOME) { if (kind == Kind.HOME) {
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show" // 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 fullReload()
abstract fun clearWarning(status: StatusViewData.Concrete)
/** Triggered when currently displayed data must be reloaded. */ /** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate() protected abstract suspend fun invalidate()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action {
val status = statusViewData.asStatusOrNull()?.status ?: return false val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE
return status.inReplyToId != null && filterRemoveReplies || return if (
status.reblog != null && filterRemoveReblogs || (status.inReplyToId != null && filterRemoveReplies) ||
filterModel.shouldFilterStatus(status.actionableStatus) (status.reblog != null && filterRemoveReblogs)
) {
return Filter.Action.HIDE
} else {
statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus)
statusViewData.filterAction
}
} }
private fun onPreferenceChanged(key: String) { private fun onPreferenceChanged(key: String) {
@ -206,7 +218,7 @@ abstract class TimelineViewModel(
fullReload() 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))) { if (filterContextMatchesKind(kind, listOf(key))) {
reloadFilters() reloadFilters()
} }
@ -222,28 +234,6 @@ abstract class TimelineViewModel(
} }
} }
private fun filterContextMatchesKind(
kind: Kind,
filterContext: List<String>
): 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) { private fun handleEvent(event: Event) {
when (event) { when (event) {
is FavoriteEvent -> handleFavEvent(event) is FavoriteEvent -> handleFavEvent(event)
@ -288,27 +278,57 @@ abstract class TimelineViewModel(
private fun reloadFilters() { private fun reloadFilters() {
viewModelScope.launch { viewModelScope.launch {
val filters = api.getFilters().getOrElse { api.getFilters().fold(
Log.e(TAG, "Failed to fetch filters", it) {
return@launch // 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.
filterModel.initWithFilters( invalidate()
filters.filter { },
filterContextMatchesKind(kind, it.context) { 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 { companion object {
private const val TAG = "TimelineVM" private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30 internal const val LOAD_AT_ONCE = 30
fun filterContextMatchesKind(
kind: Kind,
filterContext: List<String>
): Boolean {
return filterContext.contains(kind.toFilterKind().kind)
}
} }
enum class 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
}
}
} }
} }

View File

@ -80,11 +80,15 @@ class TrendingViewModel @Inject constructor(
} }
val homeFilters = deferredFilters.await().getOrNull()?.filter { val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.HOME) it.context.contains(Filter.Kind.HOME.kind)
} }
val tags = response.body()!! 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 } } .sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() } .map { it.toViewData() }
.asReversed() .asReversed()

View File

@ -23,6 +23,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -33,16 +34,16 @@ class ThreadAdapter(
) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) { ) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
VIEW_TYPE_STATUS -> { VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(parent.context) StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false))
.inflate(R.layout.item_status, parent, false) }
StatusViewHolder(view) VIEW_TYPE_STATUS_FILTERED -> {
StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false))
} }
VIEW_TYPE_STATUS_DETAILED -> { VIEW_TYPE_STATUS_DETAILED -> {
val view = LayoutInflater.from(parent.context) StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false))
.inflate(R.layout.item_status_detailed, parent, false)
StatusDetailedViewHolder(view)
} }
else -> error("Unknown item type: $viewType") else -> error("Unknown item type: $viewType")
} }
@ -54,8 +55,11 @@ class ThreadAdapter(
} }
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return if (getItem(position).isDetailed) { val viewData = getItem(position)
return if (viewData.isDetailed) {
VIEW_TYPE_STATUS_DETAILED VIEW_TYPE_STATUS_DETAILED
} else if (viewData.filterAction == Filter.Action.WARN) {
VIEW_TYPE_STATUS_FILTERED
} else { } else {
VIEW_TYPE_STATUS VIEW_TYPE_STATUS
} }
@ -65,6 +69,7 @@ class ThreadAdapter(
private const val TAG = "ThreadAdapter" private const val TAG = "ThreadAdapter"
private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_DETAILED = 1
private const val VIEW_TYPE_STATUS_FILTERED = 2
val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() { val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areItemsTheSame( override fun areItemsTheSame(

View File

@ -436,6 +436,10 @@ class ViewThreadFragment :
} }
} }
override fun clearWarningAction(position: Int) {
viewModel.clearWarning(adapter.currentList[position])
}
companion object { companion object {
private const val TAG = "ViewThreadFragment" private const val TAG = "ViewThreadFragment"

View File

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
class ViewThreadViewModel @Inject constructor( class ViewThreadViewModel @Inject constructor(
@ -414,30 +416,48 @@ class ViewThreadViewModel @Inject constructor(
private fun loadFilters() { private fun loadFilters() {
viewModelScope.launch { viewModelScope.launch {
val filters = api.getFilters().getOrElse { api.getFilters().fold(
Log.w(TAG, "Failed to fetch filters", it) {
return@launch 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( filterModel.initWithFilters(
filters.filter { filter -> filters.filter { filter -> filter.context.contains(FilterV1.THREAD) }
filter.context.contains(Filter.THREAD) )
updateStatuses()
} else {
Log.e(TAG, "Error getting filters", throwable)
}
} }
) )
}
}
updateSuccess { uiState -> private fun updateStatuses() {
val statuses = uiState.statusViewData.filter() updateSuccess { uiState ->
uiState.copy( val statuses = uiState.statusViewData.filter()
statusViewData = statuses, uiState.copy(
revealButton = statuses.getRevealButtonState() statusViewData = statuses,
) revealButton = statuses.getRevealButtonState()
} )
} }
} }
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> { private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
return filter { status -> 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 { companion object {
private const val TAG = "ViewThreadViewModel" private const val TAG = "ViewThreadViewModel"
} }

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 47) }, version = 48)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -339,7 +339,7 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER"); database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER");
} }
}; };
public static final Migration MIGRATION_23_24 = new Migration(23, 24) { public static final Migration MIGRATION_23_24 = new Migration(23, 24) {
@Override @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
@ -644,6 +644,14 @@ public abstract class AppDatabase extends RoomDatabase {
@Override @Override
public void migrate(@NonNull SupportSQLiteDatabase database) { public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0"); 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");
} }
}; };
} }

View File

@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.FilterResult
import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
@ -164,4 +165,14 @@ class Converters @Inject constructor(
fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? { fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? {
return gson.fromJson(draftAttachmentListJson, object : TypeToken<List<DraftAttachment>>() {}.type) return gson.fromJson(draftAttachmentListJson, object : TypeToken<List<DraftAttachment>>() {}.type)
} }
@TypeConverter
fun filterResultListToJson(filterResults: List<FilterResult>?): String? {
return gson.toJson(filterResults)
}
@TypeConverter
fun jsonToFilterResultList(filterResultListJson: String?): List<FilterResult>? {
return gson.fromJson(filterResultListJson, object : TypeToken<List<FilterResult>>() {}.type)
}
} }

View File

@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, 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.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.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.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username', a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.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) 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") @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getTopId(accountId: Long): String? abstract suspend fun getTopId(accountId: Long): String?

View File

@ -20,6 +20,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.entity.FilterResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
/** /**
@ -84,6 +85,7 @@ data class TimelineStatusEntity(
val pinned: Boolean, val pinned: Boolean,
val card: String?, val card: String?,
val language: String?, val language: String?,
val filtered: List<FilterResult>?,
) { ) {
val isPlaceholder: Boolean val isPlaceholder: Boolean
get() = this.authorServerId == null get() = this.authorServerId == null

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.AboutActivity import com.keylesspalace.tusky.AboutActivity
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.MainActivity 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.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity 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.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity
@ -128,4 +129,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesTrendingActivity(): TrendingActivity abstract fun contributesTrendingActivity(): TrendingActivity
@ContributesAndroidInjector
abstract fun contributesEditFilterActivity(): EditFilterActivity
} }

View File

@ -67,7 +67,8 @@ class AppModule {
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, 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_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, 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() .build()
} }

View File

@ -28,6 +28,8 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel 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.followedtags.FollowedTagsViewModel
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.notifications.NotificationsViewModel
@ -173,5 +175,15 @@ abstract class ViewModelModule {
@ViewModelKey(TrendingViewModel::class) @ViewModelKey(TrendingViewModel::class)
internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel 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 // Add more ViewModels here
} }

View File

@ -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 package com.keylesspalace.tusky.entity
import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import java.util.Date import java.util.Date
@Parcelize
data class Filter( data class Filter(
val id: String, val id: String,
val phrase: String, val title: String,
val context: List<String>, val context: List<String>,
@SerializedName("expires_at") val expiresAt: Date?, @SerializedName("expires_at") val expiresAt: Date?,
val irreversible: Boolean, @SerializedName("filter_action") private val filterAction: String,
@SerializedName("whole_word") val wholeWord: Boolean val keywords: List<FilterKeyword>,
) { // val statuses: List<FilterStatus>,
companion object { ) : Parcelable {
const val HOME = "home" enum class Action(val action: String) {
const val NOTIFICATIONS = "notifications" NONE("none"),
const val PUBLIC = "public" WARN("warn"),
const val THREAD = "thread" HIDE("hide");
const val ACCOUNT = "account"
}
override fun hashCode(): Int { companion object {
return id.hashCode() fun from(action: String): Action = values().firstOrNull { it.action == action } ?: WARN
}
override fun equals(other: Any?): Boolean {
if (other !is Filter) {
return false
} }
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<Kind>
get() = context.map { Kind.from(it) }
} }

View File

@ -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

View File

@ -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?,
)

View File

@ -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,
)
)
)
}
}

View File

@ -51,6 +51,7 @@ data class Status(
val poll: Poll?, val poll: Poll?,
val card: Card?, val card: Card?,
val language: String?, val language: String?,
val filtered: List<FilterResult>?,
) { ) {
val actionableId: String val actionableId: String

View File

@ -64,5 +64,7 @@ public interface StatusActionListener extends LinkListener {
void onVoteInPoll(int position, @NonNull List<Integer> choices); void onVoteInPoll(int position, @NonNull List<Integer> choices);
default void onShowEdits(int position) {} default void onShowEdits(int position) {}
void clearWarningAction(int position);
} }

View File

@ -1,6 +1,7 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.FilterV1
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date import java.util.Date
@ -15,36 +16,48 @@ import javax.inject.Inject
*/ */
class FilterModel @Inject constructor() { class FilterModel @Inject constructor() {
private var pattern: Pattern? = null private var pattern: Pattern? = null
private var v1 = false
lateinit var kind: Filter.Kind
fun initWithFilters(filters: List<Filter>) { fun initWithFilters(filters: List<FilterV1>) {
v1 = true
this.pattern = makeFilter(filters) this.pattern = makeFilter(filters)
} }
fun shouldFilterStatus(status: Status): Boolean { fun shouldFilterStatus(status: Status): Filter.Action {
// Patterns are expensive and thread-safe, matchers are neither. if (v1) {
val matcher = pattern?.matcher("") ?: return false // Patterns are expensive and thread-safe, matchers are neither.
val matcher = pattern?.matcher("") ?: return Filter.Action.NONE
if (status.poll != null) { if (status.poll?.options?.any { matcher.reset(it.title).find() } == true)
val pollMatches = status.poll.options.any { matcher.reset(it.title).find() } return Filter.Action.HIDE
if (pollMatches) return true
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 matchingKind = status.filtered?.filter { result ->
val attachmentsDescriptions = status.attachments result.filter.kinds.contains(kind)
.mapNotNull { it.description } }
return ( return if (matchingKind.isNullOrEmpty()) {
matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || Filter.Action.NONE
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || } else {
( matchingKind.maxOf { it.filter.action }
attachmentsDescriptions.isNotEmpty() && }
matcher.reset(attachmentsDescriptions.joinToString("\n"))
.find()
)
)
} }
private fun filterToRegexToken(filter: Filter): String? { private fun filterToRegexToken(filter: FilterV1): String? {
val phrase = filter.phrase val phrase = filter.phrase
val quotedPhrase = Pattern.quote(phrase) val quotedPhrase = Pattern.quote(phrase)
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
@ -54,7 +67,7 @@ class FilterModel @Inject constructor() {
} }
} }
private fun makeFilter(filters: List<Filter>): Pattern? { private fun makeFilter(filters: List<FilterV1>): Pattern? {
val now = Date() val now = Date()
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
if (nonExpiredFilters.isEmpty()) return null if (nonExpiredFilters.isEmpty()) return null

View File

@ -25,6 +25,8 @@ import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Filter 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.HashTag
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Marker
@ -85,6 +87,9 @@ interface MastodonApi {
suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<Instance> suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult<Instance>
@GET("api/v1/filters") @GET("api/v1/filters")
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>
@GET("api/v2/filters")
suspend fun getFilters(): NetworkResult<List<Filter>> suspend fun getFilters(): NetworkResult<List<Filter>>
@GET("api/v1/timelines/home") @GET("api/v1/timelines/home")
@ -572,30 +577,75 @@ interface MastodonApi {
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/filters") @POST("api/v1/filters")
suspend fun createFilter( suspend fun createFilterV1(
@Field("phrase") phrase: String, @Field("phrase") phrase: String,
@Field("context[]") context: List<String>, @Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?, @Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?, @Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int? @Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter> ): NetworkResult<FilterV1>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/filters/{id}") @PUT("api/v1/filters/{id}")
suspend fun updateFilter( suspend fun updateFilterV1(
@Path("id") id: String, @Path("id") id: String,
@Field("phrase") phrase: String, @Field("phrase") phrase: String,
@Field("context[]") context: List<String>, @Field("context[]") context: List<String>,
@Field("irreversible") irreversible: Boolean?, @Field("irreversible") irreversible: Boolean?,
@Field("whole_word") wholeWord: Boolean?, @Field("whole_word") wholeWord: Boolean?,
@Field("expires_in") expiresInSeconds: Int? @Field("expires_in") expiresInSeconds: Int?
): NetworkResult<Filter> ): NetworkResult<FilterV1>
@DELETE("api/v1/filters/{id}") @DELETE("api/v1/filters/{id}")
suspend fun deleteFilterV1(
@Path("id") id: String
): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v2/filters")
suspend fun createFilter(
@Field("title") title: String,
@Field("context[]") context: List<String>,
@Field("filter_action") filterAction: String,
@Field("expires_in") expiresInSeconds: Int?,
): NetworkResult<Filter>
@FormUrlEncoded
@PUT("api/v2/filters/{id}")
suspend fun updateFilter(
@Path("id") id: String,
@Field("title") title: String? = null,
@Field("context[]") context: List<String>? = null,
@Field("filter_action") filterAction: String? = null,
@Field("expires_in") expiresInSeconds: Int? = null,
): NetworkResult<Filter>
@DELETE("api/v2/filters/{id}")
suspend fun deleteFilter( suspend fun deleteFilter(
@Path("id") id: String @Path("id") id: String
): NetworkResult<ResponseBody> ): NetworkResult<ResponseBody>
@FormUrlEncoded
@POST("api/v2/filters/{filterId}/keywords")
suspend fun addFilterKeyword(
@Path("filterId") filterId: String,
@Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword>
@FormUrlEncoded
@PUT("api/v2/filters/keywords/{keywordId}")
suspend fun updateFilterKeyword(
@Path("keywordId") keywordId: String,
@Field("keyword") keyword: String,
@Field("whole_word") wholeWord: Boolean,
): NetworkResult<FilterKeyword>
@DELETE("api/v2/filters/keywords/{keywordId}")
suspend fun deleteFilterKeyword(
@Path("keywordId") keywordId: String,
): NetworkResult<ResponseBody>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/polls/{id}/votes") @POST("api/v1/polls/{id}/votes")
fun voteInPoll( fun voteInPoll(

View File

@ -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)
}
}

View File

@ -16,6 +16,7 @@ package com.keylesspalace.tusky.viewdata
import android.os.Build import android.os.Build
import android.text.Spanned import android.text.Spanned
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.replaceCrashingCharacters import com.keylesspalace.tusky.util.replaceCrashingCharacters
@ -29,6 +30,7 @@ import com.keylesspalace.tusky.util.shouldTrimStatus
*/ */
sealed class StatusViewData { sealed class StatusViewData {
abstract val id: String abstract val id: String
var filterAction: Filter.Action = Filter.Action.NONE
data class Concrete( data class Concrete(
val status: Status, val status: Status,
@ -41,7 +43,7 @@ sealed class StatusViewData {
* @return Whether the post is collapsed or fully expanded. * @return Whether the post is collapsed or fully expanded.
*/ */
val isCollapsed: Boolean, val isCollapsed: Boolean,
val isDetailed: Boolean = false val isDetailed: Boolean = false,
) : StatusViewData() { ) : StatusViewData() {
override val id: String override val id: String
get() = status.id get() = status.id

View File

@ -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>

View File

@ -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>

View File

@ -4,17 +4,18 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.FiltersActivity"> tools:context="com.keylesspalace.tusky.components.filters.FiltersActivity">
<include <include
android:id="@+id/includedToolbar" android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" /> layout="@layout/toolbar_basic" />
<ListView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/filtersView" android:id="@+id/filtersView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
/>
<com.keylesspalace.tusky.view.BackgroundMessageView <com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/filterMessageView" android:id="@+id/filterMessageView"
@ -33,7 +34,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
android:contentDescription="@string/filter_addition_dialog_title" android:contentDescription="@string/filter_addition_title"
android:src="@drawable/ic_plus_24dp" android:src="@drawable/ic_plus_24dp"
app:layout_anchor="@id/filtersView" app:layout_anchor="@id/filtersView"
app:layout_anchorGravity="bottom|end" /> app:layout_anchorGravity="bottom|end" />

View File

@ -13,19 +13,12 @@
android:hint="@string/filter_add_description" android:hint="@string/filter_add_description"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
/> />
<Spinner
android:id="@+id/filterDurationSpinner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/phraseEditText"
app:layout_constraintLeft_toLeftOf="parent"
/>
<CheckBox <CheckBox
android:id="@+id/phraseWholeWord" android:id="@+id/phraseWholeWord"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/filter_dialog_whole_word" android:text="@string/filter_dialog_whole_word"
app:layout_constraintTop_toBottomOf="@id/filterDurationSpinner" app:layout_constraintTop_toBottomOf="@id/phraseEditText"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
/> />
<TextView <TextView

View File

@ -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>

View File

@ -400,4 +400,5 @@
app:layout_constraintStart_toEndOf="@id/status_bookmark" app:layout_constraintStart_toEndOf="@id/status_bookmark"
app:layout_constraintTop_toTopOf="@id/status_reply" app:layout_constraintTop_toTopOf="@id/status_reply"
app:srcCompat="@drawable/ic_more_horiz_24dp" /> app:srcCompat="@drawable/ic_more_horiz_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -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>

View File

@ -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>

View File

@ -258,8 +258,8 @@
<string name="load_more_placeholder_text">حمِّل المزيد</string> <string name="load_more_placeholder_text">حمِّل المزيد</string>
<string name="pref_title_public_filter_keywords">الخطوط الزمنية العمومية</string> <string name="pref_title_public_filter_keywords">الخطوط الزمنية العمومية</string>
<string name="pref_title_thread_filter_keywords">المحادثات</string> <string name="pref_title_thread_filter_keywords">المحادثات</string>
<string name="filter_addition_dialog_title">إضافة عامل تصفية</string> <string name="filter_addition_title">إضافة عامل تصفية</string>
<string name="filter_edit_dialog_title">تعديل عامل التصفية</string> <string name="filter_edit_title">تعديل عامل التصفية</string>
<string name="filter_dialog_remove_button">إزالة</string> <string name="filter_dialog_remove_button">إزالة</string>
<string name="filter_dialog_update_button">تحديث</string> <string name="filter_dialog_update_button">تحديث</string>
<string name="filter_add_description">العبارة التي يلزم تصفيتها</string> <string name="filter_add_description">العبارة التي يلزم تصفيتها</string>

View File

@ -342,10 +342,10 @@
<string name="about_tusky_license">Tusky — свабодная праграма з адкрытым зыходным кодам. Зроблена пад GNU General Public License Version 3. Вы можаце паглядзець ліцэнзію тут: https://www.gnu.org/licenses/gpl-3.0.en.html</string> <string name="about_tusky_license">Tusky — свабодная праграма з адкрытым зыходным кодам. Зроблена пад GNU General Public License Version 3. Вы можаце паглядзець ліцэнзію тут: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_bug_feature_request_site">Справаздачы аб памылках і пажаданні: <string name="about_bug_feature_request_site">Справаздачы аб памылках і пажаданні:
\n https://github.com/tuskyapp/Tusky/issues</string> \n https://github.com/tuskyapp/Tusky/issues</string>
<string name="filter_addition_dialog_title">Дадаць фільтр</string> <string name="filter_addition_title">Дадаць фільтр</string>
<string name="follows_you">Вашы падпісчыкі</string> <string name="follows_you">Вашы падпісчыкі</string>
<string name="pref_title_alway_show_sensitive_media">Заўсёды паказваць далікатны змест</string> <string name="pref_title_alway_show_sensitive_media">Заўсёды паказваць далікатны змест</string>
<string name="filter_edit_dialog_title">Рэдагаваць фільтр</string> <string name="filter_edit_title">Рэдагаваць фільтр</string>
<string name="filter_dialog_remove_button">Выдаліць</string> <string name="filter_dialog_remove_button">Выдаліць</string>
<string name="filter_dialog_update_button">Абнавіць</string> <string name="filter_dialog_update_button">Абнавіць</string>
<string name="filter_dialog_whole_word">Цэлае слова</string> <string name="filter_dialog_whole_word">Цэлае слова</string>

View File

@ -160,8 +160,8 @@
<string name="filter_dialog_whole_word">Цяла дума</string> <string name="filter_dialog_whole_word">Цяла дума</string>
<string name="filter_dialog_update_button">Актуализиране</string> <string name="filter_dialog_update_button">Актуализиране</string>
<string name="filter_dialog_remove_button">Премахване</string> <string name="filter_dialog_remove_button">Премахване</string>
<string name="filter_edit_dialog_title">Редакция на филтър</string> <string name="filter_edit_title">Редакция на филтър</string>
<string name="filter_addition_dialog_title">Добавяне на филтър</string> <string name="filter_addition_title">Добавяне на филтър</string>
<string name="pref_title_thread_filter_keywords">Разговори</string> <string name="pref_title_thread_filter_keywords">Разговори</string>
<string name="pref_title_public_filter_keywords">Публични емисии</string> <string name="pref_title_public_filter_keywords">Публични емисии</string>
<string name="load_more_placeholder_text">зареждане на още</string> <string name="load_more_placeholder_text">зареждане на още</string>

View File

@ -82,8 +82,8 @@
<string name="filter_add_description">বাক্য ফিল্টার কর</string> <string name="filter_add_description">বাক্য ফিল্টার কর</string>
<string name="filter_dialog_update_button">আপডেট</string> <string name="filter_dialog_update_button">আপডেট</string>
<string name="filter_dialog_remove_button">সরাও</string> <string name="filter_dialog_remove_button">সরাও</string>
<string name="filter_edit_dialog_title">ফিল্টার সম্পাদনা করুন</string> <string name="filter_edit_title">ফিল্টার সম্পাদনা করুন</string>
<string name="filter_addition_dialog_title">ফিল্টার যোগ করুন</string> <string name="filter_addition_title">ফিল্টার যোগ করুন</string>
<string name="pref_title_thread_filter_keywords">কথাবার্তা</string> <string name="pref_title_thread_filter_keywords">কথাবার্তা</string>
<string name="pref_title_public_filter_keywords">পাবলিক টাইমলাইন</string> <string name="pref_title_public_filter_keywords">পাবলিক টাইমলাইন</string>
<string name="load_more_placeholder_text">আরো লোড কর</string> <string name="load_more_placeholder_text">আরো লোড কর</string>

View File

@ -264,8 +264,8 @@
<string name="load_more_placeholder_text">আরো লোড কর</string> <string name="load_more_placeholder_text">আরো লোড কর</string>
<string name="pref_title_public_filter_keywords">পাবলিক টাইমলাইন</string> <string name="pref_title_public_filter_keywords">পাবলিক টাইমলাইন</string>
<string name="pref_title_thread_filter_keywords">কথাবার্তা</string> <string name="pref_title_thread_filter_keywords">কথাবার্তা</string>
<string name="filter_addition_dialog_title">ফিল্টার যোগ করুন</string> <string name="filter_addition_title">ফিল্টার যোগ করুন</string>
<string name="filter_edit_dialog_title">ফিল্টার সম্পাদনা করুন</string> <string name="filter_edit_title">ফিল্টার সম্পাদনা করুন</string>
<string name="filter_dialog_remove_button">সরাও</string> <string name="filter_dialog_remove_button">সরাও</string>
<string name="filter_dialog_update_button">আপডেট</string> <string name="filter_dialog_update_button">আপডেট</string>
<string name="filter_add_description">বাক্য ফিল্টার কর</string> <string name="filter_add_description">বাক্য ফিল্টার কর</string>

View File

@ -261,8 +261,8 @@
<string name="post_text_size_largest">Més grand</string> <string name="post_text_size_largest">Més grand</string>
<string name="notification_poll_name">Enquestes</string> <string name="notification_poll_name">Enquestes</string>
<string name="pref_title_thread_filter_keywords">Converses</string> <string name="pref_title_thread_filter_keywords">Converses</string>
<string name="filter_addition_dialog_title">Afegir un filtre</string> <string name="filter_addition_title">Afegir un filtre</string>
<string name="filter_edit_dialog_title">Modificar un filtre</string> <string name="filter_edit_title">Modificar un filtre</string>
<string name="filter_dialog_remove_button">Eliminar</string> <string name="filter_dialog_remove_button">Eliminar</string>
<string name="add_account_name">Afegir un compte</string> <string name="add_account_name">Afegir un compte</string>
<string name="action_open_reblogger">Obre l\'autor de l\'impuls</string> <string name="action_open_reblogger">Obre l\'autor de l\'impuls</string>

View File

@ -406,8 +406,8 @@
<string name="filter_dialog_whole_word">هەموو وشەکە</string> <string name="filter_dialog_whole_word">هەموو وشەکە</string>
<string name="filter_dialog_update_button">نوێکردنەوە</string> <string name="filter_dialog_update_button">نوێکردنەوە</string>
<string name="filter_dialog_remove_button">لابردن</string> <string name="filter_dialog_remove_button">لابردن</string>
<string name="filter_edit_dialog_title">دەستکاریکردنی فلتەر</string> <string name="filter_edit_title">دەستکاریکردنی فلتەر</string>
<string name="filter_addition_dialog_title">زیادکردنی فلتەر</string> <string name="filter_addition_title">زیادکردنی فلتەر</string>
<string name="pref_title_thread_filter_keywords">گفتوگۆکان</string> <string name="pref_title_thread_filter_keywords">گفتوگۆکان</string>
<string name="pref_title_public_filter_keywords">هێڵی کاتی گشتی</string> <string name="pref_title_public_filter_keywords">هێڵی کاتی گشتی</string>
<string name="load_more_placeholder_text">بارکردنی زیاتر</string> <string name="load_more_placeholder_text">بارکردنی زیاتر</string>

View File

@ -265,8 +265,8 @@
<string name="load_more_placeholder_text">načíst více</string> <string name="load_more_placeholder_text">načíst více</string>
<string name="pref_title_public_filter_keywords">Veřejné časové osy</string> <string name="pref_title_public_filter_keywords">Veřejné časové osy</string>
<string name="pref_title_thread_filter_keywords">Konverzace</string> <string name="pref_title_thread_filter_keywords">Konverzace</string>
<string name="filter_addition_dialog_title">Přidat filtr</string> <string name="filter_addition_title">Přidat filtr</string>
<string name="filter_edit_dialog_title">Upravit filtr</string> <string name="filter_edit_title">Upravit filtr</string>
<string name="filter_dialog_remove_button">Odstranit</string> <string name="filter_dialog_remove_button">Odstranit</string>
<string name="filter_dialog_update_button">Aktualizovat</string> <string name="filter_dialog_update_button">Aktualizovat</string>
<string name="filter_add_description">Fráze k filtrování</string> <string name="filter_add_description">Fráze k filtrování</string>

View File

@ -331,8 +331,8 @@
<string name="notification_poll_name">Polau</string> <string name="notification_poll_name">Polau</string>
<string name="notification_sign_up_name">Cofrestriadau</string> <string name="notification_sign_up_name">Cofrestriadau</string>
<string name="pref_title_public_filter_keywords">Ffrydiau cyhoeddus</string> <string name="pref_title_public_filter_keywords">Ffrydiau cyhoeddus</string>
<string name="filter_addition_dialog_title">Ychwanegu hidlydd</string> <string name="filter_addition_title">Ychwanegu hidlydd</string>
<string name="filter_edit_dialog_title">Golygu hidlydd</string> <string name="filter_edit_title">Golygu hidlydd</string>
<string name="filter_dialog_update_button">Diweddaru</string> <string name="filter_dialog_update_button">Diweddaru</string>
<string name="pref_title_notification_filter_updates">golygwyd neges rwy wedi rhyngweithio ag ef</string> <string name="pref_title_notification_filter_updates">golygwyd neges rwy wedi rhyngweithio ag ef</string>
<string name="notification_update_format">Golygodd %s ei neges</string> <string name="notification_update_format">Golygodd %s ei neges</string>

View File

@ -246,8 +246,8 @@
<string name="replying_to">Antworten an @%s</string> <string name="replying_to">Antworten an @%s</string>
<string name="load_more_placeholder_text">mehr laden</string> <string name="load_more_placeholder_text">mehr laden</string>
<string name="pref_title_thread_filter_keywords">Konversationen</string> <string name="pref_title_thread_filter_keywords">Konversationen</string>
<string name="filter_addition_dialog_title">Filter hinzufügen</string> <string name="filter_addition_title">Filter hinzufügen</string>
<string name="filter_edit_dialog_title">Filter bearbeiten</string> <string name="filter_edit_title">Filter bearbeiten</string>
<string name="filter_dialog_remove_button">Entfernen</string> <string name="filter_dialog_remove_button">Entfernen</string>
<string name="filter_dialog_update_button">Aktualisieren</string> <string name="filter_dialog_update_button">Aktualisieren</string>
<string name="add_account_name">Konto hinzufügen</string> <string name="add_account_name">Konto hinzufügen</string>

View File

@ -260,8 +260,8 @@
<string name="load_more_placeholder_text">ŝarĝi pli</string> <string name="load_more_placeholder_text">ŝarĝi pli</string>
<string name="pref_title_public_filter_keywords">Publikaj tempolinioj</string> <string name="pref_title_public_filter_keywords">Publikaj tempolinioj</string>
<string name="pref_title_thread_filter_keywords">Konversacioj</string> <string name="pref_title_thread_filter_keywords">Konversacioj</string>
<string name="filter_addition_dialog_title">Aldoni filtrilon</string> <string name="filter_addition_title">Aldoni filtrilon</string>
<string name="filter_edit_dialog_title">Redakti filtrilon</string> <string name="filter_edit_title">Redakti filtrilon</string>
<string name="filter_dialog_remove_button">Forigi</string> <string name="filter_dialog_remove_button">Forigi</string>
<string name="filter_dialog_update_button">Aktualigi</string> <string name="filter_dialog_update_button">Aktualigi</string>
<string name="filter_add_description">Frazo filtrota</string> <string name="filter_add_description">Frazo filtrota</string>

View File

@ -382,8 +382,8 @@
<string name="notification_poll_description">Notificaciones sobre encuestas que han terminado</string> <string name="notification_poll_description">Notificaciones sobre encuestas que han terminado</string>
<string name="pref_title_public_filter_keywords">Cronologías públicas</string> <string name="pref_title_public_filter_keywords">Cronologías públicas</string>
<string name="pref_title_thread_filter_keywords">Conversaciones</string> <string name="pref_title_thread_filter_keywords">Conversaciones</string>
<string name="filter_addition_dialog_title">Añadir filtro</string> <string name="filter_addition_title">Añadir filtro</string>
<string name="filter_edit_dialog_title">Editar filtro</string> <string name="filter_edit_title">Editar filtro</string>
<string name="filter_dialog_update_button">Actualizar</string> <string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_add_description">Frase para filtrar</string> <string name="filter_add_description">Frase para filtrar</string>
<string name="error_create_list">No se pudo crear la lista</string> <string name="error_create_list">No se pudo crear la lista</string>

View File

@ -329,8 +329,8 @@
<string name="abbreviated_seconds_ago">%ds</string> <string name="abbreviated_seconds_ago">%ds</string>
<string name="pref_title_alway_open_spoiler">Beti zabaldu edukien abisuekin markatutako tootak</string> <string name="pref_title_alway_open_spoiler">Beti zabaldu edukien abisuekin markatutako tootak</string>
<string name="pref_title_thread_filter_keywords">Elkarrizketak</string> <string name="pref_title_thread_filter_keywords">Elkarrizketak</string>
<string name="filter_addition_dialog_title">Gehitu iragazkia</string> <string name="filter_addition_title">Gehitu iragazkia</string>
<string name="filter_edit_dialog_title">Editatu iragazkia</string> <string name="filter_edit_title">Editatu iragazkia</string>
<string name="filter_dialog_remove_button">Ezabatu</string> <string name="filter_dialog_remove_button">Ezabatu</string>
<string name="filter_dialog_update_button">Eguneratu</string> <string name="filter_dialog_update_button">Eguneratu</string>
<string name="filter_dialog_whole_word">Hitz osoa</string> <string name="filter_dialog_whole_word">Hitz osoa</string>

View File

@ -323,8 +323,8 @@
<string name="pref_title_alway_open_spoiler">گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا</string> <string name="pref_title_alway_open_spoiler">گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا</string>
<string name="pref_title_public_filter_keywords">خط زمانی‌های عمومی</string> <string name="pref_title_public_filter_keywords">خط زمانی‌های عمومی</string>
<string name="pref_title_thread_filter_keywords">گفت‌وگوها</string> <string name="pref_title_thread_filter_keywords">گفت‌وگوها</string>
<string name="filter_addition_dialog_title">افزودن پالایه</string> <string name="filter_addition_title">افزودن پالایه</string>
<string name="filter_edit_dialog_title">ویرایش پالایه</string> <string name="filter_edit_title">ویرایش پالایه</string>
<string name="filter_dialog_remove_button">برداشتن</string> <string name="filter_dialog_remove_button">برداشتن</string>
<string name="filter_dialog_update_button">به‌روز رسانی</string> <string name="filter_dialog_update_button">به‌روز رسانی</string>
<string name="filter_dialog_whole_word">تمام واژه</string> <string name="filter_dialog_whole_word">تمام واژه</string>

View File

@ -279,7 +279,7 @@
<string name="notification_favourite_name">Suosikit</string> <string name="notification_favourite_name">Suosikit</string>
<string name="notification_poll_name">Äänestykset</string> <string name="notification_poll_name">Äänestykset</string>
<string name="notification_poll_description">Ilmoitukset päättyneistä äänestyksistä</string> <string name="notification_poll_description">Ilmoitukset päättyneistä äänestyksistä</string>
<string name="filter_edit_dialog_title">Muokkaa suodatinta</string> <string name="filter_edit_title">Muokkaa suodatinta</string>
<string name="action_add_reaction">lisää reaktio</string> <string name="action_add_reaction">lisää reaktio</string>
<string name="confirmation_reported">Lähetetty!</string> <string name="confirmation_reported">Lähetetty!</string>
<string name="post_sent">Lähetetty!</string> <string name="post_sent">Lähetetty!</string>
@ -302,7 +302,7 @@
<string name="load_more_placeholder_text">lataa lisää</string> <string name="load_more_placeholder_text">lataa lisää</string>
<string name="pref_title_public_filter_keywords">Julkiset aikajanat</string> <string name="pref_title_public_filter_keywords">Julkiset aikajanat</string>
<string name="pref_title_thread_filter_keywords">Keskustelut</string> <string name="pref_title_thread_filter_keywords">Keskustelut</string>
<string name="filter_addition_dialog_title">Lisää suodatin</string> <string name="filter_addition_title">Lisää suodatin</string>
<string name="later">Myöhemmin</string> <string name="later">Myöhemmin</string>
<string name="description_post_cw">Sisältövaroitus: %s</string> <string name="description_post_cw">Sisältövaroitus: %s</string>
<string name="pref_title_http_proxy_server">HTTP-välityspalvelin</string> <string name="pref_title_http_proxy_server">HTTP-välityspalvelin</string>

View File

@ -265,8 +265,8 @@
<string name="load_more_placeholder_text">en charger plus</string> <string name="load_more_placeholder_text">en charger plus</string>
<string name="pref_title_public_filter_keywords">Fils publics</string> <string name="pref_title_public_filter_keywords">Fils publics</string>
<string name="pref_title_thread_filter_keywords">Conversations</string> <string name="pref_title_thread_filter_keywords">Conversations</string>
<string name="filter_addition_dialog_title">Ajouter un filtre</string> <string name="filter_addition_title">Ajouter un filtre</string>
<string name="filter_edit_dialog_title">Modifier un filtre</string> <string name="filter_edit_title">Modifier un filtre</string>
<string name="filter_dialog_remove_button">Supprimer</string> <string name="filter_dialog_remove_button">Supprimer</string>
<string name="filter_dialog_update_button">Mettre à jour</string> <string name="filter_dialog_update_button">Mettre à jour</string>
<string name="filter_add_description">Phrase à filtrer</string> <string name="filter_add_description">Phrase à filtrer</string>

View File

@ -28,8 +28,8 @@
<string name="add_account_name">Account Tafoegje</string> <string name="add_account_name">Account Tafoegje</string>
<string name="filter_dialog_update_button">Fernije</string> <string name="filter_dialog_update_button">Fernije</string>
<string name="filter_dialog_remove_button">Fuortsmite</string> <string name="filter_dialog_remove_button">Fuortsmite</string>
<string name="filter_edit_dialog_title">Filter oanpasse</string> <string name="filter_edit_title">Filter oanpasse</string>
<string name="filter_addition_dialog_title">Filter tafoegje</string> <string name="filter_addition_title">Filter tafoegje</string>
<string name="pref_title_thread_filter_keywords">Petearen</string> <string name="pref_title_thread_filter_keywords">Petearen</string>
<string name="load_more_placeholder_text">mear lade</string> <string name="load_more_placeholder_text">mear lade</string>
<string name="replying_to">Oan it reagearren op @%s</string> <string name="replying_to">Oan it reagearren op @%s</string>

View File

@ -278,8 +278,8 @@
<string name="replying_to">Ag freagairt do @%s</string> <string name="replying_to">Ag freagairt do @%s</string>
<string name="load_more_placeholder_text">Lódáil a thuilleadh</string> <string name="load_more_placeholder_text">Lódáil a thuilleadh</string>
<string name="pref_title_thread_filter_keywords">Comhráite</string> <string name="pref_title_thread_filter_keywords">Comhráite</string>
<string name="filter_addition_dialog_title">Cuir scagaire leis</string> <string name="filter_addition_title">Cuir scagaire leis</string>
<string name="filter_edit_dialog_title">Cuir scagaire in eagar</string> <string name="filter_edit_title">Cuir scagaire in eagar</string>
<string name="filter_dialog_remove_button">Bain</string> <string name="filter_dialog_remove_button">Bain</string>
<string name="pref_title_public_filter_keywords">Amlínte poiblí</string> <string name="pref_title_public_filter_keywords">Amlínte poiblí</string>
<string name="expand_collapse_all_posts">Leathnaigh/Fill na postálacha go léir</string> <string name="expand_collapse_all_posts">Leathnaigh/Fill na postálacha go léir</string>

View File

@ -12,7 +12,7 @@
<string name="title_favourites">Annsachdan</string> <string name="title_favourites">Annsachdan</string>
<string name="link_whats_an_instance">Dè a th ann an ionstans\?</string> <string name="link_whats_an_instance">Dè a th ann an ionstans\?</string>
<string name="edit_poll">Deasaich</string> <string name="edit_poll">Deasaich</string>
<string name="filter_edit_dialog_title">Deasaich a chriathrag</string> <string name="filter_edit_title">Deasaich a chriathrag</string>
<string name="action_edit_list">Deasaich an liosta</string> <string name="action_edit_list">Deasaich an liosta</string>
<string name="pref_title_edit_notification_settings">Brathan</string> <string name="pref_title_edit_notification_settings">Brathan</string>
<string name="action_edit_own_profile">Deasaich</string> <string name="action_edit_own_profile">Deasaich</string>
@ -307,7 +307,7 @@
<string name="filter_dialog_whole_word">Facal slàn</string> <string name="filter_dialog_whole_word">Facal slàn</string>
<string name="filter_dialog_update_button">Ùraich</string> <string name="filter_dialog_update_button">Ùraich</string>
<string name="filter_dialog_remove_button">Thoir air falbh</string> <string name="filter_dialog_remove_button">Thoir air falbh</string>
<string name="filter_addition_dialog_title">Cuir criathrag ris</string> <string name="filter_addition_title">Cuir criathrag ris</string>
<string name="pref_title_thread_filter_keywords">Còmhraidhean</string> <string name="pref_title_thread_filter_keywords">Còmhraidhean</string>
<string name="pref_title_public_filter_keywords">Loidhnichean-ama poblach</string> <string name="pref_title_public_filter_keywords">Loidhnichean-ama poblach</string>
<string name="load_more_placeholder_text">luchdaich barrachd dheth</string> <string name="load_more_placeholder_text">luchdaich barrachd dheth</string>

View File

@ -281,8 +281,8 @@
<string name="filter_dialog_whole_word">Palabra completa</string> <string name="filter_dialog_whole_word">Palabra completa</string>
<string name="filter_dialog_update_button">Actualizar</string> <string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_dialog_remove_button">Eliminar</string> <string name="filter_dialog_remove_button">Eliminar</string>
<string name="filter_edit_dialog_title">Editar filtro</string> <string name="filter_edit_title">Editar filtro</string>
<string name="filter_addition_dialog_title">Engadir filtro</string> <string name="filter_addition_title">Engadir filtro</string>
<string name="pref_title_thread_filter_keywords">Conversas</string> <string name="pref_title_thread_filter_keywords">Conversas</string>
<string name="pref_title_public_filter_keywords">Cronoloxías públicas</string> <string name="pref_title_public_filter_keywords">Cronoloxías públicas</string>
<string name="load_more_placeholder_text">cargar máis</string> <string name="load_more_placeholder_text">cargar máis</string>

View File

@ -247,8 +247,8 @@
<string name="action_set_caption">कैप्शन सेट करें</string> <string name="action_set_caption">कैप्शन सेट करें</string>
<string name="add_account_name">खाता जोड़ो</string> <string name="add_account_name">खाता जोड़ो</string>
<string name="filter_dialog_whole_word">पूरा शब्द</string> <string name="filter_dialog_whole_word">पूरा शब्द</string>
<string name="filter_edit_dialog_title">फ़िल्टर संपादित करें</string> <string name="filter_edit_title">फ़िल्टर संपादित करें</string>
<string name="filter_addition_dialog_title">फिल्टर लगाएं</string> <string name="filter_addition_title">फिल्टर लगाएं</string>
<string name="pref_title_public_filter_keywords">सार्वजनिक टाइमलाइन</string> <string name="pref_title_public_filter_keywords">सार्वजनिक टाइमलाइन</string>
<string name="load_more_placeholder_text">और लोड करें</string> <string name="load_more_placeholder_text">और लोड करें</string>
<string name="pref_title_show_cards_in_timelines">टाइमलाइन में लिंक प्रीव्यू दिखाएं</string> <string name="pref_title_show_cards_in_timelines">टाइमलाइन में लिंक प्रीव्यू दिखाएं</string>

View File

@ -307,8 +307,8 @@
<string name="replying_to">Válasz @%s részére</string> <string name="replying_to">Válasz @%s részére</string>
<string name="pref_title_public_filter_keywords">Nyilvános idővonalak</string> <string name="pref_title_public_filter_keywords">Nyilvános idővonalak</string>
<string name="pref_title_thread_filter_keywords">Beszélgetések</string> <string name="pref_title_thread_filter_keywords">Beszélgetések</string>
<string name="filter_addition_dialog_title">Szűrő hozzáadása</string> <string name="filter_addition_title">Szűrő hozzáadása</string>
<string name="filter_edit_dialog_title">Szűrő szerkesztése</string> <string name="filter_edit_title">Szűrő szerkesztése</string>
<string name="filter_dialog_remove_button">Eltávolítás</string> <string name="filter_dialog_remove_button">Eltávolítás</string>
<string name="filter_dialog_update_button">Frissítés</string> <string name="filter_dialog_update_button">Frissítés</string>
<string name="filter_add_description">Szűrendő kifejezés</string> <string name="filter_add_description">Szűrendő kifejezés</string>

View File

@ -277,8 +277,8 @@
<string name="load_more_placeholder_text">hlaða inn fleiru</string> <string name="load_more_placeholder_text">hlaða inn fleiru</string>
<string name="pref_title_public_filter_keywords">Opinberar tímalínur</string> <string name="pref_title_public_filter_keywords">Opinberar tímalínur</string>
<string name="pref_title_thread_filter_keywords">Samtöl</string> <string name="pref_title_thread_filter_keywords">Samtöl</string>
<string name="filter_addition_dialog_title">Bæta við síu</string> <string name="filter_addition_title">Bæta við síu</string>
<string name="filter_edit_dialog_title">Breyta síu</string> <string name="filter_edit_title">Breyta síu</string>
<string name="filter_dialog_remove_button">Fjarlægja</string> <string name="filter_dialog_remove_button">Fjarlægja</string>
<string name="filter_dialog_update_button">Uppfæra</string> <string name="filter_dialog_update_button">Uppfæra</string>
<string name="filter_dialog_whole_word">Heil orð</string> <string name="filter_dialog_whole_word">Heil orð</string>

View File

@ -259,8 +259,8 @@
<string name="load_more_placeholder_text">carica altro</string> <string name="load_more_placeholder_text">carica altro</string>
<string name="pref_title_public_filter_keywords">Timeline pubbliche</string> <string name="pref_title_public_filter_keywords">Timeline pubbliche</string>
<string name="pref_title_thread_filter_keywords">Conversazioni</string> <string name="pref_title_thread_filter_keywords">Conversazioni</string>
<string name="filter_addition_dialog_title">Aggiungi filtro</string> <string name="filter_addition_title">Aggiungi filtro</string>
<string name="filter_edit_dialog_title">Modifica filtro</string> <string name="filter_edit_title">Modifica filtro</string>
<string name="filter_dialog_remove_button">Rimuovi</string> <string name="filter_dialog_remove_button">Rimuovi</string>
<string name="filter_dialog_update_button">Aggiorna</string> <string name="filter_dialog_update_button">Aggiorna</string>
<string name="filter_add_description">Frase da filtrare</string> <string name="filter_add_description">Frase da filtrare</string>

View File

@ -248,8 +248,8 @@
<string name="title_media">メディア</string> <string name="title_media">メディア</string>
<string name="replying_to">\@%sに返信</string> <string name="replying_to">\@%sに返信</string>
<string name="load_more_placeholder_text">さらに読み込む</string> <string name="load_more_placeholder_text">さらに読み込む</string>
<string name="filter_addition_dialog_title">フィルターを追加</string> <string name="filter_addition_title">フィルターを追加</string>
<string name="filter_edit_dialog_title">フィルターを編集</string> <string name="filter_edit_title">フィルターを編集</string>
<string name="add_account_name">アカウントを追加</string> <string name="add_account_name">アカウントを追加</string>
<string name="add_account_description">新しいMastodonアカウントを追加</string> <string name="add_account_description">新しいMastodonアカウントを追加</string>
<string name="action_lists">リスト</string> <string name="action_lists">リスト</string>

View File

@ -121,8 +121,8 @@
<string name="action_schedule_post">Sɣiwes tijewwaqt-a</string> <string name="action_schedule_post">Sɣiwes tijewwaqt-a</string>
<string name="post_share_content">Bḍu agbur n tijewwiqt-a</string> <string name="post_share_content">Bḍu agbur n tijewwiqt-a</string>
<string name="post_share_link">Bḍu aseɣwen ɣer tijewwiqt</string> <string name="post_share_link">Bḍu aseɣwen ɣer tijewwiqt</string>
<string name="filter_addition_dialog_title">Rnu amsizdeg</string> <string name="filter_addition_title">Rnu amsizdeg</string>
<string name="filter_edit_dialog_title">Ẓreg amsizdeg</string> <string name="filter_edit_title">Ẓreg amsizdeg</string>
<string name="action_create_list">Snulfu-d tabdart</string> <string name="action_create_list">Snulfu-d tabdart</string>
<string name="action_rename_list">Snifel isem n tabdart</string> <string name="action_rename_list">Snifel isem n tabdart</string>
<string name="action_delete_list">Kkes tabdart-a</string> <string name="action_delete_list">Kkes tabdart-a</string>

View File

@ -269,8 +269,8 @@
<string name="load_more_placeholder_text">더 불러오기</string> <string name="load_more_placeholder_text">더 불러오기</string>
<string name="pref_title_public_filter_keywords">공개 타임라인</string> <string name="pref_title_public_filter_keywords">공개 타임라인</string>
<string name="pref_title_thread_filter_keywords">대화</string> <string name="pref_title_thread_filter_keywords">대화</string>
<string name="filter_addition_dialog_title">필터 추가</string> <string name="filter_addition_title">필터 추가</string>
<string name="filter_edit_dialog_title">필터 편집</string> <string name="filter_edit_title">필터 편집</string>
<string name="filter_dialog_remove_button">삭제</string> <string name="filter_dialog_remove_button">삭제</string>
<string name="filter_dialog_update_button">변경 사항 저장</string> <string name="filter_dialog_update_button">변경 사항 저장</string>
<string name="filter_dialog_whole_word">단어 전체에 매칭</string> <string name="filter_dialog_whole_word">단어 전체에 매칭</string>

View File

@ -118,13 +118,13 @@
<string name="post_media_attachments">Pielikumi</string> <string name="post_media_attachments">Pielikumi</string>
<string name="state_follow_requested">Sekošana pieprasīta</string> <string name="state_follow_requested">Sekošana pieprasīta</string>
<string name="pref_title_public_filter_keywords">Publiskās laika līnijas</string> <string name="pref_title_public_filter_keywords">Publiskās laika līnijas</string>
<string name="filter_addition_dialog_title">Pievienot filtru</string> <string name="filter_addition_title">Pievienot filtru</string>
<string name="pref_title_thread_filter_keywords">Sarunas</string> <string name="pref_title_thread_filter_keywords">Sarunas</string>
<string name="filter_dialog_remove_button">Noņemt</string> <string name="filter_dialog_remove_button">Noņemt</string>
<string name="filter_dialog_update_button">Atjaunināt</string> <string name="filter_dialog_update_button">Atjaunināt</string>
<string name="action_lists">Saraksti</string> <string name="action_lists">Saraksti</string>
<string name="title_lists">Saraksti</string> <string name="title_lists">Saraksti</string>
<string name="filter_edit_dialog_title">Labot filtru</string> <string name="filter_edit_title">Labot filtru</string>
<string name="add_account_name">Pievienot kontu</string> <string name="add_account_name">Pievienot kontu</string>
<string name="error_delete_list">Nevarēja dzēst sarakstu</string> <string name="error_delete_list">Nevarēja dzēst sarakstu</string>
<string name="action_create_list">Izveidot sarakstu</string> <string name="action_create_list">Izveidot sarakstu</string>

View File

@ -231,8 +231,8 @@
<string name="load_more_placeholder_text">last mer</string> <string name="load_more_placeholder_text">last mer</string>
<string name="pref_title_public_filter_keywords">Offentlige tidslinjer</string> <string name="pref_title_public_filter_keywords">Offentlige tidslinjer</string>
<string name="pref_title_thread_filter_keywords">Samtaler</string> <string name="pref_title_thread_filter_keywords">Samtaler</string>
<string name="filter_addition_dialog_title">Legg til filter</string> <string name="filter_addition_title">Legg til filter</string>
<string name="filter_edit_dialog_title">Endre filter</string> <string name="filter_edit_title">Endre filter</string>
<string name="filter_dialog_remove_button">Fjern</string> <string name="filter_dialog_remove_button">Fjern</string>
<string name="filter_dialog_update_button">Oppdater</string> <string name="filter_dialog_update_button">Oppdater</string>
<string name="filter_add_description">Filtrer frase</string> <string name="filter_add_description">Filtrer frase</string>

View File

@ -337,8 +337,8 @@
<string name="pref_title_timeline_filters">Filters</string> <string name="pref_title_timeline_filters">Filters</string>
<string name="pref_title_public_filter_keywords">Openbare tijdlijnen</string> <string name="pref_title_public_filter_keywords">Openbare tijdlijnen</string>
<string name="pref_title_thread_filter_keywords">Gesprekken</string> <string name="pref_title_thread_filter_keywords">Gesprekken</string>
<string name="filter_addition_dialog_title">Filter toevoegen</string> <string name="filter_addition_title">Filter toevoegen</string>
<string name="filter_edit_dialog_title">Filter bewerken</string> <string name="filter_edit_title">Filter bewerken</string>
<string name="filter_dialog_remove_button">Verwijderen</string> <string name="filter_dialog_remove_button">Verwijderen</string>
<string name="filter_dialog_update_button">Bijwerken</string> <string name="filter_dialog_update_button">Bijwerken</string>
<string name="filter_add_description">Zinsdeel om te filteren</string> <string name="filter_add_description">Zinsdeel om te filteren</string>

View File

@ -305,8 +305,8 @@
<string name="abbreviated_seconds_ago">%ds</string> <string name="abbreviated_seconds_ago">%ds</string>
<string name="pref_title_public_filter_keywords">Flux publics</string> <string name="pref_title_public_filter_keywords">Flux publics</string>
<string name="pref_title_thread_filter_keywords">Discutidas</string> <string name="pref_title_thread_filter_keywords">Discutidas</string>
<string name="filter_addition_dialog_title">Ajustar un filtre</string> <string name="filter_addition_title">Ajustar un filtre</string>
<string name="filter_edit_dialog_title">Modificar un filtre</string> <string name="filter_edit_title">Modificar un filtre</string>
<string name="filter_dialog_remove_button">Suprimir</string> <string name="filter_dialog_remove_button">Suprimir</string>
<string name="filter_dialog_update_button">Actualizar</string> <string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_add_description">Frasa de filtrar</string> <string name="filter_add_description">Frasa de filtrar</string>

View File

@ -318,8 +318,8 @@
<string name="pref_title_alway_open_spoiler">Zawsze rozwijaj wpisy z ostrzeżeniami o zawartości</string> <string name="pref_title_alway_open_spoiler">Zawsze rozwijaj wpisy z ostrzeżeniami o zawartości</string>
<string name="pref_title_public_filter_keywords">Publiczne osi czasu</string> <string name="pref_title_public_filter_keywords">Publiczne osi czasu</string>
<string name="pref_title_thread_filter_keywords">Konwersacje</string> <string name="pref_title_thread_filter_keywords">Konwersacje</string>
<string name="filter_addition_dialog_title">Dodaj filtr</string> <string name="filter_addition_title">Dodaj filtr</string>
<string name="filter_edit_dialog_title">Edytuj filtr</string> <string name="filter_edit_title">Edytuj filtr</string>
<string name="filter_dialog_remove_button">Usuń</string> <string name="filter_dialog_remove_button">Usuń</string>
<string name="filter_dialog_update_button">Aktualizuj</string> <string name="filter_dialog_update_button">Aktualizuj</string>
<string name="filter_dialog_whole_word">Całe słowo</string> <string name="filter_dialog_whole_word">Całe słowo</string>

View File

@ -311,8 +311,8 @@
<string name="notification_poll_description">Notificar sobre enquetes que já terminaram</string> <string name="notification_poll_description">Notificar sobre enquetes que já terminaram</string>
<string name="pref_title_public_filter_keywords">Linhas públicas</string> <string name="pref_title_public_filter_keywords">Linhas públicas</string>
<string name="pref_title_thread_filter_keywords">Conversas</string> <string name="pref_title_thread_filter_keywords">Conversas</string>
<string name="filter_addition_dialog_title">Criar filtro</string> <string name="filter_addition_title">Criar filtro</string>
<string name="filter_edit_dialog_title">Editar filtro</string> <string name="filter_edit_title">Editar filtro</string>
<string name="filter_dialog_remove_button">Excluir</string> <string name="filter_dialog_remove_button">Excluir</string>
<string name="filter_dialog_update_button">Atualizar</string> <string name="filter_dialog_update_button">Atualizar</string>
<string name="filter_add_description">Frase para filtrar</string> <string name="filter_add_description">Frase para filtrar</string>

View File

@ -311,8 +311,8 @@
<string name="load_more_placeholder_text">carregar mais</string> <string name="load_more_placeholder_text">carregar mais</string>
<string name="pref_title_public_filter_keywords">Timelines públicas</string> <string name="pref_title_public_filter_keywords">Timelines públicas</string>
<string name="pref_title_thread_filter_keywords">Conversas</string> <string name="pref_title_thread_filter_keywords">Conversas</string>
<string name="filter_addition_dialog_title">Criar filtro</string> <string name="filter_addition_title">Criar filtro</string>
<string name="filter_edit_dialog_title">Editar filtro</string> <string name="filter_edit_title">Editar filtro</string>
<string name="filter_dialog_remove_button">Remover</string> <string name="filter_dialog_remove_button">Remover</string>
<string name="filter_dialog_whole_word_description">Se a palavra ou frase for alfanumérica, só será aplicado se corresponder à palavra completa</string> <string name="filter_dialog_whole_word_description">Se a palavra ou frase for alfanumérica, só será aplicado se corresponder à palavra completa</string>
<string name="filter_add_description">Frase para filtrar</string> <string name="filter_add_description">Frase para filtrar</string>

View File

@ -285,8 +285,8 @@
<string name="load_more_placeholder_text">показать ещё</string> <string name="load_more_placeholder_text">показать ещё</string>
<string name="pref_title_public_filter_keywords">Публичные ленты</string> <string name="pref_title_public_filter_keywords">Публичные ленты</string>
<string name="pref_title_thread_filter_keywords">Разговоры</string> <string name="pref_title_thread_filter_keywords">Разговоры</string>
<string name="filter_addition_dialog_title">Добавить фильтр</string> <string name="filter_addition_title">Добавить фильтр</string>
<string name="filter_edit_dialog_title">Изм. фильтр</string> <string name="filter_edit_title">Изм. фильтр</string>
<string name="filter_dialog_remove_button">Удалить</string> <string name="filter_dialog_remove_button">Удалить</string>
<string name="filter_dialog_update_button">Обновить</string> <string name="filter_dialog_update_button">Обновить</string>
<string name="filter_add_description">Слова на фильтр</string> <string name="filter_add_description">Слова на фильтр</string>

View File

@ -206,8 +206,8 @@
<string name="filter_dialog_whole_word">सर्वः शब्दः</string> <string name="filter_dialog_whole_word">सर्वः शब्दः</string>
<string name="filter_dialog_update_button">नवीक्रियताम्</string> <string name="filter_dialog_update_button">नवीक्रियताम्</string>
<string name="filter_dialog_remove_button">नश्यताम्</string> <string name="filter_dialog_remove_button">नश्यताम्</string>
<string name="filter_edit_dialog_title">शोधकं सम्पाद्यताम्</string> <string name="filter_edit_title">शोधकं सम्पाद्यताम्</string>
<string name="filter_addition_dialog_title">शोधकं युज्यताम्</string> <string name="filter_addition_title">शोधकं युज्यताम्</string>
<string name="pref_title_thread_filter_keywords">आलापाः</string> <string name="pref_title_thread_filter_keywords">आलापाः</string>
<string name="pref_title_public_filter_keywords">सार्वजनिकतालिकाः</string> <string name="pref_title_public_filter_keywords">सार्वजनिकतालिकाः</string>
<string name="load_more_placeholder_text">अधिकमारोप्यताम्</string> <string name="load_more_placeholder_text">अधिकमारोप्यताम्</string>

View File

@ -51,7 +51,7 @@
<string name="pref_title_show_notifications_filter">දැනුම්දීම් පෙරහන පෙන්වන්න</string> <string name="pref_title_show_notifications_filter">දැනුම්දීම් පෙරහන පෙන්වන්න</string>
<string name="abbreviated_days_ago">දව. %d</string> <string name="abbreviated_days_ago">දව. %d</string>
<string name="action_logout">නික්මෙන්න</string> <string name="action_logout">නික්මෙන්න</string>
<string name="filter_edit_dialog_title">පෙරහන සංස්කරණය</string> <string name="filter_edit_title">පෙරහන සංස්කරණය</string>
<string name="notification_summary_medium">%1$s, %2$s, සහ %3$s</string> <string name="notification_summary_medium">%1$s, %2$s, සහ %3$s</string>
<string name="caption_twemoji">මාස්ටඩන් හි සම්මත ඉමෝජි කට්ටලය</string> <string name="caption_twemoji">මාස්ටඩන් හි සම්මත ඉමෝජි කට්ටලය</string>
<string name="poll_info_format"> <!-- 15 votes • 1 hour left --> %1$s • %2$s</string> <string name="poll_info_format"> <!-- 15 votes • 1 hour left --> %1$s • %2$s</string>
@ -142,7 +142,7 @@
<string name="label_quick_reply">පිළිතුරු…</string> <string name="label_quick_reply">පිළිතුරු…</string>
<string name="limit_notifications">කාලරේඛා දැනුම්දීම් සීමාකරන්න</string> <string name="limit_notifications">කාලරේඛා දැනුම්දීම් සීමාකරන්න</string>
<string name="send_post_notification_error_title">ටූට් යැවීමේ දෝෂයකි</string> <string name="send_post_notification_error_title">ටූට් යැවීමේ දෝෂයකි</string>
<string name="filter_addition_dialog_title">පෙරහන එකතු කරන්න</string> <string name="filter_addition_title">පෙරහන එකතු කරන්න</string>
<string name="pref_default_media_sensitivity">සැමවිටම මාධ්‍ය සංවේදී ලෙස සලකුණු කරන්න</string> <string name="pref_default_media_sensitivity">සැමවිටම මාධ්‍ය සංවේදී ලෙස සලකුණු කරන්න</string>
<string name="restart_required">යෙදුම යළි ඇරඹීම ඇවැසිය</string> <string name="restart_required">යෙදුම යළි ඇරඹීම ඇවැසිය</string>
<string name="restart">යළි අරඹන්න</string> <string name="restart">යළි අරඹන්න</string>

View File

@ -236,8 +236,8 @@
<string name="load_more_placeholder_text">naloži več</string> <string name="load_more_placeholder_text">naloži več</string>
<string name="pref_title_public_filter_keywords">Javne časovnice</string> <string name="pref_title_public_filter_keywords">Javne časovnice</string>
<string name="pref_title_thread_filter_keywords">Pogovori</string> <string name="pref_title_thread_filter_keywords">Pogovori</string>
<string name="filter_addition_dialog_title">Dodaj filter</string> <string name="filter_addition_title">Dodaj filter</string>
<string name="filter_edit_dialog_title">Uredi filter</string> <string name="filter_edit_title">Uredi filter</string>
<string name="filter_dialog_remove_button">Odstrani</string> <string name="filter_dialog_remove_button">Odstrani</string>
<string name="filter_dialog_update_button">Posodobi</string> <string name="filter_dialog_update_button">Posodobi</string>
<string name="filter_add_description">Filtriraj frazo</string> <string name="filter_add_description">Filtriraj frazo</string>

View File

@ -259,8 +259,8 @@
<string name="load_more_placeholder_text">ladda mer</string> <string name="load_more_placeholder_text">ladda mer</string>
<string name="pref_title_public_filter_keywords">Offentliga tidslinjer</string> <string name="pref_title_public_filter_keywords">Offentliga tidslinjer</string>
<string name="pref_title_thread_filter_keywords">Konversationer</string> <string name="pref_title_thread_filter_keywords">Konversationer</string>
<string name="filter_addition_dialog_title">Lägg till filter</string> <string name="filter_addition_title">Lägg till filter</string>
<string name="filter_edit_dialog_title">Redigera filter</string> <string name="filter_edit_title">Redigera filter</string>
<string name="filter_dialog_remove_button">Ta bort</string> <string name="filter_dialog_remove_button">Ta bort</string>
<string name="filter_dialog_update_button">Uppdatera</string> <string name="filter_dialog_update_button">Uppdatera</string>
<string name="filter_add_description">Filtrera fras</string> <string name="filter_add_description">Filtrera fras</string>

View File

@ -151,8 +151,8 @@
<string name="filter_dialog_whole_word">ทั้งคำ</string> <string name="filter_dialog_whole_word">ทั้งคำ</string>
<string name="filter_dialog_update_button">อัปเดต</string> <string name="filter_dialog_update_button">อัปเดต</string>
<string name="filter_dialog_remove_button">ลบ</string> <string name="filter_dialog_remove_button">ลบ</string>
<string name="filter_edit_dialog_title">แก้ไขตัวคัดกรอง</string> <string name="filter_edit_title">แก้ไขตัวคัดกรอง</string>
<string name="filter_addition_dialog_title">เพิ่มตัวคัดกรอง</string> <string name="filter_addition_title">เพิ่มตัวคัดกรอง</string>
<string name="pref_title_thread_filter_keywords">การสนทนา</string> <string name="pref_title_thread_filter_keywords">การสนทนา</string>
<string name="pref_title_public_filter_keywords">ไทม์ไลน์สาธารณะ</string> <string name="pref_title_public_filter_keywords">ไทม์ไลน์สาธารณะ</string>
<string name="load_more_placeholder_text">โหลดเพิ่ม</string> <string name="load_more_placeholder_text">โหลดเพิ่ม</string>

View File

@ -328,8 +328,8 @@
<string name="notification_poll_description">Sona eren anketlerle ilgili bildirimler</string> <string name="notification_poll_description">Sona eren anketlerle ilgili bildirimler</string>
<string name="pref_title_public_filter_keywords">Genel zaman çizelgesi</string> <string name="pref_title_public_filter_keywords">Genel zaman çizelgesi</string>
<string name="pref_title_thread_filter_keywords">Konuşmalar</string> <string name="pref_title_thread_filter_keywords">Konuşmalar</string>
<string name="filter_addition_dialog_title">Filtre ekle</string> <string name="filter_addition_title">Filtre ekle</string>
<string name="filter_edit_dialog_title">Filtreyi düzenle</string> <string name="filter_edit_title">Filtreyi düzenle</string>
<string name="filter_dialog_remove_button">Kaldır</string> <string name="filter_dialog_remove_button">Kaldır</string>
<string name="filter_dialog_update_button">Güncelle</string> <string name="filter_dialog_update_button">Güncelle</string>
<string name="filter_dialog_whole_word">Tüm dünya</string> <string name="filter_dialog_whole_word">Tüm dünya</string>

View File

@ -295,8 +295,8 @@
<string name="lock_account_label">Заблокувати обліковий запис</string> <string name="lock_account_label">Заблокувати обліковий запис</string>
<string name="action_remove">Вилучити</string> <string name="action_remove">Вилучити</string>
<string name="filter_dialog_remove_button">Вилучити</string> <string name="filter_dialog_remove_button">Вилучити</string>
<string name="filter_edit_dialog_title">Редагувати фільтр</string> <string name="filter_edit_title">Редагувати фільтр</string>
<string name="filter_addition_dialog_title">Додати фільтр</string> <string name="filter_addition_title">Додати фільтр</string>
<string name="pref_title_thread_filter_keywords">Розмови</string> <string name="pref_title_thread_filter_keywords">Розмови</string>
<string name="pref_title_public_filter_keywords">Загальнодоступні стрічки</string> <string name="pref_title_public_filter_keywords">Загальнодоступні стрічки</string>
<string name="load_more_placeholder_text">завантажити ще</string> <string name="load_more_placeholder_text">завантажити ще</string>

View File

@ -282,8 +282,8 @@
<string name="notification_favourite_description">Thông báo khi ai đó thích tút của bạn</string> <string name="notification_favourite_description">Thông báo khi ai đó thích tút của bạn</string>
<string name="notification_favourite_name">Lượt thích</string> <string name="notification_favourite_name">Lượt thích</string>
<string name="filter_dialog_whole_word">Toàn bộ chữ có chứa cụm từ này</string> <string name="filter_dialog_whole_word">Toàn bộ chữ có chứa cụm từ này</string>
<string name="filter_edit_dialog_title">Sửa bộ lọc</string> <string name="filter_edit_title">Sửa bộ lọc</string>
<string name="filter_addition_dialog_title">Thêm bộ lọc</string> <string name="filter_addition_title">Thêm bộ lọc</string>
<string name="pref_title_thread_filter_keywords">Thảo luận</string> <string name="pref_title_thread_filter_keywords">Thảo luận</string>
<string name="pref_title_public_filter_keywords">Liên hợp</string> <string name="pref_title_public_filter_keywords">Liên hợp</string>
<string name="load_more_placeholder_text">tải tút chưa đọc</string> <string name="load_more_placeholder_text">tải tút chưa đọc</string>

View File

@ -266,8 +266,8 @@
<string name="load_more_placeholder_text">加载更多</string> <string name="load_more_placeholder_text">加载更多</string>
<string name="pref_title_public_filter_keywords">公共时间轴</string> <string name="pref_title_public_filter_keywords">公共时间轴</string>
<string name="pref_title_thread_filter_keywords">对话</string> <string name="pref_title_thread_filter_keywords">对话</string>
<string name="filter_addition_dialog_title">添加新的过滤器</string> <string name="filter_addition_title">添加新的过滤器</string>
<string name="filter_edit_dialog_title">编辑过滤器</string> <string name="filter_edit_title">编辑过滤器</string>
<string name="filter_dialog_remove_button">移除</string> <string name="filter_dialog_remove_button">移除</string>
<string name="filter_dialog_update_button">更新</string> <string name="filter_dialog_update_button">更新</string>
<string name="filter_add_description">需要过滤的文字</string> <string name="filter_add_description">需要过滤的文字</string>

View File

@ -265,8 +265,8 @@
<string name="load_more_placeholder_text">載入更多</string> <string name="load_more_placeholder_text">載入更多</string>
<string name="pref_title_public_filter_keywords">公共時間軸</string> <string name="pref_title_public_filter_keywords">公共時間軸</string>
<string name="pref_title_thread_filter_keywords">對話</string> <string name="pref_title_thread_filter_keywords">對話</string>
<string name="filter_addition_dialog_title">添加新的過濾器</string> <string name="filter_addition_title">添加新的過濾器</string>
<string name="filter_edit_dialog_title">編輯過濾器</string> <string name="filter_edit_title">編輯過濾器</string>
<string name="filter_dialog_remove_button">移除</string> <string name="filter_dialog_remove_button">移除</string>
<string name="filter_dialog_update_button">更新</string> <string name="filter_dialog_update_button">更新</string>
<string name="filter_add_description">需要過濾的文字</string> <string name="filter_add_description">需要過濾的文字</string>

View File

@ -259,8 +259,8 @@
<string name="load_more_placeholder_text">載入更多</string> <string name="load_more_placeholder_text">載入更多</string>
<string name="pref_title_public_filter_keywords">公共時間軸</string> <string name="pref_title_public_filter_keywords">公共時間軸</string>
<string name="pref_title_thread_filter_keywords">對話</string> <string name="pref_title_thread_filter_keywords">對話</string>
<string name="filter_addition_dialog_title">添加新的過濾器</string> <string name="filter_addition_title">添加新的過濾器</string>
<string name="filter_edit_dialog_title">編輯過濾器</string> <string name="filter_edit_title">編輯過濾器</string>
<string name="filter_dialog_remove_button">移除</string> <string name="filter_dialog_remove_button">移除</string>
<string name="filter_dialog_update_button">更新</string> <string name="filter_dialog_update_button">更新</string>
<string name="filter_add_description">需要過濾的文字</string> <string name="filter_add_description">需要過濾的文字</string>

View File

@ -264,8 +264,8 @@
<string name="load_more_placeholder_text">加载更多</string> <string name="load_more_placeholder_text">加载更多</string>
<string name="pref_title_public_filter_keywords">公共时间轴</string> <string name="pref_title_public_filter_keywords">公共时间轴</string>
<string name="pref_title_thread_filter_keywords">对话</string> <string name="pref_title_thread_filter_keywords">对话</string>
<string name="filter_addition_dialog_title">添加新的过滤器</string> <string name="filter_addition_title">添加新的过滤器</string>
<string name="filter_edit_dialog_title">编辑过滤器</string> <string name="filter_edit_title">编辑过滤器</string>
<string name="filter_dialog_remove_button">移除</string> <string name="filter_dialog_remove_button">移除</string>
<string name="filter_dialog_update_button">更新</string> <string name="filter_dialog_update_button">更新</string>
<string name="filter_add_description">需要过滤的文字</string> <string name="filter_add_description">需要过滤的文字</string>

View File

@ -265,8 +265,8 @@
<string name="load_more_placeholder_text">載入更多</string> <string name="load_more_placeholder_text">載入更多</string>
<string name="pref_title_public_filter_keywords">公共時間軸</string> <string name="pref_title_public_filter_keywords">公共時間軸</string>
<string name="pref_title_thread_filter_keywords">對話</string> <string name="pref_title_thread_filter_keywords">對話</string>
<string name="filter_addition_dialog_title">添加新的過濾器</string> <string name="filter_addition_title">添加新的過濾器</string>
<string name="filter_edit_dialog_title">編輯過濾器</string> <string name="filter_edit_title">編輯過濾器</string>
<string name="filter_dialog_remove_button">移除</string> <string name="filter_dialog_remove_button">移除</string>
<string name="filter_dialog_update_button">更新</string> <string name="filter_dialog_update_button">更新</string>
<string name="filter_add_description">需要過濾的文字</string> <string name="filter_add_description">需要過濾的文字</string>

Some files were not shown because too many files have changed in this diff Show More