diff --git a/app/build.gradle b/app/build.gradle index 3fd7c5792..bef6eced1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,13 +25,13 @@ final def CUSTOM_INSTANCE = "" final def SUPPORT_ACCOUNT_URL = "https://mastodon.social/@Tusky" android { - compileSdk 35 + compileSdk 36 namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" minSdk 24 - targetSdk 35 + targetSdk 36 versionCode 131 versionName "28.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -103,8 +103,11 @@ android { // Exclude unneeded files added by libraries packagingOptions.resources.excludes += [ - 'LICENSE_OFL', - 'LICENSE_UNICODE', + 'LICENSE_OFL', + 'LICENSE_UNICODE', + 'META-INF/androidx/**', + 'META-INF/NOTICE.md', + 'DebugProbesKt.bin' ] bundle { diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index b4d6aa015..c039a046a 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -23,6 +23,28 @@ column="32"/> + + + + + + + + @@ -53,14 +75,14 @@ + message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.6.1. If deliberate, use tools:override="true", otherwise pick a different name."> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="PluralsCandidate" + message="Formatting %d followed by words ("people"): This should probably be a plural rather than a string" + errorLine1=" <string name="notifications_from_people_you_may_know">Notifications from %1$d people you may know</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/values/strings.xml" + line="873" + column="5"/> - - - - @@ -432,17 +355,6 @@ column="9"/> - - - - @@ -487,13 +399,6 @@ column="13"/> - - - - @@ -512,10 +417,21 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + - - - - - - - - diff --git a/app/lint.xml b/app/lint.xml index 4092b89ac..780739906 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -63,6 +63,9 @@ + + + @@ -70,12 +73,7 @@ - - - - - + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f666925d9..479d90efe 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,29 +20,16 @@ public static final ** CREATOR; } -# Preserve annotated Javascript interface methods. --keepclassmembers class * { - @android.webkit.JavascriptInterface ; -} - -# The support libraries contains references to newer platform versions. -# Don't warn about those in case this app is linking against an older -# platform version. We know about them, and they are safe. --dontnote androidx.** --dontwarn androidx.** - -# This class is deprecated, but remains for backward compatibility. --dontwarn android.util.FloatMath - -# These classes are duplicated between android.jar and core-lambda-stubs.jar. --dontnote java.lang.invoke.** - # TUSKY SPECIFIC OPTIONS # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile +# Preference fragments can be referenced by name, ensure they remain +# https://github.com/tuskyapp/Tusky/issues/3161 +-keep class * extends androidx.preference.PreferenceFragmentCompat + # remove all logging from production apk -assumenosideeffects class android.util.Log { public static *** getStackTraceString(...); @@ -66,9 +53,16 @@ static void checkNotNullExpressionValue(java.lang.Object, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); + static void throwUninitializedProperty(java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String); } -# Preference fragments can be referenced by name, ensure they remain -# https://github.com/tuskyapp/Tusky/issues/3161 --keep class * extends androidx.preference.PreferenceFragmentCompat +# there is no need for edit mode in production builds, allow it to be pruned +-assumenosideeffects public class * extends android.view.View { + boolean isInEditMode(); +} +-assumevalues public class * extends android.view.View { + boolean isInEditMode() return false; +} + +-checkdiscard class com.keylesspalace.tusky.usecase.DeveloperToolsUseCase diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json new file mode 100644 index 000000000..30ad53a2a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json @@ -0,0 +1,1405 @@ +{ + "formatVersion": 1, + "database": { + "version": 70, + "identityHash": "f1ac7b67aa0a9a279f7f35f5817b6a17", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsAdmin` INTEGER NOT NULL DEFAULT true, `notificationsOther` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profileHeaderUrl", + "columnName": "profileHeaderUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsAdmin", + "columnName": "notificationsAdmin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationsOther", + "columnName": "notificationsOther", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultReplyPrivacy", + "columnName": "defaultReplyPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `translationEnabled` INTEGER, `mastodonApiVersion` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mastodonApiVersion", + "columnName": "mastodonApiVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filterV2Supported", + "columnName": "filterV2Supported", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", + "unique": false, + "columnNames": [ + "authorServerId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `note` TEXT NOT NULL DEFAULT '', `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `event` TEXT, `moderationWarning` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reportId", + "columnName": "reportId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "moderationWarning", + "columnName": "moderationWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_accountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "accountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_NotificationEntity_reportId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reportId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "accountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "NotificationReportEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reportId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusIds", + "columnName": "statusIds", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetAccountId", + "columnName": "targetAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "targetAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "targetAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "HomeTimelineEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loading", + "columnName": "loading", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "tuskyAccountId" + ] + }, + "indices": [ + { + "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", + "unique": false, + "columnNames": [ + "statusId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" + }, + { + "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", + "unique": false, + "columnNames": [ + "reblogAccountId", + "tuskyAccountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineStatusEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "statusId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + }, + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "reblogAccountId", + "tuskyAccountId" + ], + "referencedColumns": [ + "serverId", + "tuskyAccountId" + ] + } + ] + }, + { + "tableName": "NotificationPolicyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `pendingRequestsCount` INTEGER NOT NULL, `pendingNotificationsCount` INTEGER NOT NULL, PRIMARY KEY(`tuskyAccountId`))", + "fields": [ + { + "fieldPath": "tuskyAccountId", + "columnName": "tuskyAccountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequestsCount", + "columnName": "pendingRequestsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingNotificationsCount", + "columnName": "pendingNotificationsCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tuskyAccountId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1ac7b67aa0a9a279f7f35f5817b6a17')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index a976d22ba..a7268ed42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -25,7 +25,6 @@ import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.Animatable -import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build @@ -49,6 +48,7 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.toDrawable import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat @@ -95,11 +95,9 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.LogoutUsecase -import com.keylesspalace.tusky.util.ActivityConstants import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.overrideActivityTransitionCompat import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation @@ -128,7 +126,6 @@ import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject -import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import javax.inject.Inject import kotlinx.coroutines.launch @@ -164,9 +161,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { private var onTabSelectedListener: OnTabSelectedListener? = null - // We need to know if the emoji pack has been changed - private var selectedEmojiPack: String? = null - /** Mediate between binding.viewPager and the chosen tab layout */ private var tabLayoutMediator: TabLayoutMediator? = null @@ -217,16 +211,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { viewModel.setupNotifications(this) } - if (explodeAnimationWasRequested()) { - overrideActivityTransitionCompat( - ActivityConstants.OVERRIDE_TRANSITION_OPEN, - R.anim.explode, - R.anim.activity_open_exit - ) - } - - selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once @@ -488,20 +472,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - override fun onResume() { - super.onResume() - val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - if (currentEmojiPack != selectedEmojiPack) { - Log.d( - TAG, - "onResume: EmojiPack has been changed from %s to %s" - .format(selectedEmojiPack, currentEmojiPack) - ) - selectedEmojiPack = currentEmojiPack - recreate() - } - } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { // Allow software back press to be properly dispatched to drawer layout val handled = when (event.action) { @@ -1051,7 +1021,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { transition: Transition? ) { activeToolbar.navigationIcon = FixedSizeDrawable( - BitmapDrawable(resources, resource), + resource.toDrawable(resources), navIconSize, navIconSize ) @@ -1118,15 +1088,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - private fun explodeAnimationWasRequested(): Boolean { - return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false) - } - override fun getActionButton() = binding.composeButton companion object { - const val OPEN_WITH_EXPLODE_ANIMATION = "explode" - private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 diff --git a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt index 53b730711..dabb08e62 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky -import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -33,7 +32,6 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ShareShortcutHelper import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -46,7 +44,6 @@ import kotlinx.coroutines.launch @HiltViewModel class MainViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val api: MastodonApi, private val eventHub: EventHub, private val accountManager: AccountManager, diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index d0cbc949d..b6dfeaff3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -204,7 +204,7 @@ class StatusListActivity : BottomSheetActivity() { { filters -> mutedFilter = filters.firstOrNull { filter -> // TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)? - filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag + filter.context.contains(Filter.Kind.HOME) && filter.title == hashedTag } updateTagMuteState(mutedFilter != null) }, @@ -250,8 +250,8 @@ class StatusListActivity : BottomSheetActivity() { mastodonApi.createFilter( title = "#$tag", - context = listOf(Filter.Kind.HOME.kind), - filterAction = Filter.Action.WARN.action, + context = listOf(Filter.Kind.HOME), + filterAction = Filter.Action.WARN, expiresIn = FilterExpiration.never ).fold( { filter -> @@ -286,7 +286,7 @@ class StatusListActivity : BottomSheetActivity() { ).fold( { filter -> mutedFilterV1 = filter - eventHub.dispatch(FilterUpdatedEvent(filter.context)) + eventHub.dispatch(FilterUpdatedEvent(filter.context.map { Filter.Kind.valueOf(it) })) filterCreateSuccess = true }, { throwable2 -> @@ -344,7 +344,7 @@ class StatusListActivity : BottomSheetActivity() { // 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 } + context = filter.context.filterNot { it == Filter.Kind.HOME } ) } else { mastodonApi.deleteFilter(filter.id) @@ -356,7 +356,7 @@ class StatusListActivity : BottomSheetActivity() { mastodonApi.updateFilterV1( id = filter.id, phrase = filter.phrase, - context = filter.context.filter { it != Filter.Kind.HOME.kind }, + context = filter.context.filterNot { it == Filter.Kind.HOME.kind }, irreversible = null, wholeWord = null, expiresIn = FilterExpiration.never @@ -372,7 +372,7 @@ class StatusListActivity : BottomSheetActivity() { result?.fold( { updateTagMuteState(false) - eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME.kind))) + eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME))) mutedFilterV1 = null mutedFilter = null diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 1430327ab..1d45ad404 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -38,6 +38,7 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ShareCompat import androidx.core.content.FileProvider +import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter @@ -63,7 +64,6 @@ import dagger.hilt.android.AndroidEntryPoint import java.io.File import java.io.FileOutputStream import java.io.IOException -import java.util.Locale import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -77,9 +77,6 @@ class ViewMediaActivity : private val binding by viewBinding(ActivityViewMediaBinding::inflate) - val toolbar: View - get() = binding.toolbar - var isToolbarVisible = true private set @@ -114,9 +111,6 @@ class ViewMediaActivity : attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) - // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener - // but it cannot be expressed and if I don't specify type explicitly compilation fails - // (probably a bug in compiler) val adapter: ViewMediaAdapter = if (attachments != null) { val realAttachs = attachments!!.map(AttachmentViewData::attachment) // Setup the view pager. @@ -139,11 +133,10 @@ class ViewMediaActivity : // Setup the toolbar. setSupportActionBar(binding.toolbar) - val actionBar = supportActionBar - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true) - actionBar.setDisplayShowHomeEnabled(true) - actionBar.title = getPageTitle(initialPosition) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + title = getPageTitle(initialPosition) } binding.toolbar.setNavigationOnClickListener { supportFinishAfterTransition() } binding.toolbar.setOnMenuItemClickListener { item: MenuItem -> @@ -203,14 +196,14 @@ class ViewMediaActivity : val alpha = if (isToolbarVisible) 1.0f else 0.0f if (isToolbarVisible) { // If to be visible, need to make visible immediately and animate alpha - binding.toolbar.alpha = 0.0f - binding.toolbar.visibility = visibility + binding.appBarLayout.alpha = 0.0f + binding.appBarLayout.visibility = visibility } - binding.toolbar.animate().alpha(alpha) + binding.appBarLayout.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - binding.toolbar.visibility = visibility + binding.appBarLayout.visibility = visibility animation.removeListener(this) } }) @@ -221,20 +214,20 @@ class ViewMediaActivity : if (attachments == null) { return "" } - return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size) + return "${position + 1}/${attachments?.size}" } private fun downloadMedia() { val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url - val filename = Uri.parse(url).lastPathSegment + val filename = url.toUri().lastPathSegment Toast.makeText( applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT ).show() - val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(Uri.parse(url)) + val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(url.toUri()) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) downloadManager.enqueue(request) } @@ -303,7 +296,7 @@ class ViewMediaActivity : val file = File(directory, getTemporaryMediaFilename("png")) val result = try { val bitmap = - Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync() + Glide.with(applicationContext).asBitmap().load(url.toUri()).submitAsync() try { FileOutputStream(file).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) @@ -332,7 +325,7 @@ class ViewMediaActivity : } private fun shareMediaFile(directory: File, url: String) { - val uri = Uri.parse(url) + val uri = url.toUri() val mimeTypeMap = MimeTypeMap.getSingleton() val extension = MimeTypeMap.getFileExtensionFromUrl(url) val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension) @@ -367,7 +360,7 @@ class ViewMediaActivity : @JvmStatic fun newIntent( - context: Context?, + context: Context, attachments: List, index: Int ): Intent { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt new file mode 100644 index 000000000..79dcc24ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt @@ -0,0 +1,46 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.visible + +/** + * Placeholder for missing parts in timelines. + * + * Displays a "Load more" button to load the gap, or a + * circular progress bar if the missing page is being loaded. + */ +class LoadMoreViewHolder( + private val binding: ItemLoadMoreBinding, + listener: StatusActionListener +) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.loadMoreButton.setOnClickListener { + binding.loadMoreButton.hide() + binding.loadMoreProgressBar.show() + listener.onLoadMore(bindingAdapterPosition) + } + } + + fun setup(loading: Boolean) { + binding.loadMoreButton.visible(!loading) + binding.loadMoreProgressBar.visible(loading) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt similarity index 95% rename from app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt index b8373f986..99f510623 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.components.conversation +package com.keylesspalace.tusky.adapter import android.view.LayoutInflater import android.view.ViewGroup @@ -23,7 +23,7 @@ import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.visible -class ConversationLoadStateAdapter( +class LoadStateFooterAdapter( private val retryCallback: () -> Unit ) : LoadStateAdapter>() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt index d64780a60..da67f33df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt @@ -1,4 +1,4 @@ -/* Copyright 2021 Tusky Contributors +/* Copyright 2025 Tusky Contributors * * This file is a part of Tusky. * @@ -12,35 +12,40 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ + package com.keylesspalace.tusky.adapter +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.util.visible -/** - * Placeholder for missing parts in timelines. - * - * Displays a "Load more" button to load the gap, or a - * circular progress bar if the missing page is being loaded. - */ class PlaceholderViewHolder( - private val binding: ItemStatusPlaceholderBinding, - listener: StatusActionListener + binding: ItemPlaceholderBinding, + mode: Mode, ) : RecyclerView.ViewHolder(binding.root) { - init { - binding.loadMoreButton.setOnClickListener { - binding.loadMoreButton.hide() - binding.loadMoreProgressBar.show() - listener.onLoadMore(bindingAdapterPosition) + val res = binding.root.context.resources + binding.topPlaceholder.visible(mode != Mode.STATUS) + binding.reblogButtonPlaceholder.visible(mode != Mode.CONVERSATION) + if (mode == Mode.NOTIFICATION) { + binding.topPlaceholder.updatePaddingRelative( + start = res.getDimensionPixelSize(R.dimen.status_info_padding_large) + ) + } + if (mode == Mode.CONVERSATION) { + binding.moreButtonPlaceHolder.updateLayoutParams { + marginEnd = res.getDimensionPixelSize(R.dimen.conversation_placeholder_more_button_inset) + } } } - fun setup(loading: Boolean) { - binding.loadMoreButton.visible(!loading) - binding.loadMoreProgressBar.visible(loading) + enum class Mode { + STATUS, + NOTIFICATION, + CONVERSATION } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index aac3c8159..249284bb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky.adapter +import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding import com.keylesspalace.tusky.entity.Emoji @@ -91,24 +93,34 @@ class PollAdapter : RecyclerView.Adapter>() { radioButton.isEnabled = enabled checkBox.isEnabled = enabled - when (mode) { - RESULT -> { - val percent = calculatePercent(option.votesCount, votersCount, voteCount) - resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context) - .emojify(emojis, resultTextView, animateEmojis) - - val level = percent * 100 - val optionColor = if (option.voted) { - R.color.colorBackgroundHighlight - } else { - R.color.colorBackgroundAccent - } + if (mode == RESULT) { + val percent = calculatePercent(option.votesCount, votersCount, voteCount) + resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context, resultTextView) + .emojify(emojis, resultTextView, animateEmojis) + + val level = percent * 100 + val optionColor = if (option.voted) { + R.color.colorBackgroundHighlight + } else { + R.color.colorBackgroundAccent + } - resultTextView.background.level = level - resultTextView.background.setTint(resultTextView.context.getColor(optionColor)) - resultTextView.setOnClickListener(resultClickListener) + holder.binding.pollLayout.setBackgroundResource(R.drawable.poll_option_background) + holder.binding.pollLayout.background.level = level + holder.binding.pollLayout.background.setTint(resultTextView.context.getColor(optionColor)) + holder.binding.root.strokeColor = holder.binding.root.context.getColor(optionColor) + resultTextView.setOnClickListener(resultClickListener) + } else { + holder.binding.pollLayout.background = null + + if (option.selected) { + holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface))) + holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface) + } else { + holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, android.R.attr.colorBackground))) + holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, R.attr.colorBackgroundAccent) } - SINGLE -> { + if (mode == SINGLE) { radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis) radioButton.isChecked = option.selected radioButton.setOnClickListener { @@ -117,12 +129,12 @@ class PollAdapter : RecyclerView.Adapter>() { notifyItemChanged(index) } } - } - MULTIPLE -> { + } else { // mode == MULTIPLE checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis) checkBox.isChecked = option.selected checkBox.setOnCheckedChangeListener { _, isChecked -> pollOptions[holder.bindingAdapterPosition].selected = isChecked + notifyItemChanged(holder.bindingAdapterPosition) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index e9eece7bc..bd3102923 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -11,7 +11,6 @@ import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateUtils; import android.view.Gravity; -import android.view.Menu; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -24,7 +23,6 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.TooltipCompat; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.text.HtmlCompat; @@ -45,9 +43,10 @@ import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; -import com.keylesspalace.tusky.entity.PreviewCard; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.HashTag; +import com.keylesspalace.tusky.entity.PreviewCard; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.entity.Translation; @@ -102,6 +101,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView sensitiveMediaWarning; private final View sensitiveMediaShow; protected final TextView[] mediaLabels; + protected final MaterialCardView[] mediaLabelContainers; protected final CharSequence[] mediaDescriptions; private final MaterialButton contentWarningButton; private final ImageView avatarInset; @@ -166,6 +166,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { itemView.findViewById(R.id.status_media_label_2), itemView.findViewById(R.id.status_media_label_3) }; + mediaLabelContainers = new MaterialCardView[]{ + itemView.findViewById(R.id.status_media_label_container_0), + itemView.findViewById(R.id.status_media_label_container_1), + itemView.findViewById(R.id.status_media_label_container_2), + itemView.findViewById(R.id.status_media_label_container_3) + }; mediaDescriptions = new CharSequence[mediaLabels.length]; contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); @@ -277,7 +283,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener); - setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + setupCard(status, expanded, !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } private void setTextVisible(boolean sensitive, @@ -523,7 +529,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { boolean sensitive, final @NonNull StatusActionListener listener, boolean showingContent, - boolean useBlurhash + boolean useBlurhash, + final @NonNull Filter filter ) { mediaPreview.setVisibility(View.VISIBLE); @@ -559,7 +566,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext()); setAttachmentClickListener(imageView, listener, i, formattedDescription, true); - if (sensitive) { + if (filter != null) { + sensitiveMediaWarning.setText(sensitiveMediaWarning.getContext().getString(R.string.status_filter_placeholder_label_format, filter.getTitle())); + } else if (sensitive) { sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); } else { sensitiveMediaWarning.setText(R.string.post_media_hidden_title); @@ -617,7 +626,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { TextView mediaLabel = mediaLabels[i]; if (i < attachments.size()) { Attachment attachment = attachments.get(i); - mediaLabel.setVisibility(View.VISIBLE); + mediaLabelContainers[i].setVisibility(View.VISIBLE); mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context); updateMediaLabel(i, sensitive, showingContent); @@ -627,7 +636,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false); } else { - mediaLabel.setVisibility(View.GONE); + mediaLabelContainers[i].setVisibility(View.GONE); } } } @@ -668,40 +677,24 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } }); - if (reblogButton != null) { reblogButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - if (statusDisplayOptions.confirmReblogs()) { - showConfirmReblog(listener, buttonState, position); - return false; - } else { - listener.onReblog(!buttonState, position, Status.Visibility.PUBLIC); - return true; - } - } else { - return false; + listener.onReblog(!buttonState, position, null, button); } + return false; }); } - favouriteButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { - if (statusDisplayOptions.confirmFavourites()) { - showConfirmFavourite(listener, buttonState, position); - return false; - } else { - listener.onFavourite(!buttonState, position); - return true; - } - } else { - return true; + listener.onFavourite(!buttonState, position, button); } + return false; }); bookmarkButton.setEventListener((button, buttonState) -> { @@ -732,62 +725,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { itemView.setOnClickListener(viewThreadListener); } - private void showConfirmReblog(StatusActionListener listener, - boolean buttonState, - int position) { - PopupMenu popup = new PopupMenu(itemView.getContext(), reblogButton); - popup.inflate(R.menu.status_reblog); - Menu menu = popup.getMenu(); - if (buttonState) { - menu.setGroupVisible(R.id.menu_action_reblog_group, false); - } else { - menu.findItem(R.id.menu_action_unreblog).setVisible(false); - } - popup.setOnMenuItemClickListener(item -> { - if (buttonState) { - listener.onReblog(false, position, Status.Visibility.PUBLIC); - } else { - Status.Visibility visibility; - if (item.getItemId() == R.id.menu_action_reblog_public) { - visibility = Status.Visibility.PUBLIC; - } else if (item.getItemId() == R.id.menu_action_reblog_unlisted) { - visibility = Status.Visibility.UNLISTED; - } else if (item.getItemId() == R.id.menu_action_reblog_private) { - visibility = Status.Visibility.PRIVATE; - } else { - visibility = Status.Visibility.PUBLIC; - } - listener.onReblog(true, position, visibility); - reblogButton.playAnimation(); - reblogButton.setChecked(true); - } - return true; - }); - popup.show(); - } - - private void showConfirmFavourite(StatusActionListener listener, - boolean buttonState, - int position) { - PopupMenu popup = new PopupMenu(itemView.getContext(), favouriteButton); - popup.inflate(R.menu.status_favourite); - Menu menu = popup.getMenu(); - if (buttonState) { - menu.findItem(R.id.menu_action_favourite).setVisible(false); - } else { - menu.findItem(R.id.menu_action_unfavourite).setVisible(false); - } - popup.setOnMenuItemClickListener(item -> { - listener.onFavourite(!buttonState, position); - if (!buttonState) { - favouriteButton.playAnimation(); - favouriteButton.setChecked(true); - } - return true; - }); - popup.show(); - } - public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @@ -812,14 +749,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { mediaContainer.setVisibility(View.VISIBLE); - setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); + setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash(), status.getFilter()); if (attachments.isEmpty()) { hideSensitiveMediaWarning(); } // Hide the unused label. - for (TextView mediaLabel : mediaLabels) { - mediaLabel.setVisibility(View.GONE); + for (MaterialCardView mediaLabelContainer : mediaLabelContainers) { + mediaLabelContainer.setVisibility(View.GONE); } } else { mediaContainer.setVisibility(View.VISIBLE); @@ -830,7 +767,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { hideSensitiveMediaWarning(); } - setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); @@ -855,7 +792,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setMetaData(status, statusDisplayOptions, listener); if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) { // there is a preview card showing the published time, we need to refresh it as well - setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } break; } @@ -1030,7 +967,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { for (int i = 0; i < args.length; i++) { if (i < options.size()) { int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); - args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context); + args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context, null); } else { args[i] = ""; } @@ -1157,6 +1094,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setupCard( final @NonNull StatusViewData.Concrete status, boolean expanded, + boolean blurMedia, final @NonNull CardViewMode cardViewMode, final @NonNull StatusDisplayOptions statusDisplayOptions, final @NonNull StatusActionListener listener @@ -1236,7 +1174,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well - if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { + if (statusDisplayOptions.mediaPreviewEnabled() && !blurMedia && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius); ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 1a0c42797..eeba661fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -149,7 +149,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo); - setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status + setupCard(uncollapsedStatus, status.isExpanded(), !status.isShowingContent(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads.isEmpty()) { Status actionable = uncollapsedStatus.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 2a63531ef..226a96152 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -69,7 +69,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { setupCollapsedState(sensitive, expanded, status, listener); - if (!showStatusInfo || status.getFilterAction() == Filter.Action.WARN) { + if (!showStatusInfo || (status.getFilter() != null && status.getFilter().getAction() == Filter.Action.WARN)) { hideStatusInfo(); } else { Status rebloggingStatus = status.getRebloggingStatus(); @@ -146,7 +146,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.GONE); } - protected TextView getStatusInfo() { + protected @NonNull TextView getStatusInfo() { return statusInfo; } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 1b4070dd2..e7363fa0f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,6 +1,7 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status @@ -18,7 +19,7 @@ data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class PollShowResultsEvent(val statusId: String) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : Event -data class FilterUpdatedEvent(val filterContext: List) : Event +data class FilterUpdatedEvent(val filterContext: List) : Event data class NewNotificationsEvent( val accountId: String, val notifications: List diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 083c93fc9..59e84345d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -35,8 +35,10 @@ import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show @@ -78,6 +80,9 @@ class AccountMediaFragment : val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + val hasFab = (activity as? ActionButtonActivity?)?.actionButton != null + binding.recyclerView.ensureBottomPadding(fab = hasFab) + val adapter = AccountMediaGridAdapter( useBlurhash = useBlurhash, context = view.context, @@ -177,7 +182,7 @@ class AccountMediaFragment : Attachment.Type.VIDEO, Attachment.Type.AUDIO -> { val intent = ViewMediaActivity.newIntent( - context, + view.context, attachmentsFromSameStatus, currentIndex ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index b8d972c43..0c556f946 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -61,8 +61,11 @@ class AccountListActivity : BottomSheetActivity() { setDisplayShowHomeEnabled(true) } - supportFragmentManager.commit { - replace(R.id.fragment_container, AccountListFragment.newInstance(type, id)) + if (supportFragmentManager.findFragmentById(R.id.fragment_container) == null) { + supportFragmentManager.commit { + val fragment = AccountListFragment.newInstance(type, id) + replace(R.id.fragment_container, fragment) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index e43b87d45..1bf2c0557 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -20,21 +20,22 @@ import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator -import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type -import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter @@ -42,25 +43,21 @@ import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHead import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Relationship -import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.getSerializableCompat import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.EndlessOnScrollListener +import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import javax.inject.Inject -import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import retrofit2.Response @AndroidEntryPoint class AccountListFragment : @@ -68,9 +65,6 @@ class AccountListFragment : AccountActionListener, LinkListener { - @Inject - lateinit var api: MastodonApi - @Inject lateinit var accountManager: AccountManager @@ -79,13 +73,20 @@ class AccountListFragment : private val binding by viewBinding(FragmentAccountListBinding::bind) + private val viewModel: AccountListViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create( + type = requireArguments().getSerializableCompat(ARG_TYPE)!!, + accountId = requireArguments().getString(ARG_ID) + ) + } + } + ) + private lateinit var type: Type private var id: String? = null - private var adapter: AccountAdapter<*>? = null - private var fetching = false - private var bottomId: String? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) type = requireArguments().getSerializableCompat(ARG_TYPE)!! @@ -123,31 +124,59 @@ class AccountListFragment : } else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay) } - this.adapter = adapter - if (binding.recyclerView.adapter == null) { - binding.recyclerView.adapter = adapter + + binding.recyclerView.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry)) + + binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() } + + lifecycleScope.launch { + viewModel.accountPager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } } - val scrollListener = object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - if (bottomId == null) { - return + lifecycleScope.launch { + viewModel.uiEvents.collect { event -> + val message = if (event.throwable != null) { + getString(event.message, event.user, event.throwable.message ?: getString(R.string.error_generic)) + } else { + getString(event.message, event.user) } - fetchAccounts(adapter, bottomId) + Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG) + .setAction(event.actionText, event.action) + .addCallback(object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar, eventType: Int) { + viewModel.consumeEvent(event) + } + }) + .show() } } - binding.recyclerView.addOnScrollListener(scrollListener) - - binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts(adapter) } + adapter.addLoadStateListener { loadState -> + binding.progressBar.visible( + loadState.refresh == LoadState.Loading && adapter.itemCount == 0 + ) - fetchAccounts(adapter) - } + if (loadState.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } - override fun onDestroyView() { - // Clear the adapter to prevent leaking the View - adapter = null - super.onDestroyView() + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.messageView.show() + val errorState = loadState.refresh as LoadState.Error + binding.messageView.setup(errorState.error) { adapter.retry() } + Log.w(TAG, "error loading accounts", errorState.error) + } else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) { + binding.recyclerView.hide() + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } else { + binding.recyclerView.show() + binding.messageView.hide() + } + } } override fun onViewTag(tag: String) { @@ -165,275 +194,29 @@ class AccountListFragment : } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - viewLifecycleOwner.lifecycleScope.launch { - try { - if (!mute) { - api.unmuteAccount(id) - } else { - api.muteAccount(id, notifications) - } - onMuteSuccess(mute, id, position, notifications) - } catch (_: Throwable) { - onMuteFailure(mute, id, notifications) - } - } - } - - private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) { - val mutesAdapter = adapter as MutesAdapter - if (muted) { - mutesAdapter.updateMutingNotifications(id, notifications, position) - return - } - val unmutedUser = mutesAdapter.removeItem(position) - - if (unmutedUser != null) { - Snackbar.make(binding.recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mutesAdapter.addItem(unmutedUser, position) - onMute(true, id, position, notifications) - } - .show() - } - } - - private fun onMuteFailure(mute: Boolean, accountId: String, notifications: Boolean) { - val verb = if (mute) { - if (notifications) { - "mute (notifications = true)" - } else { - "mute (notifications = false)" - } + if (mute) { + viewModel.mute(id, notifications) } else { - "unmute" + viewModel.unmute(id) } - Log.e(TAG, "Failed to $verb account id $accountId") } override fun onBlock(block: Boolean, id: String, position: Int) { - viewLifecycleOwner.lifecycleScope.launch { - try { - if (!block) { - api.unblockAccount(id) - } else { - api.blockAccount(id) - } - onBlockSuccess(block, id, position) - } catch (_: Throwable) { - onBlockFailure(block, id) - } - } - } - - private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) { - if (blocked) { - return - } - val blocksAdapter = adapter as BlocksAdapter - val unblockedUser = blocksAdapter.removeItem(position) - - if (unblockedUser != null) { - Snackbar.make( - binding.recyclerView, - R.string.confirmation_unblocked, - Snackbar.LENGTH_LONG - ) - .setAction(R.string.action_undo) { - blocksAdapter.addItem(unblockedUser, position) - onBlock(true, id, position) - } - .show() - } + viewModel.unblock(id) } - private fun onBlockFailure(block: Boolean, accountId: String) { - val verb = if (block) { - "block" - } else { - "unblock" - } - Log.e(TAG, "Failed to $verb account accountId $accountId") - } - - override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { - viewLifecycleOwner.lifecycleScope.launch { - if (accept) { - api.authorizeFollowRequest(id) - } else { - api.rejectFollowRequest(id) - }.fold( - onSuccess = { - onRespondToFollowRequestSuccess(position) - }, - onFailure = { throwable -> - val verb = if (accept) { - "accept" - } else { - "reject" - } - Log.e(TAG, "Failed to $verb account id $id.", throwable) - } - ) - } - } - - private fun onRespondToFollowRequestSuccess(position: Int) { - val followRequestsAdapter = adapter as FollowRequestsAdapter - followRequestsAdapter.removeItem(position) - } - - private suspend fun getFetchCallByListType(fromId: String?): Response> { - return when (type) { - Type.FOLLOWS -> { - val accountId = requireId(type, id) - api.accountFollowing(accountId, fromId) - } - Type.FOLLOWERS -> { - val accountId = requireId(type, id) - api.accountFollowers(accountId, fromId) - } - Type.BLOCKS -> api.blocks(fromId) - Type.MUTES -> api.mutes(fromId) - Type.FOLLOW_REQUESTS -> api.followRequests(fromId) - Type.REBLOGGED -> { - val statusId = requireId(type, id) - api.statusRebloggedBy(statusId, fromId) - } - Type.FAVOURITED -> { - val statusId = requireId(type, id) - api.statusFavouritedBy(statusId, fromId) - } - } - } - - private fun requireId(type: Type, id: String?): String { - return requireNotNull(id) { "id must not be null for type " + type.name } - } - - private fun fetchAccounts(adapter: AccountAdapter<*>, fromId: String? = null) { - if (fetching) { - return - } - fetching = true - binding.swipeRefreshLayout.isRefreshing = true - - if (fromId != null) { - binding.recyclerView.post { adapter.setBottomLoading(true) } - } - - viewLifecycleOwner.lifecycleScope.launch { - try { - val response = getFetchCallByListType(fromId) - - if (!response.isSuccessful) { - onFetchAccountsFailure(adapter, Exception(response.message())) - return@launch - } - - val accountList = response.body() - - if (accountList == null) { - onFetchAccountsFailure(adapter, Exception(response.message())) - return@launch - } - - val linkHeader = response.headers()["Link"] - onFetchAccountsSuccess(adapter, accountList, linkHeader) - } catch (exception: Exception) { - if (exception is CancellationException) { - // Scope is cancelled, probably because the fragment is destroyed. - // We must not touch any views anymore, so rethrow the exception. - // (CancellationException in a cancelled scope is normal and will be ignored) - throw exception - } - onFetchAccountsFailure(adapter, exception) - } - } - } - - private fun onFetchAccountsSuccess( - adapter: AccountAdapter<*>, - accounts: List, - linkHeader: String? - ) { - adapter.setBottomLoading(false) - binding.swipeRefreshLayout.isRefreshing = false - - val links = HttpHeaderLink.parse(linkHeader) - val next = HttpHeaderLink.findByRelationType(links, "next") - val fromId = next?.uri?.getQueryParameter("max_id") - - if (adapter.itemCount > 0) { - adapter.addItems(accounts) - } else { - adapter.update(accounts) - } - - if (adapter is MutesAdapter) { - fetchRelationships(adapter, accounts.map { it.id }) - } - - bottomId = fromId - - fetching = false - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null - ) - } else { - binding.messageView.hide() - } - } - - private fun fetchRelationships(mutesAdapter: MutesAdapter, ids: List) { - viewLifecycleOwner.lifecycleScope.launch { - api.relationships(ids) - .fold( - onSuccess = { relationships -> - onFetchRelationshipsSuccess(mutesAdapter, relationships) - }, - onFailure = { throwable -> - Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) - } - ) - } - } - - private fun onFetchRelationshipsSuccess( - mutesAdapter: MutesAdapter, - relationships: List - ) { - val mutingNotificationsMap = HashMap() - relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) } - mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) - } - - private fun onFetchAccountsFailure(adapter: AccountAdapter<*>, throwable: Throwable) { - fetching = false - binding.swipeRefreshLayout.isRefreshing = false - Log.e(TAG, "Fetch failure", throwable) - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup(throwable) { - binding.messageView.hide() - this.fetchAccounts(adapter, null) - } - } + override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { + viewModel.respondToFollowRequest(accept, accountIdRequestingFollow) } companion object { - private const val TAG = "AccountList" // logging tag + private const val TAG = "AccountListFragment" private const val ARG_TYPE = "type" private const val ARG_ID = "id" fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { - arguments = Bundle(3).apply { + arguments = Bundle(2).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt new file mode 100644 index 000000000..be5ee6909 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt @@ -0,0 +1,34 @@ +/* Copyright 2025 Tusky Contributors. + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.accountlist + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class AccountListPagingSource( + private val accounts: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(accounts, null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt new file mode 100644 index 000000000..62686219a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt @@ -0,0 +1,119 @@ +/* Copyright 2025 Tusky Contributors. + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.accountlist + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class AccountListRemoteMediator( + private val api: MastodonApi, + private val viewModel: AccountListViewModel, + private val fetchRelationships: Boolean +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> getFetchCallByListType(fromId = viewModel.nextKey) + LoadType.REFRESH -> { + viewModel.nextKey = null + viewModel.accounts.clear() + getFetchCallByListType(null) + } + } + } + + private suspend fun applyResponse(response: Response>): MediatorResult { + val accounts = response.body() + if (!response.isSuccessful || accounts == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + + val relationships = if (fetchRelationships) { + api.relationships(accounts.map { it.id }).getOrElse { e -> + return MediatorResult.Error(e) + } + } else { + emptyList() + } + + val viewModels = accounts.map { account -> + account.toViewData( + mutingNotifications = relationships.find { it.id == account.id }?.mutingNotifications == true + ) + } + + viewModel.accounts.addAll(viewModels) + viewModel.invalidate() + + return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) + } + + private fun requireId(type: Type, id: String?): String { + return requireNotNull(id) { "id must not be null for type " + type.name } + } + + private suspend fun getFetchCallByListType(fromId: String?): Response> { + return when (viewModel.type) { + Type.FOLLOWS -> { + val accountId = requireId(viewModel.type, viewModel.accountId) + api.accountFollowing(accountId, fromId) + } + Type.FOLLOWERS -> { + val accountId = requireId(viewModel.type, viewModel.accountId) + api.accountFollowers(accountId, fromId) + } + Type.BLOCKS -> api.blocks(fromId) + Type.MUTES -> api.mutes(fromId) + Type.FOLLOW_REQUESTS -> api.followRequests(fromId) + Type.REBLOGGED -> { + val statusId = requireId(viewModel.type, viewModel.accountId) + api.statusRebloggedBy(statusId, fromId) + } + Type.FAVOURITED -> { + val statusId = requireId(viewModel.type, viewModel.accountId) + api.statusFavouritedBy(statusId, fromId) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt new file mode 100644 index 000000000..b07bf7420 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt @@ -0,0 +1,240 @@ +/* Copyright 2025 Tusky Contributors. + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.accountlist + +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.network.MastodonApi +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel(assistedFactory = AccountListViewModel.Factory::class) +class AccountListViewModel @AssistedInject constructor( + private val api: MastodonApi, + @Assisted("type") val type: AccountListActivity.Type, + @Assisted("id") val accountId: String? +) : ViewModel() { + + private val factory = InvalidatingPagingSourceFactory { + AccountListPagingSource(accounts.toList(), nextKey) + } + + @OptIn(ExperimentalPagingApi::class) + val accountPager = Pager( + config = PagingConfig(40), + remoteMediator = AccountListRemoteMediator(api, this, fetchRelationships = type == AccountListActivity.Type.MUTES), + pagingSourceFactory = factory + ).flow + .cachedIn(viewModelScope) + + val accounts: MutableList = mutableListOf() + var nextKey: String? = null + + private val _uiEvents = MutableStateFlow>(emptyList()) + val uiEvents: Flow = _uiEvents.map { it.firstOrNull() }.filterNotNull().distinctUntilChanged() + + fun invalidate() { + factory.invalidate() + } + + // this is called by the mute notification toggle + fun mute(accountId: String, notifications: Boolean) { + val accountViewData = accounts.find { it.id == accountId } ?: return + viewModelScope.launch { + api.muteAccount(accountId, notifications).onFailure { e -> + sendEvent( + SnackbarEvent( + message = R.string.mute_failure, + user = "@${accountViewData.account.username}", + throwable = e, + actionText = R.string.action_retry, + action = { mute(accountId, notifications) } + ) + ) + } + } + } + + // this is called when unmuting is undone + private fun remute(accountViewData: AccountViewData) { + viewModelScope.launch { + api.muteAccount(accountViewData.id).fold({ + accounts.add(accountViewData) + invalidate() + }, { e -> + sendEvent( + SnackbarEvent( + message = R.string.mute_failure, + user = "@${accountViewData.account.username}", + throwable = e, + actionText = R.string.action_retry, + action = { block(accountViewData) } + ) + ) + }) + } + } + + fun unmute(accountId: String) { + val accountViewData = accounts.find { it.id == accountId } ?: return + viewModelScope.launch { + api.unmuteAccount(accountId).fold({ + accounts.removeIf { it.id == accountId } + invalidate() + sendEvent( + SnackbarEvent( + message = R.string.unmute_success, + user = "@${accountViewData.account.username}", + throwable = null, + actionText = R.string.action_undo, + action = { remute(accountViewData) } + ) + ) + }, { error -> + sendEvent( + SnackbarEvent( + message = R.string.unmute_failure, + user = "@${accountViewData.account.username}", + throwable = error, + actionText = R.string.action_retry, + action = { unmute(accountId) } + ) + ) + }) + } + } + + fun unblock(accountId: String) { + val accountViewData = accounts.find { it.id == accountId } ?: return + viewModelScope.launch { + api.unblockAccount(accountId).fold({ + accounts.removeIf { it.id == accountId } + invalidate() + sendEvent( + SnackbarEvent( + message = R.string.unblock_success, + user = "@${accountViewData.account.username}", + throwable = null, + actionText = R.string.action_undo, + action = { block(accountViewData) } + ) + ) + }, { e -> + sendEvent( + SnackbarEvent( + message = R.string.unblock_failure, + user = "@${accountViewData.account.username}", + throwable = e, + actionText = R.string.action_retry, + action = { unblock(accountId) } + ) + ) + }) + } + } + + private fun block(accountViewData: AccountViewData) { + viewModelScope.launch { + api.blockAccount(accountViewData.id).fold({ + accounts.add(accountViewData) + invalidate() + }, { e -> + sendEvent( + SnackbarEvent( + message = R.string.block_failure, + user = "@${accountViewData.account.username}", + throwable = e, + actionText = R.string.action_retry, + action = { block(accountViewData) } + ) + ) + }) + } + } + + fun respondToFollowRequest(accept: Boolean, accountId: String) { + val accountViewData = accounts.find { it.id == accountId } ?: return + viewModelScope.launch { + if (accept) { + api.authorizeFollowRequest(accountId) + } else { + api.rejectFollowRequest(accountId) + }.fold({ + accounts.removeIf { it.id == accountId } + invalidate() + }, { e -> + sendEvent( + SnackbarEvent( + message = if (accept) R.string.accept_follow_request_failure else R.string.reject_follow_request_failure, + user = "@${accountViewData.account.username}", + throwable = e, + actionText = R.string.action_retry, + action = { respondToFollowRequest(accept, accountId) } + ) + ) + }) + } + } + + fun consumeEvent(event: SnackbarEvent) { + println("event consumed $event") + _uiEvents.update { uiEvents -> + uiEvents - event + } + } + + private fun sendEvent(event: SnackbarEvent) { + println("event sent $event") + _uiEvents.update { uiEvents -> + uiEvents + event + } + } + + @AssistedFactory + interface Factory { + fun create( + @Assisted("type") type: AccountListActivity.Type, + @Assisted("id") accountId: String? + ): AccountListViewModel + } +} + +class SnackbarEvent( + @StringRes val message: Int, + val user: String, + @StringRes val actionText: Int, + val action: (View) -> Unit, + val throwable: Throwable? = null +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt new file mode 100644 index 000000000..fc9ffd265 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt @@ -0,0 +1,33 @@ +/* Copyright 2025 Tusky Contributors. + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.accountlist + +import com.keylesspalace.tusky.entity.TimelineAccount + +data class AccountViewData( + val account: TimelineAccount, + val mutingNotifications: Boolean +) { + val id: String + get() = account.id +} + +fun TimelineAccount.toViewData( + mutingNotifications: Boolean +) = AccountViewData( + account = this, + mutingNotifications = mutingNotifications +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index ac327ac03..7c00aa3ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -14,111 +14,34 @@ * see . */ package com.keylesspalace.tusky.components.accountlist.adapter -import android.view.LayoutInflater -import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.databinding.ItemFooterBinding -import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.components.accountlist.AccountViewData import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.removeDuplicatesTo -/** Generic adapter with bottom loading indicator. */ -abstract class AccountAdapter internal constructor( +abstract class AccountAdapter( protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean -) : RecyclerView.Adapter() { - - protected var accountList: MutableList = mutableListOf() - private var bottomLoading: Boolean = false - - override fun getItemCount(): Int { - return accountList.size + if (bottomLoading) 1 else 0 - } - - abstract fun createAccountViewHolder(parent: ViewGroup): AVH - - abstract fun onBindAccountViewHolder(viewHolder: AVH, position: Int) - - final override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { - @Suppress("UNCHECKED_CAST") - this.onBindAccountViewHolder(holder as AVH, position) - } - } - - final override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_ACCOUNT -> this.createAccountViewHolder(parent) - VIEW_TYPE_FOOTER -> this.createFooterViewHolder(parent) - else -> error("Unknown item type: $viewType") - } - } - - private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { - val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return BindingHolder(binding) - } - - override fun getItemViewType(position: Int): Int { - return if (position == accountList.size && bottomLoading) { - VIEW_TYPE_FOOTER - } else { - VIEW_TYPE_ACCOUNT - } - } - - fun update(newAccounts: List) { - accountList = newAccounts.removeDuplicatesTo(ArrayList()) - notifyDataSetChanged() - } - - fun addItems(newAccounts: List) { - val end = accountList.size - val last = accountList[end - 1] - if (newAccounts.none { it.id == last.id }) { - accountList.addAll(newAccounts) - notifyItemRangeInserted(end, newAccounts.size) - } - } - - fun setBottomLoading(loading: Boolean) { - val wasLoading = bottomLoading - if (wasLoading == loading) { - return - } - bottomLoading = loading - if (loading) { - notifyItemInserted(accountList.size) - } else { - notifyItemRemoved(accountList.size) - } - } - - fun removeItem(position: Int): TimelineAccount? { - if (position < 0 || position >= accountList.size) { - return null - } - val account = accountList.removeAt(position) - notifyItemRemoved(position) - return account - } - - fun addItem(account: TimelineAccount, position: Int) { - if (position < 0 || position > accountList.size) { - return - } - accountList.add(position, account) - notifyItemInserted(position) - } +) : PagingDataAdapter(AccountViewDataDifferCallback) { companion object { - const val VIEW_TYPE_ACCOUNT = 0 - const val VIEW_TYPE_FOOTER = 1 + private val AccountViewDataDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AccountViewData, + newItem: AccountViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: AccountViewData, + newItem: AccountViewData + ): Boolean { + return oldItem == newItem + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt index c1132e7f7..2df515d74 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -38,42 +38,38 @@ class BlocksAdapter( showBotOverlay = showBotOverlay ) { - override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { - val binding = ItemBlockedUserBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder( + ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - return BindingHolder(binding) } - override fun onBindAccountViewHolder( - viewHolder: BindingHolder, - position: Int - ) { - val account = accountList[position] - val binding = viewHolder.binding - val context = binding.root.context + override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + getItem(position)?.let { viewData -> + val account = viewData.account + val binding = viewHolder.binding + val context = binding.root.context - val emojifiedName = account.name.emojify( - account.emojis, - binding.blockedUserDisplayName, - animateEmojis - ) - binding.blockedUserDisplayName.text = emojifiedName - val formattedUsername = context.getString(R.string.post_username_format, account.username) - binding.blockedUserUsername.text = formattedUsername + val emojifiedName = account.name.emojify( + account.emojis, + binding.blockedUserDisplayName, + animateEmojis + ) + binding.blockedUserDisplayName.text = emojifiedName + val formattedUsername = context.getString(R.string.post_username_format, account.username) + binding.blockedUserUsername.text = formattedUsername - val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) - loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar) + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar) - binding.blockedUserBotBadge.visible(showBotOverlay && account.bot) + binding.blockedUserBotBadge.visible(showBotOverlay && account.bot) - binding.blockedUserUnblock.setOnClickListener { - accountActionListener.onBlock(false, account.id, position) - } - binding.root.setOnClickListener { - accountActionListener.onViewAccount(account.id) + binding.blockedUserUnblock.setOnClickListener { + accountActionListener.onBlock(false, account.id, position) + } + binding.root.setOnClickListener { + accountActionListener.onViewAccount(account.id) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt index 87b62486d..b91f09f12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt @@ -34,18 +34,20 @@ class FollowAdapter( showBotOverlay = showBotOverlay ) { - override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false) return AccountViewHolder(binding) } - override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) { - viewHolder.setupWithAccount( - accountList[position], - animateAvatar, - animateEmojis, - showBotOverlay - ) - viewHolder.setupActionListener(accountActionListener) + override fun onBindViewHolder(viewHolder: AccountViewHolder, position: Int) { + getItem(position)?.let { viewData -> + viewHolder.setupWithAccount( + viewData.account, + animateAvatar, + animateEmojis, + showBotOverlay + ) + viewHolder.setupActionListener(accountActionListener) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index fc860e59e..7487fb6f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -35,28 +35,20 @@ class FollowRequestsAdapter( animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) { - - override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { - val binding = ItemFollowRequestBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return FollowRequestViewHolder( - binding, - accountActionListener, - linkListener, - showHeader = false - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FollowRequestViewHolder { + val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return FollowRequestViewHolder(binding, accountActionListener, linkListener, showHeader = false) } - override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { - viewHolder.setupWithAccount( - account = accountList[position], - animateAvatar = animateAvatar, - animateEmojis = animateEmojis, - showBotOverlay = showBotOverlay - ) - viewHolder.setupActionListener(accountActionListener, accountList[position].id) + override fun onBindViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { + getItem(position)?.let { viewData -> + viewHolder.setupWithAccount( + account = viewData.account, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay + ) + viewHolder.setupActionListener(accountActionListener, viewData.account.id) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index d685730de..7e87e7d3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -39,82 +39,58 @@ class MutesAdapter( showBotOverlay = showBotOverlay ) { - private val mutingNotificationsMap = HashMap() - - override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { - val binding = ItemMutedUserBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + return BindingHolder( + ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - return BindingHolder(binding) } - override fun onBindAccountViewHolder( - viewHolder: BindingHolder, - position: Int - ) { - val account = accountList[position] - val binding = viewHolder.binding - val context = binding.root.context - - val mutingNotifications = mutingNotificationsMap[account.id] - - val emojifiedName = account.name.emojify( - account.emojis, - binding.mutedUserDisplayName, - animateEmojis - ) - binding.mutedUserDisplayName.text = emojifiedName - - val formattedUsername = context.getString(R.string.post_username_format, account.username) - binding.mutedUserUsername.text = formattedUsername - - val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) - loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) - - binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) - - val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) - binding.mutedUserUnmute.contentDescription = unmuteString - ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) - - binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null) + override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + getItem(position)?.let { viewData -> + val account = viewData.account + val binding = viewHolder.binding + val context = binding.root.context - binding.mutedUserMuteNotifications.isChecked = if (mutingNotifications == null) { - binding.mutedUserMuteNotifications.isEnabled = false - true - } else { - binding.mutedUserMuteNotifications.isEnabled = true - mutingNotifications - } - - binding.mutedUserUnmute.setOnClickListener { - accountActionListener.onMute( - false, - account.id, - viewHolder.bindingAdapterPosition, - false - ) - } - binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked -> - accountActionListener.onMute( - true, - account.id, - viewHolder.bindingAdapterPosition, - isChecked + val emojifiedName = account.name.emojify( + account.emojis, + binding.mutedUserDisplayName, + animateEmojis ) + binding.mutedUserDisplayName.text = emojifiedName + + val formattedUsername = context.getString(R.string.post_username_format, account.username) + binding.mutedUserUsername.text = formattedUsername + + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) + + binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) + + val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) + binding.mutedUserUnmute.contentDescription = unmuteString + ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) + + binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null) + + binding.mutedUserMuteNotifications.isChecked = viewData.mutingNotifications + + binding.mutedUserUnmute.setOnClickListener { + accountActionListener.onMute( + false, + account.id, + viewHolder.bindingAdapterPosition, + false + ) + } + binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked -> + accountActionListener.onMute( + true, + account.id, + viewHolder.bindingAdapterPosition, + isChecked + ) + } + binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) } } - binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) } - } - - fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) { - mutingNotificationsMap[id] = mutingNotifications - notifyItemChanged(position) - } - - fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap) { - mutingNotificationsMap.putAll(newMutingNotificationsMap) - notifyDataSetChanged() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt similarity index 64% rename from app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt index 184ff1745..97ad9d5fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt @@ -19,15 +19,18 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions -class ConversationAdapter( +class ConversationPagingAdapter( private var statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener -) : PagingDataAdapter(CONVERSATION_COMPARATOR) { +) : PagingDataAdapter(CONVERSATION_COMPARATOR) { var mediaPreviewEnabled: Boolean get() = statusDisplayOptions.mediaPreviewEnabled @@ -37,25 +40,42 @@ class ConversationAdapter( ) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { - val view = LayoutInflater.from( - parent.context - ).inflate(R.layout.item_conversation, parent, false) - return ConversationViewHolder(view, statusDisplayOptions, listener) + override fun getItemViewType(position: Int): Int { + return if (getItem(position) == null) { + VIEW_TYPE_PLACEHOLDER + } else { + VIEW_TYPE_CONVERSATION + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return if (viewType == VIEW_TYPE_CONVERSATION) { + ConversationViewHolder(layoutInflater.inflate(R.layout.item_conversation, parent, false), statusDisplayOptions, listener) + } else { + PlaceholderViewHolder( + ItemPlaceholderBinding.inflate(layoutInflater, parent, false), + mode = PlaceholderViewHolder.Mode.CONVERSATION + ) + } } - override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { onBindViewHolder(holder, position, emptyList()) } - override fun onBindViewHolder(holder: ConversationViewHolder, position: Int, payloads: List) { - getItem(position)?.let { conversationViewData -> + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { + val conversationViewData = getItem(position) + if (holder is ConversationViewHolder && conversationViewData != null) { holder.setupWithConversation(conversationViewData, payloads) } } companion object { - val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { + private const val VIEW_TYPE_PLACEHOLDER = 0 + private const val VIEW_TYPE_CONVERSATION = 1 + + private val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: ConversationViewData, newItem: ConversationViewData diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index d94f60113..e394ff263 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -96,7 +96,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { mediaContainer.setVisibility(View.VISIBLE); setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), - statusDisplayOptions.useBlurhash()); + statusDisplayOptions.useBlurhash(), statusViewData.getFilter()); if (attachments.isEmpty()) { hideSensitiveMediaWarning(); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 4303c700f..2db01c254 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -31,10 +31,12 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator +import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent @@ -53,6 +55,7 @@ import com.keylesspalace.tusky.util.isAnyLoading import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.viewdata.AttachmentViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -76,7 +79,9 @@ class ConversationsFragment : private val binding by viewBinding(FragmentTimelineBinding::bind) - private var adapter: ConversationAdapter? = null + private var adapter: ConversationPagingAdapter? = null + + private var buttonToAnimate: SparkButton? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) @@ -88,8 +93,6 @@ class ConversationsFragment : showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -97,7 +100,7 @@ class ConversationsFragment : openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - val adapter = ConversationAdapter(statusDisplayOptions, this) + val adapter = ConversationPagingAdapter(statusDisplayOptions, this) this.adapter = adapter setupRecyclerView(adapter) @@ -193,6 +196,7 @@ class ConversationsFragment : override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null + buttonToAnimate = null super.onDestroyView() } @@ -212,7 +216,7 @@ class ConversationsFragment : } } - private fun setupRecyclerView(adapter: ConversationAdapter) { + private fun setupRecyclerView(adapter: ConversationPagingAdapter) { binding.recyclerView.ensureBottomPadding(fab = true) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -224,26 +228,37 @@ class ConversationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.adapter = - adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) + adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry)) } private fun refreshContent() { adapter?.refresh() } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { // its impossible to reblog private messages } - override fun onFavourite(favourite: Boolean, position: Int) { + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { adapter?.peek(position)?.let { conversation -> - viewModel.favourite(favourite, conversation) + buttonToAnimate = button + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favourite(true, conversation) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.favourite(false, conversation) + buttonToAnimate?.isChecked = false + } } } - override fun onBookmark(favourite: Boolean, position: Int) { + override fun onBookmark(bookmark: Boolean, position: Int) { adapter?.peek(position)?.let { conversation -> - viewModel.bookmark(favourite, conversation) + viewModel.bookmark(bookmark, conversation) } } @@ -371,7 +386,7 @@ class ConversationsFragment : .show() } - private fun onPreferenceChanged(adapter: ConversationAdapter, key: String) { + private fun onPreferenceChanged(adapter: ConversationPagingAdapter, key: String) { when (key) { PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 2077f39a1..efd751cd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -112,9 +112,9 @@ class ConversationsViewModel @Inject constructor( fun showPollResults(conversation: ConversationViewData) = viewModelScope.launch { conversation.lastStatus.status.poll?.let { poll -> - conversation.toEntity(accountId = accountId, poll = poll.copy(voted = true)).let { - saveConversationToDb(it) - } + saveConversationToDb( + conversation.toEntity(accountId = accountId, poll = poll.copy(voted = true)) + ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt index 414fb1b88..5770cd2c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -35,6 +35,7 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.entity.Filter @@ -56,6 +57,9 @@ class EditFilterActivity : BaseActivity() { @Inject lateinit var eventHub: EventHub + @Inject + lateinit var instanceInfoRepository: InstanceInfoRepository + private val binding by viewBinding(ActivityEditFilterBinding::inflate) private val viewModel: EditFilterViewModel by viewModels() @@ -67,7 +71,7 @@ class EditFilterActivity : BaseActivity() { super.onCreate(savedInstanceState) originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT) - filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf()) + filter = originalFilter ?: Filter(context = emptyList(), action = Filter.Action.WARN) binding.apply { contextSwitches = mapOf( filterContextHome to Filter.Kind.HOME, @@ -124,14 +128,17 @@ class EditFilterActivity : BaseActivity() { viewModel.setTitle(editable.toString()) validateSaveButton() } - binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> - viewModel.setAction( - if (checked) { - Filter.Action.WARN - } else { - Filter.Action.HIDE - } - ) + + // blur filter is supported in mastodon api version 5+ + val blurFilterSupported = instanceInfoRepository.cachedInstanceInfoOrFallback.mastodonApiVersion?.let { it >= 5 } == true + binding.filterActionBlur.visible(blurFilterSupported) + binding.filterActionGroup.setOnCheckedChangeListener { _, checkedId -> + val action = when (checkedId) { + R.id.filter_action_blur -> Filter.Action.BLUR + R.id.filter_action_hide -> Filter.Action.HIDE + else -> Filter.Action.WARN + } + viewModel.setAction(action) } binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> viewModel.setDuration( @@ -178,6 +185,7 @@ class EditFilterActivity : BaseActivity() { lifecycleScope.launch { viewModel.action.collect { action -> when (action) { + Filter.Action.BLUR -> binding.filterActionBlur.isChecked = true Filter.Action.HIDE -> binding.filterActionHide.isChecked = true else -> binding.filterActionWarn.isChecked = true } @@ -299,14 +307,14 @@ class EditFilterActivity : BaseActivity() { if (viewModel.saveChanges(this@EditFilterActivity)) { finish() // Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter - val affectedContexts = viewModel.contexts.value.map { - it.kind - }.union(originalFilter?.context ?: listOf()).distinct() + val affectedContexts = viewModel.contexts.value + .union(originalFilter?.context.orEmpty()) + .distinct() eventHub.dispatch(FilterUpdatedEvent(affectedContexts)) } else { Snackbar.make( binding.root, - getString(R.string.error_deleting_filter, viewModel.title.value), + getString(R.string.error_saving_filter, viewModel.title.value), Snackbar.LENGTH_SHORT ).show() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt index 881fd70e9..c34306d5d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -60,7 +60,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( } else { -1 } - _contexts.value = filter.kinds + _contexts.value = filter.context } fun addKeyword(keyword: FilterKeyword) { @@ -109,10 +109,10 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( } suspend fun saveChanges(context: Context): Boolean { - val contexts = _contexts.value.map { it.kind } + val contexts = _contexts.value val title = _title.value val durationIndex = _duration.value - val action = _action.value.action + val action = _action.value return withContext(viewModelScope.coroutineContext) { originalFilter?.let { filter -> @@ -123,8 +123,8 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( private suspend fun createFilter( title: String, - contexts: List, - action: String, + contexts: List, + action: Filter.Action, durationIndex: Int, context: Context ): Boolean { @@ -149,7 +149,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( return ( throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api - createFilterV1(contexts, expiration) + createFilterV1(contexts.map(Filter.Kind::kind), expiration) ) } ) @@ -158,8 +158,8 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( private suspend fun updateFilter( originalFilter: Filter, title: String, - contexts: List, - action: String, + contexts: List, + action: Filter.Action, durationIndex: Int, context: Context ): Boolean { @@ -189,7 +189,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel( { throwable -> if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api - if (updateFilterV1(contexts, expiration)) { + if (updateFilterV1(contexts.map(Filter.Kind::kind), expiration)) { return true } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt index 96d51fc6e..72c294c19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt @@ -43,7 +43,7 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List) : 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("/") + filter.context.map { contexts.getOrNull(it.ordinal) }.joinToString("/") ) binding.delete.setOnClickListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index ce54c6960..343351eed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -31,5 +31,6 @@ data class InstanceInfo( val maxFieldValueLength: Int?, val version: String?, val translationEnabled: Boolean?, + val mastodonApiVersion: Int?, val vapidKey: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index b69cf5ab7..e9b2153bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -146,6 +146,7 @@ class InstanceInfoRepository @Inject constructor( maxFieldValueLength = this?.maxFieldValueLength, version = this?.version, translationEnabled = this?.translationEnabled, + mastodonApiVersion = this?.mastodonApiVersion, vapidKey = this?.vapidKey ) @@ -175,6 +176,7 @@ class InstanceInfoRepository @Inject constructor( maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, translationEnabled = this.configuration?.translation?.enabled, + mastodonApiVersion = this.apiVersions?.mastodon, vapidKey = this.configuration?.vapid?.publicKey ) @@ -204,6 +206,7 @@ class InstanceInfoRepository @Inject constructor( maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, translationEnabled = null, + mastodonApiVersion = null, vapidKey = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 9bd35af3e..bea4d3d1a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -24,6 +24,7 @@ import android.util.Log import android.view.Menu import android.view.View import android.widget.TextView +import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars @@ -138,7 +139,7 @@ class LoginActivity : BaseActivity() { try { HttpUrl.Builder().host(domain).scheme("https").build() - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain) return @@ -161,11 +162,11 @@ class LoginActivity : BaseActivity() { ).fold( { credentials -> // Save credentials so we can access them after we opened another activity for auth. - preferences.edit() - .putString(DOMAIN, domain) - .putString(CLIENT_ID, credentials.clientId) - .putString(CLIENT_SECRET, credentials.clientSecret) - .apply() + preferences.edit { + putString(DOMAIN, domain) + putString(CLIENT_ID, credentials.clientId) + putString(CLIENT_SECRET, credentials.clientSecret) + } redirectUserToAuthorizeAndLogin(domain, credentials.clientId, openInWebView) }, @@ -291,9 +292,8 @@ class LoginActivity : BaseActivity() { oauthScopes = OAUTH_SCOPES, newAccount = newAccount ) - finishAffinity() val intent = Intent(this, MainActivity::class.java) - intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intent) }, { e -> setLoading(false) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt index 851f815cd..8bb6c2f02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt @@ -15,13 +15,14 @@ package com.keylesspalace.tusky.components.notifications -import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toAccount import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.db.entity.NotificationEntity import com.keylesspalace.tusky.db.entity.NotificationReportEntity import com.keylesspalace.tusky.db.entity.TimelineAccountEntity +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.util.toViewData @@ -29,7 +30,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData -fun Placeholder.toNotificationEntity( +fun LoadMorePlaceholder.toNotificationEntity( tuskyAccountId: Long ) = NotificationEntity( id = this.id, @@ -61,6 +62,7 @@ fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean, + filter: Filter?, ): NotificationViewData.Concrete = NotificationViewData.Concrete( id = id, type = type, @@ -68,7 +70,8 @@ fun Notification.toViewData( statusViewData = status?.toViewData( isShowingContent = isShowingContent, isExpanded = isExpanded, - isCollapsed = isCollapsed + isCollapsed = isCollapsed, + filter = filter, ), report = report, moderationWarning = moderationWarning, @@ -90,7 +93,7 @@ fun NotificationDataEntity.toViewData( translation: TranslationViewData? = null ): NotificationViewData { if (type == null || account == null) { - return NotificationViewData.Placeholder(id = id, isLoading = loading) + return NotificationViewData.LoadMore(id = id, isLoading = loading) } return NotificationViewData.Concrete( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 5251ebbfb..1ba150bf9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -72,6 +73,8 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.TranslationViewData @@ -113,6 +116,8 @@ class NotificationsFragment : private var loadMorePosition: Int? = null private var statusIdBelowLoadMore: String? = null + private var buttonToAnimate: SparkButton? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) @@ -129,8 +134,6 @@ class NotificationsFragment : } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -284,6 +287,7 @@ class NotificationsFragment : // Clear the adapters to prevent leaking the View notificationsAdapter = null notificationsPolicyAdapter = null + buttonToAnimate = null super.onDestroyView() } @@ -336,9 +340,23 @@ class NotificationsFragment : viewModel.remove(notification.id) } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status, visibility) + buttonToAnimate = button + + if (reblog && visibility == null) { + confirmReblog(preferences) { visibility -> + viewModel.reblog(true, status, visibility) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) + if (reblog) { + buttonToAnimate?.playAnimation() + } + buttonToAnimate?.isChecked = reblog + } } override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit @@ -369,9 +387,20 @@ class NotificationsFragment : viewModel.untranslate(status) } - override fun onFavourite(favourite: Boolean, position: Int) { + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.favorite(favourite, status) + buttonToAnimate = button + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favorite(true, status) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.favorite(false, status) + buttonToAnimate?.isChecked = false + } } override fun onBookmark(bookmark: Boolean, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt index 394791b04..69f597ce4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -23,16 +23,18 @@ import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.LoadMoreViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.databinding.ItemFollowBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding +import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding -import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification @@ -80,16 +82,17 @@ class NotificationsPagingAdapter( override fun getItemViewType(position: Int): Int { return when (val notification = getItem(position)) { + is NotificationViewData.LoadMore -> VIEW_TYPE_LOAD_MORE is NotificationViewData.Concrete -> { when (notification.type) { Notification.Type.Mention, - Notification.Type.Poll -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + Notification.Type.Poll -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } Notification.Type.Status, - Notification.Type.Update -> if (notification.statusViewData?.filterAction == Filter.Action.WARN) { + Notification.Type.Update -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS_NOTIFICATION @@ -105,13 +108,17 @@ class NotificationsPagingAdapter( else -> VIEW_TYPE_UNKNOWN } } - else -> VIEW_TYPE_PLACEHOLDER + null -> VIEW_TYPE_PLACEHOLDER } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { + VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder( + ItemPlaceholderBinding.inflate(inflater, parent, false), + mode = PlaceholderViewHolder.Mode.NOTIFICATION + ) VIEW_TYPE_STATUS -> StatusViewHolder( inflater.inflate(R.layout.item_status, parent, false), statusListener, @@ -137,8 +144,8 @@ class NotificationsPagingAdapter( statusListener, true ) - VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder( - ItemStatusPlaceholderBinding.inflate(inflater, parent, false), + VIEW_TYPE_LOAD_MORE -> LoadMoreViewHolder( + ItemLoadMoreBinding.inflate(inflater, parent, false), statusListener ) VIEW_TYPE_REPORT -> ReportNotificationViewHolder( @@ -169,24 +176,25 @@ class NotificationsPagingAdapter( when (notification) { is NotificationViewData.Concrete -> (viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions) - is NotificationViewData.Placeholder -> { - (viewHolder as PlaceholderViewHolder).setup(notification.isLoading) + is NotificationViewData.LoadMore -> { + (viewHolder as LoadMoreViewHolder).setup(notification.isLoading) } } } } companion object { - private const val VIEW_TYPE_STATUS = 0 - private const val VIEW_TYPE_STATUS_FILTERED = 1 - private const val VIEW_TYPE_STATUS_NOTIFICATION = 2 - private const val VIEW_TYPE_FOLLOW = 3 - private const val VIEW_TYPE_FOLLOW_REQUEST = 4 - private const val VIEW_TYPE_PLACEHOLDER = 5 - private const val VIEW_TYPE_REPORT = 6 - private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 7 - private const val VIEW_TYPE_MODERATION_WARNING = 8 - private const val VIEW_TYPE_UNKNOWN = 9 + private const val VIEW_TYPE_PLACEHOLDER = 0 + private const val VIEW_TYPE_STATUS = 1 + private const val VIEW_TYPE_STATUS_FILTERED = 2 + private const val VIEW_TYPE_STATUS_NOTIFICATION = 3 + private const val VIEW_TYPE_FOLLOW = 4 + private const val VIEW_TYPE_FOLLOW_REQUEST = 5 + private const val VIEW_TYPE_LOAD_MORE = 6 + private const val VIEW_TYPE_REPORT = 7 + private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 8 + private const val VIEW_TYPE_MODERATION_WARNING = 9 + private const val VIEW_TYPE_UNKNOWN = 10 val NotificationsDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt index 57c6f0411..3ddb30fef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt @@ -22,7 +22,7 @@ import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.keylesspalace.tusky.components.systemnotifications.toTypes -import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -119,7 +119,7 @@ class NotificationsRemoteMediator( to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ notificationsDao.insertNotification( - Placeholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id) + LoadMorePlaceholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id) ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index eacc28225..139f2d758 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -36,7 +36,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.toTypes -import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel @@ -117,7 +117,7 @@ class NotificationsViewModel @Inject constructor( val translation = translations[notification.status?.serverId] notification.toViewData(translation = translation) }.filter { notificationViewData -> - shouldFilterStatus(notificationViewData) != Filter.Action.HIDE + shouldFilterStatus(notificationViewData)?.action != Filter.Action.HIDE } } } @@ -131,7 +131,7 @@ class NotificationsViewModel @Inject constructor( if (event is PreferenceChangedEvent) { onPreferenceChanged(event.preferenceKey) } - if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS.kind)) { + if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS)) { filterModel.init(Filter.Kind.NOTIFICATIONS) refreshTrigger.value += 1 } @@ -165,21 +165,21 @@ class NotificationsViewModel @Inject constructor( } } - private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action { + private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter? { return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> { val account = activeAccountFlow.value notificationViewData.statusViewData?.let { statusViewData -> if (statusViewData.status.account.id == account?.accountId) { - return Filter.Action.NONE + return null } - statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable) - return statusViewData.filterAction + statusViewData.filter = filterModel.shouldFilterStatus(statusViewData.actionable) + return statusViewData.filter } - Filter.Action.NONE + null } - else -> Filter.Action.NONE + else -> null } } @@ -312,7 +312,7 @@ class NotificationsViewModel @Inject constructor( val notificationsDao = db.notificationsDao() notificationsDao.insertNotification( - Placeholder(placeholderId, loading = true).toNotificationEntity( + LoadMorePlaceholder(placeholderId, loading = true).toNotificationEntity( accountId ) ) @@ -346,10 +346,7 @@ class NotificationsViewModel @Inject constructor( return@launch } - val account = activeAccountFlow.value - if (account == null) { - return@launch - } + val account = activeAccountFlow.value ?: return@launch val statusDao = db.timelineStatusDao() val accountDao = db.timelineAccountDao() @@ -404,7 +401,7 @@ class NotificationsViewModel @Inject constructor( ReadingOrder.NEWEST_FIRST -> notifications.last().id } notificationsDao.insertNotification( - Placeholder( + LoadMorePlaceholder( idToConvert, loading = false ).toNotificationEntity(accountId) @@ -424,7 +421,7 @@ class NotificationsViewModel @Inject constructor( val activeAccount = accountManager.activeAccount!! db.notificationsDao() .insertNotification( - Placeholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id) + LoadMorePlaceholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt index 3b6514a97..f6626d083 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt @@ -28,6 +28,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R @@ -47,6 +48,8 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.AndroidEntryPoint @@ -67,6 +70,8 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat private var adapter: NotificationsPagingAdapter? = null + private var buttonToAnimate: SparkButton? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -115,8 +120,6 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -161,9 +164,23 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat viewModel.remove(notification) } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status, visibility) + buttonToAnimate = button + + if (reblog && visibility == null) { + confirmReblog(preferences) { visibility -> + viewModel.reblog(true, status, visibility) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) + if (reblog) { + buttonToAnimate?.playAnimation() + } + buttonToAnimate?.isChecked = reblog + } } override val onMoreTranslate: ((Boolean, Int) -> Unit)? @@ -175,9 +192,20 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat } } - override fun onFavourite(favourite: Boolean, position: Int) { + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.favorite(favourite, status) + buttonToAnimate = button + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favorite(true, status) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.favorite(false, status) + buttonToAnimate?.isChecked = false + } } override fun onBookmark(bookmark: Boolean, position: Int) { @@ -285,13 +313,14 @@ class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notificat // not needed, blocking via the more menu on statuses is handled in SFragment } - override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) { + override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { val notification = adapter?.peek(position) ?: return - viewModel.respondToFollowRequest(accept, accountId = id, notification = notification) + viewModel.respondToFollowRequest(accept, accountId = accountIdRequestingFollow, notification = notification) } override fun onDestroyView() { adapter = null + buttonToAnimate = null super.onDestroyView() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt index c1f8ca984..7ecee658a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt @@ -20,6 +20,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.notifications.toViewData +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -70,9 +71,10 @@ class NotificationRequestDetailsRemoteMediator( val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false val notificationData = notifications.map { notification -> notification.toViewData( - isShowingContent = alwaysShowSensitiveMedia, + isShowingContent = notification.status?.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.NOTIFICATIONS) ?: true, isExpanded = alwaysOpenSpoiler, - true + isCollapsed = true, + filter = notification.status?.getApplicableFilter(Filter.Kind.NOTIFICATIONS), ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index 9a71d34bd..0d994f705 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -41,6 +41,7 @@ import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import dagger.hilt.android.AndroidEntryPoint +import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import javax.inject.Inject import kotlinx.coroutines.launch @@ -159,7 +160,7 @@ class PreferencesActivity : } PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH, PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES, - PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { + EMOJI_PREFERENCE, PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 2661c21a4..637796035 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -21,7 +21,6 @@ import androidx.annotation.DrawableRes import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.valueOf import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.settings.AppTheme diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index 644ed122c..5b4b0b96a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -87,7 +87,7 @@ class ReportViewModel @Inject constructor( .map { pagingData -> /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete instead of StatusViewState */ - pagingData.map { status -> status.toViewData(false, false, false) } + pagingData.map { status -> status.toViewData(false, false, false, filter = null) } } .cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index ddc9d914d..e28480ff5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -83,7 +83,7 @@ class ReportStatusesFragment : when (status.attachments[idx].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(status) - val intent = ViewMediaActivity.newIntent(context, attachments, idx) + val intent = ViewMediaActivity.newIntent(requireContext(), attachments, idx) if (v != null) { val url = status.attachments[idx].url ViewCompat.setTransitionName(v, url) @@ -147,8 +147,6 @@ class ReportStatusesFragment : showBotOverlay = false, useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 3114edc71..ab5c76a94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFacto import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases @@ -70,9 +71,10 @@ class SearchViewModel @Inject constructor( SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { it.statuses.map { status -> status.toViewData( - isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + isShowingContent = status.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.PUBLIC), isExpanded = alwaysOpenSpoiler, - isCollapsed = true + isCollapsed = true, + filter = status.getApplicableFilter(Filter.Kind.PUBLIC), ) }.apply { loadedStatuses.addAll(this) @@ -121,9 +123,9 @@ class SearchViewModel @Inject constructor( hashtagsPagingSourceFactory.newSearch(query) } - fun removeItem(statusViewData: StatusViewData.Concrete) { + fun removeItem(statusViewData: StatusViewData.Concrete, deleteMedia: Boolean) { viewModelScope.launch { - if (timelineCases.delete(statusViewData.id).isSuccess) { + if (timelineCases.delete(statusViewData.id, deleteMedia).isSuccess) { if (loadedStatuses.remove(statusViewData)) { statusesPagingSourceFactory.invalidate() } @@ -207,9 +209,9 @@ class SearchViewModel @Inject constructor( } } - fun deleteStatusAsync(id: String): Deferred> { + fun deleteStatusAsync(id: String, deleteMedia: Boolean): Deferred> { return viewModelScope.async { - timelineCases.delete(id) + timelineCases.delete(id, deleteMedia) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 887fc3283..5950d4908 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -19,7 +19,6 @@ import android.Manifest import android.app.DownloadManager import android.content.Intent import android.content.SharedPreferences -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment @@ -30,6 +29,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.content.getSystemService +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData @@ -38,6 +38,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R @@ -61,6 +62,8 @@ import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.updateRelativeTimePeriodically +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -97,6 +100,8 @@ class SearchStatusesFragment : SearchFragment(), Status pendingMediaDownloads = null } + private var buttonToAnimate: SparkButton? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY) @@ -125,8 +130,6 @@ class SearchStatusesFragment : SearchFragment(), Status showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -156,6 +159,11 @@ class SearchStatusesFragment : SearchFragment(), Status return adapter } + override fun onDestroyView() { + buttonToAnimate = null + super.onDestroyView() + } + override fun onRefresh() { viewModel.clearStatusCache() super.onRefresh() @@ -173,9 +181,18 @@ class SearchStatusesFragment : SearchFragment(), Status } } - override fun onFavourite(favourite: Boolean, position: Int) { - adapter?.peek(position)?.let { status -> - viewModel.favorite(status, favourite) + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { + val status = adapter?.peek(position)?.asStatusOrNull() ?: return + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favorite(status, true) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.favorite(status, false) + buttonToAnimate?.isChecked = false } } @@ -197,7 +214,7 @@ class SearchStatusesFragment : SearchFragment(), Status Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(status) val intent = ViewMediaActivity.newIntent( - context, + requireContext(), attachments, attachmentIndex ) @@ -265,15 +282,29 @@ class SearchStatusesFragment : SearchFragment(), Status override fun clearWarningAction(position: Int) {} - private fun removeItem(position: Int) { + private fun removeItem(position: Int, deleteMedia: Boolean) { adapter?.peek(position)?.let { - viewModel.removeItem(it) + viewModel.removeItem(it, deleteMedia) } } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { adapter?.peek(position)?.let { status -> - viewModel.reblog(status, reblog, visibility) + buttonToAnimate = button + + if (reblog && visibility == null) { + confirmReblog(preferences) { visibility -> + viewModel.reblog(status, true, visibility) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.reblog(status, reblog, visibility ?: Status.Visibility.PUBLIC) + if (reblog) { + buttonToAnimate?.playAnimation() + } + buttonToAnimate?.isChecked = false + } } } @@ -453,12 +484,12 @@ class SearchStatusesFragment : SearchFragment(), Status } R.id.status_unreblog_private -> { - onReblog(false, position) + onReblog(false, position, Status.Visibility.PRIVATE) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { - onReblog(true, position) + onReblog(true, position, Status.Visibility.PRIVATE) return@setOnMenuItemClickListener true } @@ -523,7 +554,7 @@ class SearchStatusesFragment : SearchFragment(), Status private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { return mentions.firstOrNull { - account?.username == it.username && account.domain == Uri.parse(it.url)?.host + account?.username == it.username && account.domain == it.url.toUri().host } != null } @@ -544,7 +575,7 @@ class SearchStatusesFragment : SearchFragment(), Status val downloadManager: DownloadManager = requireContext().getSystemService()!! for (url in mediaUrls) { - val uri = Uri.parse(url) + val uri = url.toUri() val request = DownloadManager.Request(uri) request.setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, @@ -578,8 +609,8 @@ class SearchStatusesFragment : SearchFragment(), Status MaterialAlertDialogBuilder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatusAsync(id) - removeItem(position) + viewModel.deleteStatusAsync(id, true) + removeItem(position, true) } .setNegativeButton(android.R.string.cancel, null) .show() @@ -592,9 +623,9 @@ class SearchStatusesFragment : SearchFragment(), Status .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewLifecycleOwner.lifecycleScope.launch { - viewModel.deleteStatusAsync(id).await().fold( + viewModel.deleteStatusAsync(id, false).await().fold( { deletedStatus -> - removeItem(position) + removeItem(position, false) val redraftStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 9dcb73894..4e47088f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -35,6 +35,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R @@ -65,6 +66,8 @@ import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData @@ -137,6 +140,8 @@ class TimelineFragment : /** The user's preferred reading order */ private lateinit var readingOrder: ReadingOrder + private var buttonToAnimate: SparkButton? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -184,8 +189,6 @@ class TimelineFragment : } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -296,6 +299,7 @@ class TimelineFragment : override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null + buttonToAnimate = null super.onDestroyView() } @@ -403,9 +407,23 @@ class TimelineFragment : super.reply(status.status) } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status, visibility) + buttonToAnimate = button + + if (reblog && visibility == null) { + confirmReblog(preferences) { visibility -> + viewModel.reblog(true, status, visibility) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) + if (reblog) { + buttonToAnimate?.playAnimation() + } + buttonToAnimate?.isChecked = reblog + } } private fun onTranslate(position: Int) { @@ -427,9 +445,18 @@ class TimelineFragment : viewModel.untranslate(status) } - override fun onFavourite(favourite: Boolean, position: Int) { + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return - viewModel.favorite(favourite, status) + buttonToAnimate = button + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favorite(true, status) + buttonToAnimate?.playAnimation() + } + } else { + viewModel.favorite(false, status) + } } override fun onBookmark(bookmark: Boolean, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index e6783a624..d4a0ad194 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -22,11 +22,13 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder +import com.keylesspalace.tusky.adapter.LoadMoreViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding +import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding -import com.keylesspalace.tusky.databinding.ItemStatusPlaceholderBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions @@ -49,23 +51,29 @@ class TimelinePagingAdapter( stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(viewGroup.context) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) return when (viewType) { + VIEW_TYPE_PLACEHOLDER -> { + PlaceholderViewHolder( + ItemPlaceholderBinding.inflate(inflater, parent, false), + mode = PlaceholderViewHolder.Mode.STATUS + ) + } VIEW_TYPE_STATUS_FILTERED -> { FilteredStatusViewHolder( - ItemStatusFilteredBinding.inflate(inflater, viewGroup, false), + ItemStatusFilteredBinding.inflate(inflater, parent, false), statusListener ) } - VIEW_TYPE_PLACEHOLDER -> { - PlaceholderViewHolder( - ItemStatusPlaceholderBinding.inflate(inflater, viewGroup, false), + VIEW_TYPE_LOAD_MORE -> { + LoadMoreViewHolder( + ItemLoadMoreBinding.inflate(inflater, parent, false), statusListener ) } else -> { - StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) + StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) } } } @@ -80,11 +88,11 @@ class TimelinePagingAdapter( payloads: List ) { val viewData = getItem(position) - if (viewData is StatusViewData.Placeholder) { - val holder = viewHolder as PlaceholderViewHolder + if (viewData is StatusViewData.LoadMore) { + val holder = viewHolder as LoadMoreViewHolder holder.setup(viewData.isLoading) } else if (viewData is StatusViewData.Concrete) { - if (viewData.filterAction == Filter.Action.WARN) { + if (viewData.filter?.action == Filter.Action.WARN) { val holder = viewHolder as FilteredStatusViewHolder holder.bind(viewData) } else { @@ -102,21 +110,21 @@ class TimelinePagingAdapter( override fun getItemViewType(position: Int): Int { val viewData = getItem(position) - return if (viewData is StatusViewData.Placeholder) { - VIEW_TYPE_PLACEHOLDER - } else if (viewData?.filterAction == Filter.Action.WARN) { - VIEW_TYPE_STATUS_FILTERED - } else { - VIEW_TYPE_STATUS + return when { + viewData == null -> VIEW_TYPE_PLACEHOLDER + viewData is StatusViewData.LoadMore -> VIEW_TYPE_LOAD_MORE + viewData.filter?.action == Filter.Action.WARN -> VIEW_TYPE_STATUS_FILTERED + else -> VIEW_TYPE_STATUS } } companion object { - 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 = 0 + private const val VIEW_TYPE_STATUS = 1 + private const val VIEW_TYPE_STATUS_FILTERED = 2 + private const val VIEW_TYPE_LOAD_MORE = 3 - val TimelineDifferCallback = object : DiffUtil.ItemCallback() { + private val TimelineDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: StatusViewData, newItem: StatusViewData diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index b342dc2f6..701fe7901 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -19,13 +19,14 @@ import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import java.util.Date -data class Placeholder( +data class LoadMorePlaceholder( val id: String, val loading: Boolean ) @@ -59,7 +60,7 @@ fun TimelineAccountEntity.toAccount(): TimelineAccount { ) } -fun Placeholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity { +fun LoadMorePlaceholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity { return HomeTimelineEntity( id = this.id, tuskyAccountId = tuskyAccountId, @@ -143,9 +144,13 @@ fun TimelineStatusEntity.toStatus( filtered = filtered, ) -fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { +fun HomeTimelineData.toViewData( + isDetailed: Boolean = false, + translation: TranslationViewData? = null, + filter: Filter? = null, +): StatusViewData { if (this.account == null || this.status == null) { - return StatusViewData.Placeholder(this.id, loading) + return StatusViewData.LoadMore(this.id, loading) } val originalStatus = status.toStatus(account) @@ -195,5 +200,5 @@ fun HomeTimelineData.toViewData(isDetailed: Boolean = false, translation: Transl isDetailed = isDetailed, repliedToAccount = repliedToAccount?.toAccount(), translation = translation, - ) + ).apply { this.filter = filter } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 13a47848f..d8e6b4a04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -21,7 +21,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction -import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AppDatabase @@ -113,7 +113,7 @@ class CachedTimelineRemoteMediator( to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ timelineDao.insertHomeTimelineItem( - Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) + LoadMorePlaceholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } } @@ -161,7 +161,7 @@ class CachedTimelineRemoteMediator( } val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler - val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) + val contentShowing = oldStatus?.contentShowing ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind()) val contentCollapsed = oldStatus?.contentCollapsed != false statusDao.insert( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 12ffafa7e..c1bd36d8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -32,8 +32,9 @@ import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST -import com.keylesspalace.tusky.components.timeline.Placeholder +import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -100,12 +101,15 @@ class CachedTimelineViewModel @Inject constructor( .combine(translations) { pagingData, translations -> pagingData.map { timelineData -> val translation = translations[timelineData.status?.serverId] + val status = timelineData.account?.let { timelineData.status?.toStatus(it) } + val filter = status?.let { shouldFilterStatus(it) } timelineData.toViewData( isDetailed = false, - translation = translation + translation = translation, + filter = filter, ) }.filter { statusViewData -> - shouldFilterStatus(statusViewData) != Filter.Action.HIDE + statusViewData.filter?.action != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) @@ -149,7 +153,7 @@ class CachedTimelineViewModel @Inject constructor( val accountDao = db.timelineAccountDao() timelineDao.insertHomeTimelineItem( - Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) + LoadMorePlaceholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) ) val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction { @@ -207,8 +211,8 @@ class CachedTimelineViewModel @Inject constructor( status.actionableStatus.toEntity( tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, - contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - contentCollapsed = true + contentShowing = status.shouldShowContent(account.alwaysShowSensitiveMedia, kind.toFilterKind()), + contentCollapsed = true, ) ) timelineDao.insertHomeTimelineItem( @@ -236,7 +240,7 @@ class CachedTimelineViewModel @Inject constructor( NEWEST_FIRST -> statuses.last().id } timelineDao.insertHomeTimelineItem( - Placeholder( + LoadMorePlaceholder( idToConvert, loading = false ).toEntity(accountId) @@ -255,7 +259,7 @@ class CachedTimelineViewModel @Inject constructor( Log.w(TAG, "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() - .insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) + .insertHomeTimelineItem(LoadMorePlaceholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun fullReload() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 566274afb..b9d84e4c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -74,14 +74,16 @@ class NetworkTimelineRemoteMediator( s.asStatusOrNull()?.id == status.id }?.asStatusOrNull() - val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) + val filter = oldStatus?.filter ?: status.getApplicableFilter(viewModel.kind.toFilterKind()) + val contentShowing = oldStatus?.isShowingContent ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind()) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler val contentCollapsed = oldStatus?.isCollapsed != false status.toViewData( isShowingContent = contentShowing, isExpanded = expanded, - isCollapsed = contentCollapsed + isCollapsed = contentCollapsed, + filter = filter, ) } @@ -101,7 +103,7 @@ class NetworkTimelineRemoteMediator( viewModel.statusData.addAll(0, data) if (insertPlaceholder) { - viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false) + viewModel.statusData[statuses.size - 1] = StatusViewData.LoadMore(statuses.last().id, false) } } else { val linkHeader = statusResponse.headers()["Link"] diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index fd1d40bce..60bd6c76d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -102,7 +102,9 @@ class NetworkTimelineViewModel @Inject constructor( ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - shouldFilterStatus(statusViewData) != Filter.Action.HIDE + statusViewData.asStatusOrNull()?.actionable?.let { + shouldFilterStatus(it)?.action != Filter.Action.HIDE + } ?: true } } .flowOn(Dispatchers.Default) @@ -198,9 +200,9 @@ class NetworkTimelineViewModel @Inject constructor( viewModelScope.launch { try { val placeholderIndex = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } + statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId } statusData[placeholderIndex] = - StatusViewData.Placeholder(placeholderId, isLoading = true) + StatusViewData.LoadMore(placeholderId, isLoading = true) val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id @@ -221,9 +223,10 @@ class NetworkTimelineViewModel @Inject constructor( val activeAccount = accountManager.activeAccount!! val data: MutableList = statuses.map { status -> status.toViewData( - isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + isShowingContent = status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, kind.toFilterKind()), isExpanded = activeAccount.alwaysOpenSpoiler, - isCollapsed = true + isCollapsed = true, + filter = status.getApplicableFilter(kind.toFilterKind()), ) }.toMutableList() @@ -255,7 +258,7 @@ class NetworkTimelineViewModel @Inject constructor( statusData.removeAll { status -> when (status) { - is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( + is StatusViewData.LoadMore -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( firstId ) @@ -266,7 +269,7 @@ class NetworkTimelineViewModel @Inject constructor( } } else { data[data.size - 1] = - StatusViewData.Placeholder(statuses.last().id, isLoading = false) + StatusViewData.LoadMore(statuses.last().id, isLoading = false) } } @@ -285,8 +288,8 @@ class NetworkTimelineViewModel @Inject constructor( Log.w("NetworkTimelineVM", "failed loading statuses", e) val index = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) + statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId } + statusData[index] = StatusViewData.LoadMore(placeholderId, isLoading = false) currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index f8546a1b0..d8ae99499 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -178,20 +178,18 @@ abstract class TimelineViewModel( /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() - protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { - val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE + protected fun shouldFilterStatus(status: Status): Filter? { return if ( (status.isReply && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) || (status.account.id == status.reblog?.account?.id && filterRemoveSelfReblogs) ) { - Filter.Action.HIDE + Filter(context = listOf(kind.toFilterKind()), action = Filter.Action.HIDE) } else if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) { // Mastodon filters don't apply for own posts - Filter.Action.NONE + null } else { - statusViewData.filterAction = filterModel.shouldFilterStatus(status.actionableStatus) - statusViewData.filterAction + filterModel.shouldFilterStatus(status.actionableStatus) } } @@ -244,9 +242,8 @@ abstract class TimelineViewModel( private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 - fun filterContextMatchesKind(kind: Kind, filterContext: List): Boolean { - return filterContext.contains(kind.toFilterKind().kind) - } + fun filterContextMatchesKind(kind: Kind, filterContext: List): Boolean = + filterContext.contains(kind.toFilterKind()) } enum class Kind { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index ea8b4b092..7a8aba5eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -95,7 +95,7 @@ class TrendingTagsViewModel @Inject constructor( TrendingTagsUiState(emptyList(), LoadingState.LOADED) } else { val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> - filter.context.contains(Filter.Kind.HOME.kind) + filter.context.contains(Filter.Kind.HOME) } val tags = tagResponse .filter { tag -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 0b5f7d458..0ccc44fb3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -71,7 +71,7 @@ class ThreadAdapter( val viewData = getItem(position) return if (viewData.isDetailed) { VIEW_TYPE_STATUS_DETAILED - } else if (viewData.filterAction == Filter.Action.WARN) { + } else if (viewData.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index bedb42821..5abc6ce8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -34,6 +34,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.accountlist.AccountListActivity @@ -55,6 +56,8 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite +import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData @@ -88,6 +91,8 @@ class ViewThreadFragment : private var alwaysShowSensitiveMedia = false private var alwaysOpenSpoiler = false + private var buttonToAnimate: SparkButton? = null + /** * State of the "reveal" menu item that shows/hides content that is behind a content * warning. Setting this invalidates the menu to redraw the menu item. @@ -115,8 +120,6 @@ class ViewThreadFragment : } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -266,6 +269,7 @@ class ViewThreadFragment : override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null + buttonToAnimate = null super.onDestroyView() } @@ -334,9 +338,23 @@ class ViewThreadFragment : super.reply(viewData.status) } - override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) { + override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.currentList?.getOrNull(position) ?: return - viewModel.reblog(reblog, status, visibility) + buttonToAnimate = button + + if (reblog && visibility == null) { + confirmReblog(preferences) { visibility -> + viewModel.reblog(true, status, visibility) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) + if (reblog) { + buttonToAnimate?.playAnimation() + } + buttonToAnimate?.isChecked = false + } } override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) = @@ -369,9 +387,20 @@ class ViewThreadFragment : viewModel.untranslate(status) } - override fun onFavourite(favourite: Boolean, position: Int) { + override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.currentList?.getOrNull(position) ?: return - viewModel.favorite(favourite, status) + buttonToAnimate = button + + if (favourite) { + confirmFavourite(preferences) { + viewModel.favorite(true, status) + buttonToAnimate?.playAnimation() + buttonToAnimate?.isChecked = true + } + } else { + viewModel.favorite(false, status) + buttonToAnimate?.isChecked = false + } } override fun onBookmark(bookmark: Boolean, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 717a0fc26..a4f555a43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -61,9 +61,9 @@ class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, private val filterModel: FilterModel, private val timelineCases: TimelineCases, - eventHub: EventHub, - private val accountManager: AccountManager, private val db: AppDatabase, + eventHub: EventHub, + accountManager: AccountManager, ) : ViewModel() { private val activeAccount = accountManager.activeAccount!! @@ -312,6 +312,7 @@ class ViewThreadViewModel @Inject constructor( isCollapsed = viewData.isCollapsed, isDetailed = viewData.isDetailed, translation = viewData.translation, + filter = viewData.filter, ) } } @@ -421,8 +422,8 @@ class ViewThreadViewModel @Inject constructor( if (status.isDetailed || status.status.account.id == activeAccount.accountId) { true } else { - status.filterAction = filterModel.shouldFilterStatus(status.status) - status.filterAction != Filter.Action.HIDE + status.filter = filterModel.shouldFilterStatus(status.status) + status.filter?.action != Filter.Action.HIDE } } } @@ -432,11 +433,11 @@ class ViewThreadViewModel @Inject constructor( it.id == this.id } return toViewData( - isShowingContent = oldStatus?.isShowingContent - ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), + isShowingContent = oldStatus?.isShowingContent ?: actionableStatus.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.THREAD), isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, - isDetailed = oldStatus?.isDetailed ?: isDetailed + isDetailed = oldStatus?.isDetailed ?: isDetailed, + filter = oldStatus?.filter ?: actionableStatus.getApplicableFilter(Filter.Kind.THREAD), ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index aef461e15..3c48992e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -2,7 +2,6 @@ package com.keylesspalace.tusky.components.viewthread.edits import android.content.Context import android.graphics.Typeface.DEFAULT_BOLD -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.text.Editable import android.text.SpannableStringBuilder @@ -12,6 +11,7 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.graphics.drawable.toDrawable import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide @@ -188,7 +188,7 @@ class ViewEditsAdapter( val placeholder: Drawable = if (blurhash != null && useBlurhash) { BlurhashDrawable(context, blurhash) } else { - ColorDrawable(MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent)) + MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent).toDrawable() } if (attachment.previewUrl.isNullOrEmpty()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 776446025..660e90743 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -65,7 +65,7 @@ import java.io.File; }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. - version = 68, + version = 70, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -76,6 +76,7 @@ import java.io.File; @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity @AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity @AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity + @AutoMigration(from = 68, to = 70), // added mastodonApiVersion to InstanceEntity } ) public abstract class AppDatabase extends RoomDatabase { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt index b79bf9ead..9a336827f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt @@ -42,6 +42,8 @@ data class InstanceEntity( val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, + val mastodonApiVersion: Int?, + // ToDo: Remove this again when filter v1 support is dropped @ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false, val vapidKey: String? @@ -70,5 +72,6 @@ data class InstanceInfoEntity( val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, + val mastodonApiVersion: Int?, val vapidKey: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt index ac5cf7bab..fc6ce60b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt @@ -79,16 +79,14 @@ object PlayerModule { textRendererOutput, metadataRendererOutput -> arrayOf( - MediaCodecVideoRenderer( - context, - MediaCodecSelector.DEFAULT, - DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS, - // enableDecoderFallback = true, helps playing videos even if one decoder fails - true, - eventHandler, - videoRendererEventListener, - DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY - ), + MediaCodecVideoRenderer.Builder(context) + .setMediaCodecSelector(MediaCodecSelector.DEFAULT) + .setAllowedJoiningTimeMs(DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS) + .setEnableDecoderFallback(true) + .setEventHandler(eventHandler) + .setEventListener(videoRendererEventListener) + .setMaxDroppedFramesToNotify(DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) + .build(), MediaCodecAudioRenderer( context, MediaCodecSelector.DEFAULT, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index f85be3834..b7197db15 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -9,39 +9,60 @@ import kotlinx.parcelize.Parcelize @JsonClass(generateAdapter = true) @Parcelize data class Filter( - val id: String, - val title: String, - val context: List, + val id: String = "", + val title: String = "", + val context: List, @Json(name = "expires_at") val expiresAt: Date? = null, - @Json(name = "filter_action") val filterAction: String, + @Json(name = "filter_action") val action: Action, // This field is mandatory according to the API documentation but is in fact optional in some instances val keywords: List = emptyList(), // val statuses: List, ) : Parcelable { + + @JsonClass(generateAdapter = false) enum class Action(val action: String) { + @Json(name = "none") NONE("none"), + + @Json(name = "blur") + BLUR("blur"), + + @Json(name = "warn") WARN("warn"), + + @Json(name = "hide") HIDE("hide"); + // Retrofit will call toString when sending this class as part of a form-urlencoded body. + override fun toString() = action + companion object { fun from(action: String): Action = entries.firstOrNull { it.action == action } ?: WARN } } + + @JsonClass(generateAdapter = false) enum class Kind(val kind: String) { + @Json(name = "home") HOME("home"), + + @Json(name = "notifications") NOTIFICATIONS("notifications"), + + @Json(name = "public") PUBLIC("public"), + + @Json(name = "thread") THREAD("thread"), + + @Json(name = "account") ACCOUNT("account"); + // Retrofit will call toString when sending this class as part of a form-urlencoded body. + override fun toString() = kind + companion object { fun from(kind: String): Kind = entries.firstOrNull { it.kind == kind } ?: PUBLIC } } - - val action: Action - get() = Action.from(filterAction) - - val kinds: List - get() = context.map { Kind.from(it) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index 40f48e1cd..1636b8800 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -40,20 +40,18 @@ data class FilterV1( return other.id == 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 - ) + fun toFilter() = Filter( + id = id, + title = phrase, + context = context.map(Filter.Kind::from), + expiresAt = expiresAt, + action = Filter.Action.WARN, + keywords = listOf( + FilterKeyword( + id = id, + keyword = phrase, + wholeWord = wholeWord ) ) - } + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index e6ac39e62..cde8c1626 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -17,7 +17,8 @@ data class Instance( // val registrations: Registrations, // val contact: Contact, val rules: List = emptyList(), - val pleroma: PleromaConfiguration? = null + val pleroma: PleromaConfiguration? = null, + @Json(name = "api_versions") val apiVersions: ApiVersions? = null, ) { @JsonClass(generateAdapter = true) data class Usage(val users: Users) { @@ -46,7 +47,7 @@ data class Instance( val statuses: Statuses? = null, @Json(name = "media_attachments") val mediaAttachments: MediaAttachments? = null, val polls: Polls? = null, - val translation: Translation? = null + val translation: Translation? = null, ) { @JsonClass(generateAdapter = true) data class Urls(@Json(name = "streaming_api") val streamingApi: String? = null) @@ -105,4 +106,7 @@ data class Instance( @JsonClass(generateAdapter = true) data class Rule(val id: String, val text: String) + + @JsonClass(generateAdapter = true) + data class ApiVersions(val mastodon: Int? = null) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 67b34e549..c4f2563bf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -158,6 +158,12 @@ data class Status( return builder.toString() } + fun getApplicableFilter(kind: Filter.Kind): Filter? = + actionableStatus.filtered?.filter { it.filter.context.contains(kind) }?.maxByOrNull { it.filter.action.ordinal }?.filter + + fun shouldShowContent(alwayShowSensitiveContent: Boolean, context: Filter.Kind): Boolean = + alwayShowSensitiveContent || (!actionableStatus.sensitive && getApplicableFilter(context)?.action != Filter.Action.BLUR) + @JsonClass(generateAdapter = true) data class Mention( val id: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index ca6ca4c89..1da0bdde9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -18,7 +18,6 @@ import android.Manifest import android.app.DownloadManager import android.content.DialogInterface import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment @@ -31,10 +30,12 @@ import androidx.annotation.LayoutRes import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.content.getSystemService +import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.sparkbutton.SparkButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -74,7 +75,7 @@ import kotlinx.coroutines.launch * up what needs to be where. */ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) { protected abstract fun removeItem(position: Int) - protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility) + protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) /** `null` if translation is not supported on this screen */ protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? @@ -318,12 +319,12 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo } R.id.status_unreblog_private -> { - onReblog(false, position, Status.Visibility.PUBLIC) + onReblog(false, position, Status.Visibility.PRIVATE, null) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { - onReblog(true, position, Status.Visibility.PUBLIC) + onReblog(true, position, Status.Visibility.PRIVATE, null) return@setOnMenuItemClickListener true } @@ -398,7 +399,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo val (attachment) = attachments[urlIndex] when (attachment.type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { - val intent = newIntent(context, attachments, urlIndex) + val intent = newIntent(requireContext(), attachments, urlIndex) if (view != null) { val url = attachment.url view.transitionName = url @@ -432,7 +433,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> viewLifecycleOwner.lifecycleScope.launch { - val result = timelineCases.delete(id).exceptionOrNull() + val result = timelineCases.delete(id, true).exceptionOrNull() if (result != null) { Log.w("SFragment", "error deleting status", result) Toast.makeText(requireContext(), R.string.error_generic, Toast.LENGTH_SHORT).show() @@ -456,7 +457,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> viewLifecycleOwner.lifecycleScope.launch { - timelineCases.delete(id).fold( + timelineCases.delete(id, false).fold( { deletedStatus -> removeItem(position) val sourceStatus = if (deletedStatus.isEmpty) { @@ -542,7 +543,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo val downloadManager: DownloadManager = requireContext().getSystemService()!! for (url in mediaUrls) { - val uri = Uri.parse(url) + val uri = url.toUri() downloadManager.enqueue( DownloadManager.Request(uri).apply { setDestinationInExternalPublicDir( @@ -568,7 +569,6 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo } companion object { - private const val TAG = "SFragment" private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads" private fun accountIsInMentions( @@ -576,7 +576,7 @@ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayo mentions: List ): Boolean { return mentions.any { mention -> - account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host + account?.username == mention.username && account.domain == mention.url.toUri().host } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 6faf739c2..ae2e38cd1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -28,8 +28,8 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars -import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide @@ -115,14 +115,18 @@ class ViewImageFragment : ViewMediaFragment() { val descriptionBottomSheet = BottomSheetBehavior.from(binding.captionSheet) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + val topInsets = insets.getInsets(displayCutout()).top val bottomInsets = insets.getInsets(systemBars()).bottom val mediaDescriptionBottomPadding = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_bottom_padding) val mediaDescriptionPeekHeight = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_peek_height) - val imageViewBottomMargin = requireContext().resources.getDimensionPixelSize(R.dimen.media_image_view_bottom_margin) binding.mediaDescription.updatePadding(bottom = mediaDescriptionBottomPadding + bottomInsets) descriptionBottomSheet.setPeekHeight(mediaDescriptionPeekHeight + bottomInsets, false) - binding.photoView.updateLayoutParams { bottomMargin = imageViewBottomMargin + bottomInsets } - insets.inset(0, 0, 0, bottomInsets) + binding.photoView.updatePadding( + top = topInsets, + bottom = bottomInsets + ) + binding.photoView.invalidate() + insets.inset(0, topInsets, 0, bottomInsets) } val singleTapDetector = GestureDetector( @@ -282,9 +286,11 @@ class ViewImageFragment : ViewMediaFragment() { // Request image from the network on fail load image from cache .error( glide.load(url) + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .centerInside() .addListener(ImageRequestListener(false, isThumbnailRequest = false)) ) + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .centerInside() .addListener(ImageRequestListener(true, isThumbnailRequest = false)) .into(photoView) diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt index a726e6867..d101dd57d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt @@ -15,12 +15,24 @@ package com.keylesspalace.tusky.interfaces import android.view.View +import at.connyduck.sparkbutton.SparkButton import com.keylesspalace.tusky.entity.Status interface StatusActionListener : LinkListener { fun onReply(position: Int) - fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility = Status.Visibility.PUBLIC) - fun onFavourite(favourite: Boolean, position: Int) + + /** + * Reblog the post at [position] + * @param visibility The visibility to use for the reblog, if the user has already chosen it, null otherwise + * @param button Optional button to animate + */ + fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton? = null) + + /** + * Favourite the post at [position] + * @param button Optional button to animate + */ + fun onFavourite(favourite: Boolean, position: Int, button: SparkButton? = null) fun onBookmark(bookmark: Boolean, position: Int) fun onMore(view: View, position: Int) fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 355212589..86eefb834 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -66,13 +66,13 @@ class FilterModel @Inject constructor( ) } - fun shouldFilterStatus(status: Status): Filter.Action { + fun shouldFilterStatus(status: Status): Filter? { if (v1) { // Patterns are expensive and thread-safe, matchers are neither. - val matcher = pattern?.matcher("") ?: return Filter.Action.NONE + val matcher = pattern?.matcher("") ?: return null if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { - return Filter.Action.HIDE + return Filter(context = listOf(kind), action = Filter.Action.HIDE) } val spoilerText = status.actionableStatus.spoilerText @@ -83,21 +83,13 @@ class FilterModel @Inject constructor( (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || (attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find()) ) { - Filter.Action.HIDE + return Filter(context = listOf(kind), action = Filter.Action.HIDE) } else { - Filter.Action.NONE + null } } - val matchingKind = status.filtered.orEmpty().filter { result -> - result.filter.kinds.contains(kind) - } - - return if (matchingKind.isEmpty()) { - Filter.Action.NONE - } else { - matchingKind.maxOf { it.filter.action } - } + return status.getApplicableFilter(kind) } private fun filterToRegexToken(filter: FilterV1): String? { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 602cdce3c..48db67e4b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -250,7 +250,10 @@ interface MastodonApi { ): Response> @DELETE("api/v1/statuses/{id}") - suspend fun deleteStatus(@Path("id") statusId: String): NetworkResult + suspend fun deleteStatus( + @Path("id") statusId: String, + @Query("delete_media") deleteMedia: Boolean? = null + ): NetworkResult @FormUrlEncoded @POST("api/v1/statuses/{id}/reblog") @@ -402,10 +405,10 @@ interface MastodonApi { suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/blocks") - suspend fun blocks(@Query("max_id") maxId: String?): Response> + suspend fun blocks(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/mutes") - suspend fun mutes(@Query("max_id") maxId: String?): Response> + suspend fun mutes(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/domain_blocks") suspend fun domainBlocks( @@ -561,8 +564,8 @@ interface MastodonApi { @POST("api/v2/filters") suspend fun createFilter( @Field("title") title: String, - @Field("context[]") context: List, - @Field("filter_action") filterAction: String, + @Field("context[]") context: List, + @Field("filter_action") filterAction: Filter.Action, @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @@ -571,8 +574,8 @@ interface MastodonApi { suspend fun updateFilter( @Path("id") id: String, @Field("title") title: String? = null, - @Field("context[]") context: List? = null, - @Field("filter_action") filterAction: String? = null, + @Field("context[]") context: List? = null, + @Field("filter_action") filterAction: Filter.Action? = null, @Field("expires_in") expires: FilterExpiration? = null ): NetworkResult diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 8d61330e6..f4f83259d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -127,7 +127,7 @@ class SendStatusService : Service() { cancelSendingIntent(sendingNotificationId) ) - if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (statusesToSend.isEmpty() || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) startForeground(sendingNotificationId, builder.build()) } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 086e19ea8..1ff08d9f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -106,6 +106,9 @@ object PrefKeys { /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" + /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ + const val REBLOG_PRIVACY = "reblogPrivacy" + object Deprecated { const val FAB_HIDE = "fabHide" } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 4fa2ed3c4..3d21318ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -109,8 +109,8 @@ class TimelineCases @Inject constructor( } } - suspend fun delete(statusId: String): NetworkResult { - return mastodonApi.deleteStatus(statusId) + suspend fun delete(statusId: String, deleteMedia: Boolean): NetworkResult { + return mastodonApi.deleteStatus(statusId, deleteMedia) .onSuccess { eventHub.dispatch(StatusDeletedEvent(statusId)) } .onFailure { Log.w(TAG, "Failed to delete status", it) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index 53290d075..9f30e54eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable import android.text.style.ReplacementSpan import android.view.View import android.widget.TextView +import androidx.core.graphics.withSave import androidx.core.text.toSpannable import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget @@ -155,32 +156,31 @@ class EmojiSpan(view: View) : ReplacementSpan() { paint: Paint ) { imageDrawable?.let { drawable -> - canvas.save() - - // start with a width relative to the text size - var emojiWidth = paint.textSize * 1.1 - - // calculate the height, keeping the aspect ratio correct - val drawableWidth = drawable.intrinsicWidth - val drawableHeight = drawable.intrinsicHeight - var emojiHeight = emojiWidth / drawableWidth * drawableHeight + canvas.withSave { + // start with a width relative to the text size + var emojiWidth = paint.textSize * 1.1 + + // calculate the height, keeping the aspect ratio correct + val drawableWidth = drawable.intrinsicWidth + val drawableHeight = drawable.intrinsicHeight + var emojiHeight = emojiWidth / drawableWidth * drawableHeight + + // how much vertical space there is draw the emoji + val drawableSpace = (bottom - top).toDouble() + + // in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio + if (emojiHeight > drawableSpace) { + emojiWidth *= drawableSpace / emojiHeight + emojiHeight = drawableSpace + } + drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt()) - // how much vertical space there is draw the emoji - val drawableSpace = (bottom - top).toDouble() + // vertically center the emoji in the line + val transY = top + (drawableSpace / 2 - emojiHeight / 2) - // in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio - if (emojiHeight > drawableSpace) { - emojiWidth *= drawableSpace / emojiHeight - emojiHeight = drawableSpace + translate(x, transY.toFloat()) + drawable.draw(this) } - drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt()) - - // vertically center the emoji in the line - val transY = top + (drawableSpace / 2 - emojiHeight / 2) - - canvas.translate(x, transY.toFloat()) - drawable.draw(canvas) - canvas.restore() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index e45a52fa9..224025322 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -413,7 +413,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { try { context.startActivity(intent) } catch (e: ActivityNotFoundException) { - Log.w(TAG, "Activity was not found for intent, $intent") + Log.w(TAG, "Activity was not found for intent, $intent", e) } } @@ -430,6 +430,8 @@ fun openLinkInCustomTab(uri: Uri, context: Context) { materialR.attr.colorSurface, Color.BLACK ) + + @Suppress("DEPRECATION") val navigationbarColor = MaterialColors.getColor( context, android.R.attr.navigationBarColor, @@ -454,7 +456,7 @@ fun openLinkInCustomTab(uri: Uri, context: Context) { try { customTabsIntent.launchUrl(context, uri) } catch (e: ActivityNotFoundException) { - Log.w(TAG, "Activity was not found for intent $customTabsIntent") + Log.w(TAG, "Activity was not found for intent $customTabsIntent", e) openLinkInBrowser(uri, context) } } @@ -480,7 +482,7 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { uri = URI(urlString) - } catch (e: URISyntaxException) { + } catch (_: URISyntaxException) { return false } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 82f43bf59..c567ed048 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -105,8 +105,8 @@ class ListStatusAccessibilityDelegate( R.id.action_unfavourite -> statusActionListener.onFavourite(false, pos) R.id.action_bookmark -> statusActionListener.onBookmark(true, pos) R.id.action_unbookmark -> statusActionListener.onBookmark(false, pos) - R.id.action_reblog -> statusActionListener.onReblog(true, pos) - R.id.action_unreblog -> statusActionListener.onReblog(false, pos) + R.id.action_reblog -> statusActionListener.onReblog(true, pos, null) + R.id.action_unreblog -> statusActionListener.onReblog(false, pos, null) R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index 12e5b73d1..0bde07487 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -17,13 +17,6 @@ package com.keylesspalace.tusky.util -/** - * Copies elements to destination, removing duplicates and preserving original order. - */ -fun > Iterable.removeDuplicatesTo(destination: C): C { - return filterTo(destination, HashSet()::add) -} - inline fun List.withoutFirstWhich(predicate: (T) -> Boolean): List { val index = indexOfFirst(predicate) if (index == -1) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 8f3d2a7c9..05fa86a25 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.SharedPreferences import android.os.Build import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceDataStore import com.keylesspalace.tusky.R @@ -44,9 +45,9 @@ class LocaleManager @Inject constructor( // hand over the old setting to the system and save a dummy value in Shared Preferences applyLanguageToApp(language) - preferences.edit() - .putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM) - .apply() + preferences.edit { + putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM) + } } } else { // on Android < 13 we have to apply the language at every app start @@ -58,9 +59,9 @@ class LocaleManager @Inject constructor( // if we are on Android < 13 we have to save the selected language so we can apply it at appstart // on Android 13+ the system handles it for us if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - preferences.edit() - .putString(PrefKeys.LANGUAGE, value) - .apply() + preferences.edit { + putString(PrefKeys.LANGUAGE, value) + } } applyLanguageToApp(value) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt b/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt index 788786ed0..5df784d94 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RickRoll.kt @@ -2,7 +2,7 @@ package com.keylesspalace.tusky.util import android.content.Context import android.content.Intent -import android.net.Uri +import androidx.core.net.toUri import com.keylesspalace.tusky.R fun shouldRickRoll(context: Context, domain: String) = @@ -11,7 +11,7 @@ fun shouldRickRoll(context: Context, domain: String) = } fun rickRoll(context: Context) { - val uri = Uri.parse(context.getString(R.string.rick_roll_url)) + val uri = context.getString(R.string.rick_roll_url).toUri() val intent = Intent(Intent.ACTION_VIEW, uri).apply { addCategory(Intent.CATEGORY_BROWSABLE) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index d92df6fe9..271930d47 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -19,13 +19,13 @@ package com.keylesspalace.tusky.util import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.graphics.Canvas import android.util.Log import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.Person import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.GlideException @@ -67,7 +67,7 @@ class ShareShortcutHelper @Inject constructor( } // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon - val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) + val outBmp = createBitmap(outerSize, outerSize) val canvas = Canvas(outBmp) val borderSize = (outerSize - innerSize) / 2 diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 3980ac712..fee4cdbd9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -34,10 +34,6 @@ data class StatusDisplayOptions( val useBlurhash: Boolean, @get:JvmName("cardViewMode") val cardViewMode: CardViewMode, - @get:JvmName("confirmReblogs") - val confirmReblogs: Boolean, - @get:JvmName("confirmFavourites") - val confirmFavourites: Boolean, @get:JvmName("hideStats") val hideStats: Boolean, @get:JvmName("animateEmojis") @@ -69,12 +65,6 @@ data class StatusDisplayOptions( PrefKeys.USE_BLURHASH -> copy( useBlurhash = preferences.getBoolean(key, true) ) - PrefKeys.CONFIRM_FAVOURITES -> copy( - confirmFavourites = preferences.getBoolean(key, false) - ) - PrefKeys.CONFIRM_REBLOGS -> copy( - confirmReblogs = preferences.getBoolean(key, true) - ) PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy( hideStats = preferences.getBoolean(key, false) ) @@ -93,21 +83,6 @@ data class StatusDisplayOptions( } companion object { - /** Preference keys that, if changed, affect StatusDisplayOptions */ - val prefKeys = setOf( - PrefKeys.ABSOLUTE_TIME_VIEW, - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA, - PrefKeys.ALWAYS_OPEN_SPOILER, - PrefKeys.ANIMATE_CUSTOM_EMOJIS, - PrefKeys.ANIMATE_GIF_AVATARS, - PrefKeys.CONFIRM_FAVOURITES, - PrefKeys.CONFIRM_REBLOGS, - PrefKeys.MEDIA_PREVIEW_ENABLED, - PrefKeys.SHOW_BOT_OVERLAY, - PrefKeys.USE_BLURHASH, - PrefKeys.WELLBEING_HIDE_STATS_POSTS - ) - fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), @@ -116,8 +91,6 @@ data class StatusDisplayOptions( showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), showSensitiveMedia = account.alwaysShowSensitiveMedia, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index d73d9e87f..fe66bc64e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -17,13 +17,13 @@ package com.keylesspalace.tusky.util import android.content.Context import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.text.InputFilter import android.text.TextUtils import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.annotation.DrawableRes +import androidx.core.graphics.drawable.toDrawable import com.bumptech.glide.Glide import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R @@ -89,9 +89,8 @@ class StatusViewHelper(private val itemView: View) { } val mediaPreviewUnloaded = - ColorDrawable( - MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK) - ) + MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK) + .toDrawable() val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) @@ -348,7 +347,8 @@ class StatusViewHelper(private val itemView: View) { options[i].title, percent, options[i].voted, - pollResults[i].context + pollResults[i].context, + pollResults[i] ) pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i], animateEmojis) pollResults[i].visibility = View.VISIBLE diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 01aff3e00..18ac108e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -9,7 +9,7 @@ import kotlin.random.Random private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" const val HASHTAG_EXPRESSION = "([\\w_]*[\\p{Alpha}_][\\w_]*)" -val hashtagPattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE) +val hashtagPattern: Pattern = Pattern.compile(HASHTAG_EXPRESSION, Pattern.CASE_INSENSITIVE or Pattern.MULTILINE) fun randomAlphanumericString(count: Int): String { val chars = CharArray(count) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index 08aa992c7..06a308714 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -22,7 +22,6 @@ import android.graphics.Color import android.graphics.drawable.Drawable import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.res.use import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.settings.AppTheme @@ -31,12 +30,6 @@ import com.keylesspalace.tusky.settings.AppTheme * the ability to do so is not supported in resource files. */ -fun getDimension(context: Context, @AttrRes attribute: Int): Int { - return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> - array.getDimensionPixelSize(0, -1) - } -} - fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: Int) { drawable.setTint(MaterialColors.getColor(context, attribute, Color.BLACK)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index 7c0f8adf2..7fc38bbd5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -36,6 +36,7 @@ package com.keylesspalace.tusky.util import androidx.paging.CombinedLoadStates import androidx.paging.LoadState +import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.StatusViewData @@ -47,17 +48,16 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean, isDetailed: Boolean = false, + filter: Filter?, translation: TranslationViewData? = null, -): StatusViewData.Concrete { - return StatusViewData.Concrete( - status = this, - isShowingContent = isShowingContent, - isCollapsed = isCollapsed, - isExpanded = isExpanded, - isDetailed = isDetailed, - translation = translation, - ) -} +) = StatusViewData.Concrete( + status = this, + isShowingContent = isShowingContent, + isCollapsed = isCollapsed, + isExpanded = isExpanded, + isDetailed = isDetailed, + translation = translation, +).apply { this.filter = filter } fun List.toViewData(): List { val maxTrendingValue = flatMap { tag -> tag.history } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt index 1fc01c999..46cc5b18a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt @@ -39,6 +39,7 @@ import android.view.MotionEvent.ACTION_DOWN import android.view.MotionEvent.ACTION_UP import android.view.ViewConfiguration import androidx.appcompat.widget.AppCompatTextView +import androidx.core.graphics.withSave import androidx.core.view.doOnLayout import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R @@ -379,15 +380,15 @@ class ClickableSpanTextView @JvmOverloads constructor( // Paint span boundaries. Optimised out on release builds, or debug builds where // showSpanBoundaries is false. if (BuildConfig.DEBUG && showSpanBoundaries) { - canvas.save() - for (entry in delegateRects) { - canvas.drawRect(entry.key, paddingDebugPaint) - } + canvas.withSave { + for (entry in delegateRects) { + drawRect(entry.key, paddingDebugPaint) + } - for (entry in spanRects) { - canvas.drawRect(entry.key, spanDebugPaint) + for (entry in spanRects) { + drawRect(entry.key, spanDebugPaint) + } } - canvas.restore() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConfirmationBottomSheet.kt b/app/src/main/java/com/keylesspalace/tusky/view/ConfirmationBottomSheet.kt new file mode 100644 index 000000000..ee1a298d9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/ConfirmationBottomSheet.kt @@ -0,0 +1,209 @@ +package com.keylesspalace.tusky.view + +import android.annotation.SuppressLint +import android.content.Context +import android.content.SharedPreferences +import android.content.res.ColorStateList +import android.graphics.drawable.RippleDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Filter +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.toDrawable +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import com.google.android.material.R as materialR +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.color.MaterialColors +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.BottomsheetConfirmationBinding +import com.keylesspalace.tusky.databinding.ItemReblogOptionBinding +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.getSerializableCompat +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class ConfirmationBottomSheet : BottomSheetDialogFragment(R.layout.bottomsheet_confirmation) { + + @Inject + lateinit var prefs: SharedPreferences + + private val binding by viewBinding(BottomsheetConfirmationBinding::bind) + + private var selectedOption = Status.Visibility.PUBLIC + + @SuppressLint("UseCompatTextViewDrawableApis") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val mode: Mode = requireArguments().getSerializableCompat(ARG_MODE)!! + if (mode == Mode.REBLOG) { + selectedOption = Status.Visibility.valueOf(prefs.getNonNullString(PrefKeys.REBLOG_PRIVACY, Status.Visibility.PUBLIC.name)) + + binding.confirmTextView.setText(R.string.reblog_confirm) + binding.confirmTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_repeat_24dp, 0, 0, 0) + binding.confirmTextView.compoundDrawableTintList = ColorStateList.valueOf( + MaterialColors.getColor(binding.confirmTextView, materialR.attr.colorPrimary) + ) + + binding.confirmButton.setText(R.string.action_reblog) + + binding.confirmButton.setOnClickListener { + prefs.edit { + putString(PrefKeys.REBLOG_PRIVACY, selectedOption.name) + } + setFragmentResult(KEY_CONFIRM, bundleOf(RESULT_VISIBILITY to selectedOption.name)) + dismiss() + } + + binding.reblogPrivacyDropdown.setAdapter(OptionsAdapter(view.context)) + + binding.reblogPrivacyLayout.setStartIconDrawable(selectedOption.getIcon()) + binding.reblogPrivacyDropdown.setText(selectedOption.getName()) + + binding.reblogPrivacyDropdown.setOnItemClickListener { _, _, position, _ -> + selectedOption = reblogOptions.getOrElse(position) { Status.Visibility.PUBLIC } + binding.reblogPrivacyLayout.setStartIconDrawable(selectedOption.getIcon()) + binding.reblogPrivacyDropdown.setText(selectedOption.getName()) + } + } else { + binding.confirmTextView.setText(R.string.favourite_confirm) + binding.confirmTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_star_24dp, 0, 0, 0) + binding.confirmTextView.compoundDrawableTintList = ColorStateList.valueOf( + requireContext().getColor(R.color.favoriteButtonActiveColor) + ) + + binding.reblogPrivacyLayout.hide() + + binding.confirmButton.setText(R.string.action_favourite) + + binding.confirmButton.setOnClickListener { + setFragmentResult(KEY_CONFIRM, bundleOf()) + dismiss() + } + } + binding.cancelButton.setOnClickListener { + dismiss() + } + } + + inner class OptionsAdapter(context: Context) : ArrayAdapter(context, R.layout.item_reblog_option, reblogOptions) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val item = getItem(position) + val view: View = convertView ?: run { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ItemReblogOptionBinding.inflate(layoutInflater) + + binding.reblogOptionName.setText(item.getName()) + binding.reblogOptionDescription.setText(item.getDescription()) + binding.reblogOptionIcon.setImageResource(item.getIcon()) + binding.root + } + if (item == selectedOption) { + // using the same color as MaterialAutoCompleteTextView.MaterialArrayAdapter which is not public unfortunately + val overlayColor = ColorUtils.setAlphaComponent( + MaterialColors.getColor(view, materialR.attr.colorOnSurface), + 30 + ) + view.background = RippleDrawable( + ColorStateList.valueOf(overlayColor), + MaterialColors.getColor(view, materialR.attr.colorSecondaryContainer).toDrawable(), + null + ) + } else { + view.background = null + } + return view + } + + override fun getFilter() = object : Filter() { + override fun performFiltering(constraint: CharSequence) = FilterResults().apply { count = 3 } + + override fun publishResults(constraint: CharSequence, results: FilterResults) { + // noop + } + } + } + + enum class Mode { + REBLOG, + FAVOURITE + } + + companion object { + private const val TAG = "ConfirmationBottomSheet" + + private const val KEY_CONFIRM = "confirm" + private const val ARG_MODE = "mode" + private const val RESULT_VISIBILITY = "visibility" + + private val reblogOptions = listOf(Status.Visibility.PUBLIC, Status.Visibility.UNLISTED, Status.Visibility.PRIVATE) + + fun Fragment.confirmReblog(preferences: SharedPreferences, onConfirmed: (Status.Visibility) -> Unit) { + if (preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true)) { + val bottomSheet = ConfirmationBottomSheet() + bottomSheet.arguments = bundleOf( + ARG_MODE to Mode.REBLOG + ) + bottomSheet.show(childFragmentManager, TAG) + childFragmentManager.setFragmentResultListener(KEY_CONFIRM, this) { requestKey, result -> + onConfirmed(Status.Visibility.valueOf(result.getString(RESULT_VISIBILITY)!!)) + } + } else { + onConfirmed(Status.Visibility.PUBLIC) + } + } + + fun Fragment.confirmFavourite(preferences: SharedPreferences, onConfirmed: () -> Unit) { + if (preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, true)) { + val bottomSheet = ConfirmationBottomSheet() + bottomSheet.arguments = bundleOf( + ARG_MODE to Mode.FAVOURITE + ) + bottomSheet.show(childFragmentManager, TAG) + childFragmentManager.setFragmentResultListener(KEY_CONFIRM, this) { _, _ -> + onConfirmed() + } + } else { + onConfirmed() + } + } + + @StringRes + private fun Status.Visibility?.getName(): Int { + return when (this) { + Status.Visibility.PUBLIC -> R.string.post_privacy_public + Status.Visibility.UNLISTED -> R.string.post_privacy_unlisted + else -> R.string.post_privacy_followers_only + } + } + + @StringRes + private fun Status.Visibility?.getDescription(): Int { + return when (this) { + Status.Visibility.PUBLIC -> R.string.reblog_privacy_public_description + Status.Visibility.UNLISTED -> R.string.reblog_privacy_unlisted_description + else -> R.string.reblog_privacy_followers_only_description + } + } + + @DrawableRes + private fun Status.Visibility?.getIcon(): Int { + return when (this) { + Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp + Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp + else -> R.drawable.ic_lock_24dp + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt b/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt deleted file mode 100644 index c240adf90..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/EndlessOnScrollListener.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ -package com.keylesspalace.tusky.view - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView - -abstract class EndlessOnScrollListener(private val layoutManager: LinearLayoutManager) : - RecyclerView.OnScrollListener() { - private var previousTotalItemCount = 0 - - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val totalItemCount = layoutManager.itemCount - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - - if (totalItemCount < previousTotalItemCount) { - previousTotalItemCount = totalItemCount - } - if (totalItemCount != previousTotalItemCount) { - previousTotalItemCount = totalItemCount - } - if (lastVisibleItemPosition + VISIBLE_THRESHOLD > totalItemCount) { - onLoadMore(totalItemCount, view) - } - } - - fun reset() { - previousTotalItemCount = 0 - } - - abstract fun onLoadMore(totalItemsCount: Int, view: RecyclerView) - - companion object { - private const val VISIBLE_THRESHOLD = 15 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt index dab8716fb..8924eb91c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -21,12 +21,12 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.PathMeasure import android.graphics.Rect -import android.text.TextUtils import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.core.content.res.use +import androidx.core.text.layoutDirection import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R @@ -103,7 +103,7 @@ class GraphView @JvmOverloads constructor( init { initFromXML(attrs) - isRtlLayout = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL + isRtlLayout = Locale.getDefault().layoutDirection == LAYOUT_DIRECTION_RTL } private fun initFromXML(attr: AttributeSet?) { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt index ed53bf603..873e20653 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -25,7 +25,7 @@ sealed class NotificationViewData { abstract val id: String abstract fun asStatusOrNull(): StatusViewData.Concrete? - abstract fun asPlaceholderOrNull(): Placeholder? + abstract fun asPlaceholderOrNull(): LoadMore? data class Concrete( override val id: String, @@ -41,7 +41,7 @@ sealed class NotificationViewData { override fun asPlaceholderOrNull() = null } - data class Placeholder( + data class LoadMore( override val id: String, val isLoading: Boolean ) : NotificationViewData() { diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index 07d95982e..395ba8222 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -16,9 +16,15 @@ package com.keylesspalace.tusky.viewdata import android.content.Context +import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.DynamicDrawableSpan +import android.text.style.ImageSpan +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.core.text.parseAsHtml +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption @@ -52,13 +58,21 @@ fun calculatePercent(fraction: Int?, totalVoters: Int?, totalVotes: Int): Int { } } -fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context): Spanned { +fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context, textView: TextView? = null): Spanned { val builder = SpannableStringBuilder( context.getString(R.string.poll_percent_format, percent).parseAsHtml() ) if (voted) { builder.append(" ✓ ") + + if (textView != null) { + val size = (textView.textSize * 1.1).toInt() + val drawable = AppCompatResources.getDrawable(context, R.drawable.ic_check_circle_24dp)!! + drawable.setBounds(0, 0, size, size) + drawable.setTint(MaterialColors.getColor(textView, android.R.attr.textColorPrimary)) + builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_CENTER), builder.length - 2, builder.length - 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } } else { builder.append(" ") } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 597832003..eb16ebc98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -38,11 +38,11 @@ sealed interface TranslationViewData { * Created by charlag on 11/07/2017. * * Class to represent data required to display either a notification or a placeholder. - * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. + * It is either a [StatusViewData.Concrete] or a [StatusViewData.LoadMore]. */ sealed class StatusViewData { abstract val id: String - var filterAction: Filter.Action = Filter.Action.NONE + var filter: Filter? = null data class Concrete( val status: Status, @@ -133,12 +133,12 @@ sealed class StatusViewData { } } - data class Placeholder( + data class LoadMore( override val id: String, val isLoading: Boolean ) : StatusViewData() fun asStatusOrNull() = this as? Concrete - fun asPlaceholderOrNull() = this as? Placeholder + fun asPlaceholderOrNull() = this as? LoadMore } diff --git a/app/src/main/res/anim/explode.xml b/app/src/main/res/anim/explode.xml deleted file mode 100644 index 08001aef5..000000000 --- a/app/src/main/res/anim/explode.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_check_circle_24dp.xml b/app/src/main/res/drawable/ic_check_circle_24dp.xml new file mode 100644 index 000000000..728be3ba6 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/text_placeholder.xml b/app/src/main/res/drawable/text_placeholder.xml new file mode 100644 index 000000000..9ab36c7f0 --- /dev/null +++ b/app/src/main/res/drawable/text_placeholder.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 0ef8377f5..894a3c051 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -26,7 +26,7 @@ android:background="?android:attr/colorBackground" android:clipToPadding="false" android:paddingBottom="@dimen/recyclerview_bottom_padding_no_actionbutton" - android:scrollbarStyle="outsideInset" + android:scrollbarStyle="outsideOverlay" android:scrollbars="vertical" /> diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml index 08fed6c7a..7ea25b3e4 100644 --- a/app/src/main/res/layout/activity_edit_filter.xml +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -81,6 +81,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + + + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> + android:layout_gravity="center" + android:indeterminate="true" /> + + - - + + + + + + + + + + + + +