diff --git a/Release.md b/Release.md new file mode 100644 index 000000000..230a4024a --- /dev/null +++ b/Release.md @@ -0,0 +1,47 @@ +# Releasing Tusky + +Before each major release, make a beta for at least a week to make sure the release is well tested before being released for everybody. Minor releases can skip beta. + +This approach of having ~500 user on the nightly releases and ~5000 users on the beta releases has so far worked very well and helped to fix bugs before they could reach most users. + +## Beta + +- Make sure all new features are well tested by Nightly users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, emails on `tusky@connyduck.at`, #Tusky hashtag. +- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub) +- Check all the translations (Android Studio shows warnings on problems). Sometimes translators add faulty translations that would crash Tusky in this language, e.g. wrong number of formatting parameters. In this case it is usually easiest to just delete the string. [Example cleanup](https://github.com/tuskyapp/Tusky/commit/feaea70af418c77178985144a2d01a8e97725dfd). +- Update `versionCode` and `versionName` in `app/build.gradle` +- Add a new short changelog under `fastlane/metadata/android/en-US/changelogs`. Use the next versionCode as the filename. This is so translators on Weblate have the duration of the beta to translate the changelog and F-Droid users will see it in their language on the release. If another beta is released, the changelogs have to be renamed. Note that changelogs shouldn't be over 500 characters or F-Droid will truncate them. +- Build the app as apk and as app bundle. +- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct. +- Merge `develop` into `main` +- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). + - Tag the head of `main`. + - Create an exhaustive changelog by going through all commits since the last release. + - Attach the apk, adb and mapping.txt files to the release + - Mark the release as being a pre-release. +- Create a merge request at F-Droid. [Example](https://gitlab.com/fdroid/fdroiddata/-/merge_requests/11218) (F-Droid automatically picks up new release tags, but not beta ones. This could probably be changed somehow.) +- Upload the release to the Open Testing track on Google Play. +- Announce the release + +## Full release + +- Make sure all new features are well tested by beta users and all issues addressed as good as possible. Check GitHub issues, Google Play crash reports, messages on `@Tusky@mastodon.social`, #Tusky hashtag. +- Merge the latest Weblate translations (Weblate -> Repository maintenance -> commit all changes, then merge the automatic PRs by @nailyk-weblate on GitHub) +- Update `versionCode` and `versionName` in `app/build.gradle` +- Build the app as apk and as app bundle. +- Do a quick check to make sure the build doesn't crash. Also install it over the last release to make sure the database migrations are correct. +- Merge `develop` into `main` +- Create a new [GitHub release](https://github.com/tuskyapp/Tusky/releases). + - Tag the head of `main`. + - Resuse the changelog from the beta release, or create a new one if this is only a minor release. + - Attach the apk, adb and mapping.txt files to the release +- (F-Droid will automatically detect and build the release) +- Upload the release to the Production track on Google Play. +- update the download link on the homepage ([repo](https://github.com/tuskyapp/tuskyapp.github.io)) +- Announce the release + +## Versioning + +Since Tusky is user facing software that has no Api, we don't use semantic versioning. Tusky verion numbers only consist of two numbers major.minor with optional commit hash (nightly/test releases) or beta flag (beta releases). +- User visible changes in the release -> new major version +- Only bugfixes, new translations, refactorings or performance improvements in the release -> new minor version diff --git a/app/build.gradle b/app/build.gradle index 256f8a021..5af881d6f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,8 +22,9 @@ android { compileSdkVersion 33 defaultConfig { applicationId 'net.accelf.yuito' - minSdkVersion 21 - targetSdkVersion 31 + namespace 'com.keylesspalace.tusky' + minSdkVersion 23 + targetSdkVersion 33 versionCode 52 versionName '4.4.2' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -78,6 +79,9 @@ android { returnDefaultValues = true includeAndroidResources = true } + unitTests.all { + systemProperty 'robolectric.logging.enabled', 'true' + } } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) @@ -94,8 +98,9 @@ android { enableSplit = false } } - kotlinOptions { - freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + dependenciesInfo { + includeInApk false + includeInBundle false } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 757c97c77..40c6585fc 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -125,3 +125,7 @@ static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, 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 diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json new file mode 100644 index 000000000..9d8a0ed71 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json @@ -0,0 +1,989 @@ +{ + "formatVersion": 1, + "database": { + "version": 46, + "identityHash": "583d1808a21973aa9d2628009a7444a7", + "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, `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": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "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, '583d1808a21973aa9d2628009a7444a7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 000f4a5b4..77f81aba4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools" > diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index cb262806a..ad173170a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -21,6 +21,7 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Color; import android.os.Bundle; import android.util.Log; import android.view.MenuItem; @@ -35,6 +36,7 @@ import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; +import com.google.android.material.color.MaterialColors; import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; import com.keylesspalace.tusky.components.login.LoginActivity; @@ -77,12 +79,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab /* set the taskdescription programmatically, the theme would turn it blue */ String appName = getString(R.string.app_name); Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = ThemeUtils.getColor(this, R.attr.colorSurface); + int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK); setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); int style = textStyle(preferences.getString("statusTextSize", "medium")); - getTheme().applyStyle(style, false); + getTheme().applyStyle(style, true); if(requiresLogin()) { redirectIfNotLoggedIn(); diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt index 3a61df90f..bbb5bc6a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -7,6 +7,7 @@ import android.widget.ArrayAdapter import android.widget.Toast import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityFiltersBinding @@ -19,7 +20,6 @@ import com.keylesspalace.tusky.view.getSecondsForDurationIndex import com.keylesspalace.tusky.view.setupEditDialogForFilter import com.keylesspalace.tusky.view.showAddFilterDialog import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import java.io.IOException import javax.inject.Inject @@ -150,12 +150,10 @@ class FiltersActivity : BaseActivity() { binding.filterProgressBar.show() lifecycleScope.launch { - val newFilters = try { - api.getFilters().await() - } catch (t: Exception) { + val newFilters = api.getFilters().getOrElse { binding.filterProgressBar.hide() binding.filterMessageView.show() - if (t is IOException) { + if (it is IOException) { binding.filterMessageView.setup( R.drawable.elephant_offline, R.string.error_network diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 5850e3210..436bef7a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -38,12 +38,12 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.show @@ -244,8 +244,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) .let(this::ListViewHolder) .apply { + val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary) val context = nameTextView.context - val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary) val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 2fe21d231..6c10b6dbb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -44,6 +44,7 @@ import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu +import androidx.appcompat.widget.ThemeUtils import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -61,6 +62,7 @@ import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition +import com.google.android.material.color.MaterialColors import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator @@ -97,11 +99,14 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ResettableFragment 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.ThemeUtils import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.reduceSwipeSensitivity +import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding @@ -159,6 +164,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Inject lateinit var logoutUsecase: LogoutUsecase + @Inject + lateinit var developerToolsUseCase: DeveloperToolsUseCase + @Inject lateinit var viewModelFactory: ViewModelFactory @@ -266,7 +274,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { sizeDp = 20 - colorInt = ThemeUtils.getColor(this@MainActivity, android.R.attr.textColorPrimary) + colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary) } setOnMenuItemClickListener { startActivity(SearchActivity.getIntent(this@MainActivity)) @@ -274,6 +282,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } + binding.viewPager.reduceSwipeSensitivity() + setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar) /* Fetch user info while we're doing other things. This has to be done after setting up the @@ -459,7 +469,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) - header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent)) + header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent)) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) DrawerImageLoader.init(object : AbstractDrawerImageLoader() { @@ -555,8 +565,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { - textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) - color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) + textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary)) } }, DividerDrawerItem(), @@ -613,11 +623,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } if (BuildConfig.DEBUG) { + // Add a "Developer tools" entry. Code that makes it easier to + // set the app state at runtime belongs here, it will never + // be exposed to users. binding.mainDrawer.addItems( + DividerDrawerItem(), secondaryDrawerItem { - nameText = "debug" - isEnabled = false - textColor = ColorStateList.valueOf(Color.GREEN) + nameText = "Developer tools" + isEnabled = true + iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode + onClick = { + buildDeveloperToolsDialog().show() + } } ) } @@ -626,11 +643,34 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje secondaryDrawerItem { nameText = "Yuito (by kyori19)" isEnabled = false - textColor = ColorStateList.valueOf(ThemeUtils.getColor(this@MainActivity, R.attr.colorInfo)) + textColor = ColorStateList.valueOf(MaterialColors.getColor(binding.mainDrawer, R.attr.colorInfo)) } ) } + private fun buildDeveloperToolsDialog(): AlertDialog { + return AlertDialog.Builder(this) + .setTitle("Developer Tools") + .setItems( + arrayOf("Create \"Load more\" gap") + ) { _, which -> + Log.d(TAG, "Developer tools: $which") + when (which) { + 0 -> { + Log.d(TAG, "Creating \"Load more\" gap") + lifecycleScope.launch { + accountManager.activeAccount?.let { + developerToolsUseCase.createLoadMoreGap( + it.id + ) + } + } + } + } + } + .create() + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } @@ -640,14 +680,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Suppress("DEPRECATION") item.icon?.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN) } else { - ThemeUtils.setDrawableTint(this, item.icon, android.R.attr.textColorTertiary) + setDrawableTint(this, item.icon!!, android.R.attr.textColorTertiary) } } - private fun setupTabs(selectNotificationTab: Boolean): ArrayList { - + private fun setupTabs(selectNotificationTab: Boolean) { val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { - val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize) + val actionBarSize = getDimension(this, R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin binding.topNav.hide() @@ -713,7 +752,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } } - ThemeUtils.setDrawableTint(this, item.icon, android.R.attr.textColorPrimary) + setDrawableTint(this, item.icon!!, android.R.attr.textColorPrimary) } } tintCheckIcon(menuBuilder.findItem(R.id.tabToggleStreaming)) @@ -820,7 +859,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje updateProfiles() keepScreenOn() - return popups } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 0ff2f3997..2b8e854ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -190,25 +190,30 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { muteTagItem?.isEnabled = false unmuteTagItem?.isVisible = false - mastodonApi.getFilters().observeOn(AndroidSchedulers.mainThread()) - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe { filters -> - for (filter in filters) { - if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { - Log.d(TAG, "Tag $hashtag is filtered") - muteTagItem?.isVisible = false - unmuteTagItem?.isVisible = true - mutedFilter = filter - return@subscribe + lifecycleScope.launch { + mastodonApi.getFilters().fold( + { filters -> + for (filter in filters) { + if ((tag == filter.phrase) and filter.context.contains(Filter.HOME)) { + Log.d(TAG, "Tag $hashtag is filtered") + muteTagItem?.isVisible = false + unmuteTagItem?.isVisible = true + mutedFilter = filter + return@fold + } } - } - Log.d(TAG, "Tag $hashtag is not filtered") - mutedFilter = null - muteTagItem?.isEnabled = true - muteTagItem?.isVisible = true - muteTagItem?.isVisible = true - } + Log.d(TAG, "Tag $hashtag is not filtered") + mutedFilter = null + muteTagItem?.isEnabled = true + muteTagItem?.isVisible = true + muteTagItem?.isVisible = true + }, + { throwable -> + Log.e(TAG, "Error getting filters: $throwable") + } + ) + } } private fun muteTag(): Boolean { diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 5401b5931..4c7aeca9d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -22,8 +22,9 @@ import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager -import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.setAppNightMode import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_defaults.DefaultEmojiPackList @@ -72,8 +73,8 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) - ThemeUtils.setAppNightMode(theme) + val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + setAppNightMode(theme) localeManager.setLocale() diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt index 366dae7f9..bbd83df32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt @@ -26,7 +26,8 @@ import com.keylesspalace.tusky.util.removeDuplicates abstract class AccountAdapter internal constructor( var accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, - protected val animateEmojis: Boolean + protected val animateEmojis: Boolean, + protected val showBotOverlay: Boolean ) : RecyclerView.Adapter() { var accountList = mutableListOf() private var bottomLoading: Boolean = false diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java deleted file mode 100644 index 6672fff39..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.keylesspalace.tusky.adapter; - -import android.content.SharedPreferences; -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; - -public class AccountViewHolder extends RecyclerView.ViewHolder { - private TextView username; - private TextView displayName; - private ImageView avatar; - private ImageView avatarInset; - private String accountId; - private boolean showBotOverlay; - - public AccountViewHolder(View itemView) { - super(itemView); - username = itemView.findViewById(R.id.account_username); - displayName = itemView.findViewById(R.id.account_display_name); - avatar = itemView.findViewById(R.id.account_avatar); - avatarInset = itemView.findViewById(R.id.account_avatar_inset); - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()); - showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); - } - - public void setupWithAccount(TimelineAccount account, boolean animateAvatar, boolean animateEmojis) { - accountId = account.getId(); - String format = username.getContext().getString(R.string.post_username_format); - String formattedUsername = String.format(format, account.getUsername()); - username.setText(formattedUsername); - CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis); - displayName.setText(emojifiedName); - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_48dp); - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); - if (showBotOverlay && account.getBot()) { - avatarInset.setVisibility(View.VISIBLE); - avatarInset.setImageResource(R.drawable.bot_badge); - } else { - avatarInset.setVisibility(View.GONE); - } - } - - void setupActionListener(final AccountActionListener listener) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } - - public void setupLinkListener(final LinkListener listener) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } -} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.kt new file mode 100644 index 000000000..f125422c3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.kt @@ -0,0 +1,56 @@ +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAccountBinding +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible + +class AccountViewHolder( + private val binding: ItemAccountBinding +) : RecyclerView.ViewHolder(binding.root) { + private lateinit var accountId: String + + fun setupWithAccount( + account: TimelineAccount, + animateAvatar: Boolean, + animateEmojis: Boolean, + showBotOverlay: Boolean + ) { + accountId = account.id + + binding.accountUsername.text = binding.accountUsername.context.getString( + R.string.post_username_format, + account.username + ) + + val emojifiedName = account.name.emojify( + account.emojis, + binding.accountDisplayName, + animateEmojis + ) + binding.accountDisplayName.text = emojifiedName + + val avatarRadius = binding.accountAvatar.context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.accountAvatar, avatarRadius, animateAvatar) + + binding.accountBotBadge.visible(showBotOverlay && account.bot) + } + + fun setupActionListener(listener: AccountActionListener) { + itemView.setOnClickListener { listener.onViewAccount(accountId) } + } + + fun setupLinkListener(listener: LinkListener) { + itemView.setOnClickListener { + listener.onViewAccount( + accountId + ) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt index 859807e97..c4da2ab8c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt @@ -31,11 +31,13 @@ import com.keylesspalace.tusky.util.loadAvatar class BlocksAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, - animateEmojis: Boolean + animateEmojis: Boolean, + showBotOverlay: Boolean, ) : AccountAdapter( accountActionListener, animateAvatar, - animateEmojis + animateEmojis, + showBotOverlay ) { override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder { val view = LayoutInflater.from(parent.context) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index d3412e5b1..51aa43f73 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -27,7 +27,8 @@ import java.util.Locale class EmojiAdapter( emojiList: List, - private val onEmojiSelectedListener: OnEmojiSelectedListener + private val onEmojiSelectedListener: OnEmojiSelectedListener, + private val animate: Boolean ) : RecyclerView.Adapter>() { private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } @@ -44,9 +45,16 @@ class EmojiAdapter( val emoji = emojiList[position] val emojiImageView = holder.binding.root - Glide.with(emojiImageView) - .load(emoji.url) - .into(emojiImageView) + if (animate) { + Glide.with(emojiImageView) + .load(emoji.url) + .into(emojiImageView) + } else { + Glide.with(emojiImageView) + .asBitmap() + .load(emoji.url) + .into(emojiImageView) + } emojiImageView.setOnClickListener { onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt index 672f1fcac..5c5463056 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt @@ -16,23 +16,37 @@ package com.keylesspalace.tusky.adapter import android.view.LayoutInflater import android.view.ViewGroup -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.interfaces.AccountActionListener /** Displays either a follows or following list. */ class FollowAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, - animateEmojis: Boolean -) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis) { + animateEmojis: Boolean, + showBotOverlay: Boolean +) : AccountAdapter( + accountActionListener, + animateAvatar, + animateEmojis, + showBotOverlay +) { override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_account, parent, false) - return AccountViewHolder(view) + 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) + viewHolder.setupWithAccount( + accountList[position], + animateAvatar, + animateEmojis, + showBotOverlay + ) viewHolder.setupActionListener(accountActionListener) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 38b301b3a..60770ddad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -34,7 +34,12 @@ class FollowRequestViewHolder( private val showHeader: Boolean ) : RecyclerView.ViewHolder(binding.root) { - fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) { + fun setupWithAccount( + account: TimelineAccount, + animateAvatar: Boolean, + animateEmojis: Boolean, + showBotOverlay: Boolean + ) { val wrappedName = account.name.unicodeWrap() val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) binding.displayNameTextView.text = emojifiedName diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt index 9b0a5dd90..95d944bda 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt @@ -23,8 +23,9 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener class FollowRequestsAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, - animateEmojis: Boolean -) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis) { + animateEmojis: Boolean, + showBotOverlay: Boolean +) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) { override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { val binding = ItemFollowRequestBinding.inflate( LayoutInflater.from(parent.context), parent, false @@ -33,7 +34,7 @@ class FollowRequestsAdapter( } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) + viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay) viewHolder.setupActionListener(accountActionListener, accountList[position].id) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt index ef5edd1d4..daf8381e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt @@ -21,7 +21,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView -import com.keylesspalace.tusky.util.ThemeUtils +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.modernLanguageCode import java.util.Locale @@ -29,7 +29,7 @@ import java.util.Locale class LocaleAdapter(context: Context, resource: Int, locales: List) : ArrayAdapter(context, resource, locales) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getView(position, convertView, parent) as TextView).apply { - setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) + setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary)) typeface = Typeface.DEFAULT_BOLD text = super.getItem(position)?.modernLanguageCode?.uppercase() } @@ -37,7 +37,7 @@ class LocaleAdapter(context: Context, resource: Int, locales: List) : Ar override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getDropDownView(position, convertView, parent) as TextView).apply { - setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) + setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary)) text = super.getItem(position)?.getTuskyDisplayName(context) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt index 41bb286f5..42e19c659 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt @@ -1,19 +1,14 @@ package com.keylesspalace.tusky.adapter import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.ViewCompat -import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.databinding.ItemMutedUserBinding import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar -import java.util.HashMap /** * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications @@ -22,29 +17,68 @@ import java.util.HashMap class MutesAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, - animateEmojis: Boolean -) : AccountAdapter( + animateEmojis: Boolean, + showBotOverlay: Boolean +) : AccountAdapter>( accountActionListener, animateAvatar, - animateEmojis + animateEmojis, + showBotOverlay ) { private val mutingNotificationsMap = HashMap() - override fun createAccountViewHolder(parent: ViewGroup): MutedUserViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_muted_user, parent, false) - return MutedUserViewHolder(view) + override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { + val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } - override fun onBindAccountViewHolder(viewHolder: MutedUserViewHolder, position: Int) { + override fun onBindAccountViewHolder(viewHolder: BindingHolder, position: Int) { val account = accountList[position] - viewHolder.setupWithAccount( - account, - mutingNotificationsMap[account.id], - animateAvatar, - animateEmojis - ) - viewHolder.setupActionListener(accountActionListener) + 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) + + 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 = 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 + ) + } + binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) } } fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) { @@ -52,81 +86,8 @@ class MutesAdapter( notifyItemChanged(position) } - fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap?) { - mutingNotificationsMap.putAll(newMutingNotificationsMap!!) + fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap) { + mutingNotificationsMap.putAll(newMutingNotificationsMap) notifyDataSetChanged() } - - class MutedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val avatar: ImageView = itemView.findViewById(R.id.muted_user_avatar) - private val username: TextView = itemView.findViewById(R.id.muted_user_username) - private val displayName: TextView = itemView.findViewById(R.id.muted_user_display_name) - private val unmute: ImageButton = itemView.findViewById(R.id.muted_user_unmute) - private val muteNotifications: ImageButton = - itemView.findViewById(R.id.muted_user_mute_notifications) - - private var id: String? = null - private var notifications = false - - fun setupWithAccount( - account: TimelineAccount, - mutingNotifications: Boolean?, - animateAvatar: Boolean, - animateEmojis: Boolean - ) { - id = account.id - val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) - displayName.text = emojifiedName - val format = username.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) - username.text = formattedUsername - val avatarRadius = avatar.context.resources - .getDimensionPixelSize(R.dimen.avatar_radius_48dp) - loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar) - val unmuteString = - unmute.context.getString(R.string.action_unmute_desc, formattedUsername) - unmute.contentDescription = unmuteString - ViewCompat.setTooltipText(unmute, unmuteString) - if (mutingNotifications == null) { - muteNotifications.isEnabled = false - notifications = true - } else { - muteNotifications.isEnabled = true - notifications = mutingNotifications - } - if (notifications) { - muteNotifications.setImageResource(R.drawable.ic_notifications_24dp) - val unmuteNotificationsString = muteNotifications.context - .getString(R.string.action_unmute_notifications_desc, formattedUsername) - muteNotifications.contentDescription = unmuteNotificationsString - ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString) - } else { - muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp) - val muteNotificationsString = muteNotifications.context - .getString(R.string.action_mute_notifications_desc, formattedUsername) - muteNotifications.contentDescription = muteNotificationsString - ViewCompat.setTooltipText(muteNotifications, muteNotificationsString) - } - } - - fun setupActionListener(listener: AccountActionListener) { - unmute.setOnClickListener { - listener.onMute( - false, - id, - bindingAdapterPosition, - false - ) - } - muteNotifications.setOnClickListener { - listener.onMute( - true, - id, - bindingAdapterPosition, - !notifications - ) - } - itemView.setOnClickListener { listener.onViewAccount(id) } - } - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 47ef253c3..f9a5120a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -257,7 +257,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_FOLLOW_REQUEST: { if (payloadForHolder == null) { FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); + holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); } break; @@ -442,7 +442,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { statusNameBar = itemView.findViewById(R.id.status_name_bar); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + timestampInfo = itemView.findViewById(R.id.status_meta_info); statusContent = itemView.findViewById(R.id.notification_content); statusAvatar = itemView.findViewById(R.id.notification_status_avatar); notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); 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 e80e3746d..c277ea385 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt @@ -15,26 +15,52 @@ package com.keylesspalace.tusky.adapter import android.view.View -import android.widget.Button -import android.widget.ProgressBar import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.progressindicator.CircularProgressIndicatorSpec +import com.google.android.material.progressindicator.IndeterminateDrawable import com.keylesspalace.tusky.R import com.keylesspalace.tusky.interfaces.StatusActionListener /** * Placeholder for different timelines. - * Either displays "load more" button or a progress indicator. - **/ + * + * Displays a "Load more" button for a particular status ID, or a + * circular progress wheel if the status' page is being loaded. + * + * The user can only have one "Load more" operation in progress at + * a time (determined by the adapter), so the contents of the view + * and the enabled state is driven by that. + */ class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val loadMoreButton: Button = itemView.findViewById(R.id.button_load_more) - private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar) + private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more) + private val drawable = IndeterminateDrawable.createCircularDrawable( + itemView.context, + CircularProgressIndicatorSpec(itemView.context, null) + ) - fun setup(listener: StatusActionListener, progress: Boolean) { - loadMoreButton.visibility = if (progress) View.GONE else View.VISIBLE - progressBar.visibility = if (progress) View.VISIBLE else View.GONE - loadMoreButton.isEnabled = true - loadMoreButton.setOnClickListener { v: View? -> + fun setup(listener: StatusActionListener, loading: Boolean) { + itemView.isEnabled = !loading + loadMoreButton.isEnabled = !loading + + if (loading) { + loadMoreButton.text = "" + loadMoreButton.icon = drawable + return + } + + loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text) + loadMoreButton.icon = null + + // To allow the user to click anywhere in the layout to load more content set the click + // listener on the parent layout instead of loadMoreButton. + // + // See the comments in item_status_placeholder.xml for more details. + itemView.setOnClickListener { + itemView.isEnabled = false loadMoreButton.isEnabled = false + loadMoreButton.icon = drawable + loadMoreButton.text = "" listener.onLoadMore(bindingAdapterPosition) } } 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 6d70d0e1c..596c9432d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -38,7 +38,9 @@ class PollAdapter : RecyclerView.Adapter>() { private var emojis: List = emptyList() private var resultClickListener: View.OnClickListener? = null private var animateEmojis = false + private var enabled = true + @JvmOverloads fun setup( options: List, voteCount: Int, @@ -46,7 +48,8 @@ class PollAdapter : RecyclerView.Adapter>() { emojis: List, mode: Int, resultClickListener: View.OnClickListener?, - animateEmojis: Boolean + animateEmojis: Boolean, + enabled: Boolean = true ) { this.pollOptions = options this.voteCount = voteCount @@ -55,6 +58,7 @@ class PollAdapter : RecyclerView.Adapter>() { this.mode = mode this.resultClickListener = resultClickListener this.animateEmojis = animateEmojis + this.enabled = enabled notifyDataSetChanged() } @@ -82,6 +86,9 @@ class PollAdapter : RecyclerView.Adapter>() { radioButton.visible(mode == SINGLE) checkBox.visible(mode == MULTIPLE) + radioButton.isEnabled = enabled + checkBox.isEnabled = enabled + when (mode) { RESULT -> { val percent = calculatePercent(option.votesCount, votersCount, voteCount) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index 0155f4a44..db2f79a99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -24,8 +24,8 @@ import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionLi import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.TimestampUtils import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap import java.util.Date @@ -41,7 +41,7 @@ class ReportNotificationViewHolder( binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, TimestampUtils.getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset 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 f69e9fc9c..aca431f26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -6,9 +6,13 @@ import android.content.Context; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.format.DateUtils; +import android.text.style.DynamicDrawableSpan; +import android.text.style.ImageSpan; import android.view.View; import android.view.ViewGroup; import android.widget.Button; @@ -22,6 +26,7 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; @@ -33,6 +38,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; import com.google.android.material.button.MaterialButton; +import com.google.android.material.color.MaterialColors; import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; @@ -54,8 +60,8 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.util.TouchDelegateHelper; import com.keylesspalace.tusky.view.MediaPreviewImageView; import com.keylesspalace.tusky.view.MediaPreviewLayout; import com.keylesspalace.tusky.viewdata.PollOptionViewData; @@ -97,7 +103,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private ImageView avatarInset; public ImageView avatar; - public TextView timestampInfo; + public TextView metaInfo; public TextView content; public TextView contentWarningDescription; @@ -128,7 +134,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_timestamp_info); + metaInfo = itemView.findViewById(R.id.status_meta_info); content = itemView.findViewById(R.id.status_content); avatar = itemView.findViewById(R.id.status_avatar); replyButton = itemView.findViewById(R.id.status_reply); @@ -178,7 +184,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - mediaPreviewUnloaded = new ColorDrawable(ThemeUtils.getColor(itemView.getContext(), R.attr.colorBackgroundAccent)); + mediaPreviewUnloaded = new ColorDrawable(MaterialColors.getColor(itemView, R.attr.colorBackgroundAccent)); + + TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); } protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { @@ -316,7 +324,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } - protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { + protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + + Status status = statusViewData.getActionable(); + + Status.Visibility visibility = status.getVisibility(); + Context context = metaInfo.getContext(); + + Drawable visibilityIcon = getVisibilityIcon(visibility); + CharSequence visibilityString = getVisibilityDescription(context, visibility); + + SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString); + + if (visibilityIcon != null) { + ImageSpan visibilityIconSpan = new ImageSpan( + visibilityIcon, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : ImageSpan.ALIGN_BASELINE + ); + sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + Date createdAt = status.getCreatedAt(); + Date editedAt = status.getEditedAt(); + String timestampText; if (statusDisplayOptions.useAbsoluteTime()) { timestampText = absoluteTimeFormatter.format(createdAt, true); @@ -326,15 +356,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); - String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); timestampText = readout; } } if (editedAt != null) { - timestampText = timestampInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); + timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); } - timestampInfo.setText(timestampText); + + sb.append(timestampText); + metaInfo.setText(sb); } private CharSequence getCreatedAtDescription(Date createdAt, @@ -357,10 +389,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setStatusVisibility(Status.Visibility visibility) { - + @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) { if (visibility == null) { - return; + return null; } int visibilityIcon; @@ -381,29 +412,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { visibilityIcon = R.drawable.ic_email_24dp; break; default: - return; + return null; } - final Drawable visibilityDrawable = this.timestampInfo.getContext() - .getDrawable(visibilityIcon); + final Drawable visibilityDrawable = AppCompatResources.getDrawable( + this.metaInfo.getContext(), visibilityIcon + ); if (visibilityDrawable == null) { - return; + return null; } - final int size = (int) this.timestampInfo.getTextSize(); + final int size = (int) this.metaInfo.getTextSize(); visibilityDrawable.setBounds( 0, 0, size, size ); - visibilityDrawable.setTint(this.timestampInfo.getCurrentTextColor()); - this.timestampInfo.setCompoundDrawables( - visibilityDrawable, - null, - null, - null - ); + visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor()); + + return visibilityDrawable; } protected void setIsReply(boolean isReply) { @@ -496,57 +524,58 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; - ViewKt.doOnLayout(imageView, view -> { - if (TextUtils.isEmpty(previewUrl)) { + if (TextUtils.isEmpty(previewUrl)) { + imageView.removeFocalPoint(); + + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView); + } else { + Focus focus = meta != null ? meta.getFocus() : null; + + if (focus != null) { // If there is a focal point for this attachment: + imageView.setFocalPoint(focus); + + Glide.with(imageView.getContext()) + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView); + } else { imageView.removeFocalPoint(); Glide.with(imageView) - .load(placeholder) + .load(previewUrl) + .placeholder(placeholder) .centerInside() .into(imageView); - - } else { - Focus focus = meta != null ? meta.getFocus() : null; - - if (focus != null) { // If there is a focal point for this attachment: - imageView.setFocalPoint(focus); - - Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .addListener(imageView) - .into(imageView); - } else { - imageView.removeFocalPoint(); - - Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .into(imageView); - } } - return null; - }); + } } - protected void setMediaPreviews(final List attachments, boolean sensitive, - final StatusActionListener listener, boolean showingContent, - boolean useBlurhash) { + protected void setMediaPreviews( + final List attachments, + boolean sensitive, + final StatusActionListener listener, + boolean showingContent, + boolean useBlurhash + ) { + mediaPreview.setVisibility(View.VISIBLE); mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments)); - mediaPreview.forEachIndexed((i, imageView) -> { + mediaPreview.forEachIndexed((i, imageView, descriptionIndicator) -> { Attachment attachment = attachments.get(i); String previewUrl = attachment.getPreviewUrl(); String description = attachment.getDescription(); + boolean hasDescription = !TextUtils.isEmpty(description); - if (TextUtils.isEmpty(description)) { - imageView.setContentDescription(imageView.getContext() - .getString(R.string.action_view_media)); - } else { + if (hasDescription) { imageView.setContentDescription(description); + } else { + imageView.setContentDescription(imageView.getContext().getString(R.string.action_view_media)); } loadImage( @@ -573,12 +602,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); + + descriptionIndicator.setVisibility(hasDescription && showingContent ? View.VISIBLE : View.GONE); + sensitiveMediaShow.setOnClickListener(v -> { if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { listener.onContentHiddenChange(false, getBindingAdapterPosition()); } v.setVisibility(View.GONE); sensitiveMediaWarning.setVisibility(View.VISIBLE); + descriptionIndicator.setVisibility(View.GONE); }); sensitiveMediaWarning.setOnClickListener(v -> { if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { @@ -586,6 +619,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } v.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.VISIBLE); + descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE); }); return null; @@ -813,8 +847,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(status.getUsername()); - setCreatedAt(actionable.getCreatedAt(), actionable.getEditedAt(), statusDisplayOptions); - setStatusVisibility(actionable.getVisibility()); + setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); setReplyCount(actionable.getRepliesCount()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), @@ -868,7 +901,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads instanceof List) for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getActionable().getCreatedAt(), status.getActionable().getEditedAt(), statusDisplayOptions); + setMetaData(status, statusDisplayOptions, listener); } } @@ -950,7 +983,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { if (visibility == null) { return ""; @@ -1239,7 +1272,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(visibility); displayName.setVisibility(visibility); username.setVisibility(visibility); - timestampInfo.setVisibility(visibility); + metaInfo.setVisibility(visibility); contentWarningDescription.setVisibility(visibility); contentWarningButton.setVisibility(visibility); content.setVisibility(visibility); 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 35b2d7ac5..d21021714 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,13 +1,20 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; import android.text.method.LinkMovementMethod; +import android.text.style.DynamicDrawableSpan; +import android.text.style.ImageSpan; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -15,19 +22,20 @@ import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.NoUnderlineURLSpan; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; -import java.util.ArrayList; import java.util.Date; -import java.util.List; public class StatusDetailedViewHolder extends StatusBaseViewHolder { private final TextView reblogs; private final TextView favourites; private final View infoDivider; + private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); + public StatusDetailedViewHolder(View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); @@ -36,17 +44,74 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setCreatedAt(Date createdAt, Date editedAt, StatusDisplayOptions statusDisplayOptions) { - DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - Context context = timestampInfo.getContext(); - List list = new ArrayList<>(); + protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + + Status status = statusViewData.getActionable(); + + Status.Visibility visibility = status.getVisibility(); + Context context = metaInfo.getContext(); + + Drawable visibilityIcon = getVisibilityIcon(visibility); + CharSequence visibilityString = getVisibilityDescription(context, visibility); + + SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString); + + if (visibilityIcon != null) { + ImageSpan visibilityIconSpan = new ImageSpan( + visibilityIcon, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE + ); + sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + String metadataJoiner = context.getString(R.string.metadata_joiner); + + Date createdAt = status.getCreatedAt(); if (createdAt != null) { - list.add(dateFormat.format(createdAt)); + + sb.append(" "); + sb.append(dateFormat.format(createdAt)); } + + Date editedAt = status.getEditedAt(); + if (editedAt != null) { - list.add(context.getString(R.string.post_edited, dateFormat.format(editedAt))); + String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt)); + + sb.append(metadataJoiner); + int spanStart = sb.length(); + int spanEnd = spanStart + editedAtString.length(); + + sb.append(editedAtString); + + if (statusViewData.getStatus().getEditedAt() != null) { + NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") { + @Override + public void onClick(@NonNull View view) { + listener.onShowEdits(getBindingAdapterPosition()); + } + }; + + sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } } - timestampInfo.setText(TextUtils.join(context.getString(R.string.timestamp_joiner), list)); + + Status.Application app = status.getApplication(); + + if (app != null) { + + sb.append(metadataJoiner); + + if (app.getWebsite() != null) { + CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); + sb.append(text); + } else { + sb.append(app.getName()); + } + } + + metaInfo.setMovementMethod(LinkMovementMethod.getInstance()); + metaInfo.setText(sb); } private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { @@ -84,21 +149,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { }); } - private void setApplication(@Nullable Status.Application app) { - if (app != null) { - - timestampInfo.append(" • "); - - if (app.getWebsite() != null) { - CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); - timestampInfo.append(text); - timestampInfo.setMovementMethod(LinkMovementMethod.getInstance()); - } else { - timestampInfo.append(app.getName()); - } - } - } - @Override public void setupWithStatus(@NonNull final StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @@ -106,8 +156,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? - status.copyWithCollapsed(false) : - status; + status.copyWithCollapsed(false) : + status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status @@ -120,8 +170,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } else { hideQuantitativeStats(); } - - setApplication(actionable.getApplication()); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt index 994630a14..e3e4f27e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.adapter -import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup @@ -30,8 +29,8 @@ import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding import com.keylesspalace.tusky.databinding.ItemTabPreferenceSmallBinding import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show interface ItemInteractionListener { @@ -101,7 +100,7 @@ class TabAdapter( listener.onTabRemoved(holder.bindingAdapterPosition) } binding.removeButton.isEnabled = removeButtonEnabled - ThemeUtils.setDrawableTint( + setDrawableTint( holder.itemView.context, binding.removeButton.drawable, (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) @@ -119,17 +118,18 @@ class TabAdapter( val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? ?: Chip(context).apply { + setCloseIconResource(R.drawable.ic_cancel_24dp) + isCheckable = false binding.chipGroup.addView(this, binding.chipGroup.size - 1) - chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary)) } chip.text = arg if (tab.arguments.size <= 1) { - chip.chipIcon = null + chip.isCloseIconVisible = false chip.setOnClickListener(null) } else { - chip.setChipIconResource(R.drawable.ic_cancel_24dp) + chip.isCloseIconVisible = true chip.setOnClickListener { listener.onChipClicked(tab, holder.bindingAdapterPosition, i) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 254218fc8..85419c00f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -16,6 +16,8 @@ package com.keylesspalace.tusky.components.account import android.animation.ArgbEvaluator +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.ColorStateList @@ -41,6 +43,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel @@ -70,12 +73,12 @@ import com.keylesspalace.tusky.util.DefaultTextWatcher import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -177,9 +180,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI * Load colors and dimensions from resources */ private fun loadResources() { - toolbarColor = ThemeUtils.getColor(this, R.attr.colorSurface) + toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK) statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) - statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark) + statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) } @@ -238,6 +241,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI // Setup the tabs and timeline pager. adapter = AccountPagerAdapter(this, viewModel.accountId) + binding.accountFragmentViewPager.reduceSwipeSensitivity() binding.accountFragmentViewPager.adapter = adapter binding.accountFragmentViewPager.offscreenPageLimit = 2 @@ -323,7 +327,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI supportActionBar?.setDisplayShowTitleEnabled(false) } - if (hideFab && !viewModel.isSelf && !blocking) { + if (hideFab && !blocking) { if (verticalOffset > oldOffset) { binding.accountFloatingActionButton.show() } @@ -412,6 +416,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountUsernameTextView.text = usernameFormatted binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) + // Long press on username to copy it to clipboard + for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) { + view.setOnLongClickListener { + loadedAccount?.let { loadedAccount -> + val fullUsername = getFullUsername(loadedAccount) + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername)) + Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT) + .show() + } + true + } + } + val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) @@ -668,7 +686,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountFollowButton.show() updateFollowButton() - if (blocking || viewModel.isSelf) { + if (blocking) { binding.accountFloatingActionButton.hide() binding.accountMuteButton.hide() binding.accountSubscribeButton.hide() @@ -709,9 +727,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI getString(R.string.action_mute) } - if (loadedAccount != null) { + loadedAccount?.let { loadedAccount -> val muteDomain = menu.findItem(R.id.action_mute_domain) - domain = getDomain(loadedAccount?.url) + domain = getDomain(loadedAccount.url) if (domain.isEmpty()) { // If we can't get the domain, there's no way we can mute it anyway... menu.removeItem(R.id.action_mute_domain) @@ -807,10 +825,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private fun mention() { loadedAccount?.let { - val intent = ComposeActivity.startIntent( - this, - ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)) - ) + val options = if (viewModel.isSelf) { + ComposeActivity.ComposeOptions(kind = ComposeActivity.ComposeKind.NEW) + } else { + ComposeActivity.ComposeOptions( + mentionedUsernames = setOf(it.username), + kind = ComposeActivity.ComposeKind.NEW + ) + } + val intent = ComposeActivity.startIntent(this, options) startActivity(intent) } } @@ -834,23 +857,47 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. - if (loadedAccount?.url != null) { - openLink(loadedAccount!!.url) + loadedAccount?.let { loadedAccount -> + openLink(loadedAccount.url) } return true } R.id.action_open_as -> { - if (loadedAccount != null) { + loadedAccount?.let { loadedAccount -> showAccountChooserDialog( item.title, false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { - openAsAccount(loadedAccount!!.url, account) + openAsAccount(loadedAccount.url, account) } } ) } } + R.id.action_share_account_link -> { + // If the account isn't loaded yet, eat the input. + loadedAccount?.let { loadedAccount -> + val url = loadedAccount.url + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_TEXT, url) + sendIntent.type = "text/plain" + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to))) + } + return true + } + R.id.action_share_account_username -> { + // If the account isn't loaded yet, eat the input. + loadedAccount?.let { loadedAccount -> + val fullUsername = getFullUsername(loadedAccount) + val sendIntent = Intent() + sendIntent.action = Intent.ACTION_SEND + sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername) + sendIntent.type = "text/plain" + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to))) + } + return true + } R.id.action_block -> { toggleBlock() return true @@ -872,8 +919,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return true } R.id.action_report -> { - if (loadedAccount != null) { - startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username)) + loadedAccount?.let { loadedAccount -> + startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)) } return true } @@ -882,11 +929,22 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } override fun getActionButton(): FloatingActionButton? { - return if (!viewModel.isSelf && !blocking) { + return if (!blocking) { binding.accountFloatingActionButton } else null } + private fun getFullUsername(account: Account): String { + if (account.isRemote()) { + return "@" + account.username + } else { + val localUsername = account.localUsername + // Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible. + val domain = accountManager.activeAccount!!.domain + return "@$localUsername@$domain" + } + } + override fun androidInjector() = dispatchingAndroidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 664651eb2..1b4aa7f02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -2,6 +2,7 @@ package com.keylesspalace.tusky.components.account import android.util.Log import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.EventHub @@ -19,6 +20,7 @@ import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -181,7 +183,11 @@ class AccountViewModel @Inject constructor( /** * @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE */ - private fun changeRelationship(relationshipAction: RelationShipAction, parameter: Boolean? = null, duration: Int? = null) { + private fun changeRelationship( + relationshipAction: RelationShipAction, + parameter: Boolean? = null, + duration: Int? = null + ) = viewModelScope.launch { val relation = relationshipData.value?.data val account = accountData.value?.data val isMastodon = relationshipData.value?.data?.notifying != null @@ -216,40 +222,45 @@ class AccountViewModel @Inject constructor( relationshipData.postValue(Loading(newRelation)) } - when (relationshipAction) { - RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs = parameter ?: true) - RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) - RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) - RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) - RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration) - RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) - RelationShipAction.SUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = true) - else mastodonApi.subscribeAccount(accountId) - } - RelationShipAction.UNSUBSCRIBE -> { - if (isMastodon) - mastodonApi.followAccount(accountId, notify = false) - else mastodonApi.unsubscribeAccount(accountId) - } - }.subscribe( - { relationship -> - relationshipData.postValue(Success(relationship)) - - when (relationshipAction) { - RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) - RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) - RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) - else -> { - } + try { + val relationship = when (relationshipAction) { + RelationShipAction.FOLLOW -> mastodonApi.followAccount( + accountId, + showReblogs = parameter ?: true + ) + RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) + RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) + RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) + RelationShipAction.MUTE -> mastodonApi.muteAccount( + accountId, + parameter ?: true, + duration + ) + RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) + RelationShipAction.SUBSCRIBE -> { + if (isMastodon) + mastodonApi.followAccount(accountId, notify = true) + else mastodonApi.subscribeAccount(accountId) + } + RelationShipAction.UNSUBSCRIBE -> { + if (isMastodon) + mastodonApi.followAccount(accountId, notify = false) + else mastodonApi.unsubscribeAccount(accountId) } - }, - { - relationshipData.postValue(Error(relation)) } - ) - .autoDispose() + + relationshipData.postValue(Success(relationship)) + + when (relationshipAction) { + RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) + RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) + else -> { + } + } + } catch (_: Throwable) { + relationshipData.postValue(Error(relation)) + } } fun noteChanged(newNote: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index e5a0b592d..fcc3bcf9d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -11,11 +11,11 @@ import androidx.core.view.setPadding import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.Glide +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.getFormattedDescription import com.keylesspalace.tusky.util.hide @@ -40,7 +40,7 @@ class AccountMediaGridAdapter( } ) { - private val baseItemBackgroundColor = ThemeUtils.getColor(context, R.attr.colorSurface) + private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK) private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index e788abb1b..f9aa5af48 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -122,7 +122,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, } viewModel.emojis.observe(this) { - picker.adapter = EmojiAdapter(it, this) + picker.adapter = EmojiAdapter(it, this, animateEmojis) } viewModel.load() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 154519ba8..0da5dac1b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -31,6 +31,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Parcelable +import android.provider.MediaStore import android.util.Log import android.view.KeyEvent import android.view.MenuItem @@ -64,6 +65,7 @@ import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig @@ -89,8 +91,8 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.PickMediaFiles -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.getInitialLanguage import com.keylesspalace.tusky.util.getLocaleList @@ -100,6 +102,7 @@ import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -140,10 +143,10 @@ class ComposeActivity : private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var scheduleBehavior: BottomSheetBehavior<*> - // this only exists when a status is trying to be sent, but uploads are still occurring - private var finishingUploadDialog: ProgressDialog? = null private var photoUploadUri: Uri? = null + private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL @@ -191,7 +194,7 @@ class ComposeActivity : Log.w("ComposeActivity", "Edit image cancelled by user") } else { Log.w("ComposeActivity", "Edit image failed: " + result.error) - displayTransientError(R.string.error_image_edit_failed) + displayTransientMessage(R.string.error_image_edit_failed) } viewModel.cropImageItemOld = null } @@ -211,8 +214,7 @@ class ComposeActivity : accountManager.setActiveAccount(accountId) } - val preferences = PreferenceManager.getDefaultSharedPreferences(this) - val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } @@ -222,7 +224,7 @@ class ComposeActivity : // do not do anything when not logged in, activity will be finished in super.onCreate() anyway val activeAccount = accountManager.activeAccount ?: return - setupAvatar(preferences, activeAccount) + setupAvatar(activeAccount) val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> @@ -242,15 +244,14 @@ class ComposeActivity : binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null - setupButtons() - subscribeToUpdates(mediaAdapter) - /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) - viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true)) + setupButtons() + subscribeToUpdates(mediaAdapter) + if (accountManager.shouldDisplaySelfUsername(this)) { binding.composeUsernameView.text = getString( R.string.compose_active_account_description, @@ -367,7 +368,7 @@ class ComposeActivity : binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } - ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) + setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) binding.composeReplyView.setOnClickListener { @@ -380,7 +381,7 @@ class ComposeActivity : binding.composeReplyContentView.show() val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 } - ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) + setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null) } } @@ -394,7 +395,7 @@ class ComposeActivity : binding.composeQuoteView.text = getString(R.string.quote_to, quoteStatusAuthor) val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } - ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) + setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) binding.composeQuoteView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) binding.composeQuoteView.setOnClickListener { @@ -407,7 +408,7 @@ class ComposeActivity : binding.composeQuoteContentView.show() val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 } - ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) + setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null) } } @@ -520,9 +521,9 @@ class ComposeActivity : lifecycleScope.launch { viewModel.uploadError.collect { throwable -> if (throwable is UploadServerError) { - displayTransientError(throwable.errorMessage) + displayTransientMessage(throwable.errorMessage) } else { - displayTransientError(R.string.error_media_upload_sending) + displayTransientMessage(R.string.error_media_upload_sending) } } } @@ -569,8 +570,11 @@ class ComposeActivity : binding.composeScheduleView.setListener(this) binding.atButton.setOnClickListener { atButtonClicked() } binding.hashButton.setOnClickListener { hashButtonClicked() } + binding.descriptionMissingWarningButton.setOnClickListener { + displayTransientMessage(R.string.hint_media_description_missing) + } - val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary) val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 } binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) @@ -581,6 +585,8 @@ class ComposeActivity : val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 } binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) + binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null) + binding.actionPhotoTake.setOnClickListener { initiateCameraApp() } binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } @@ -633,7 +639,7 @@ class ComposeActivity : } } - private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { + private fun setupAvatar(activeAccount: AccountEntity) { val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) val a = obtainStyledAttributes(null, actionBarSizeAttr) val avatarSize = a.getDimensionPixelSize(0, 1) @@ -723,15 +729,15 @@ class ComposeActivity : super.onSaveInstanceState(outState) } - private fun displayTransientError(errorMessage: String) { - val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG) + private fun displayTransientMessage(message: String) { + val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG) // necessary so snackbar is shown over everything bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.setAnchorView(R.id.composeBottomBar) bar.show() } - private fun displayTransientError(@StringRes stringId: Int) { - displayTransientError(getString(stringId)) + private fun displayTransientMessage(@StringRes stringId: Int) { + displayTransientMessage(getString(stringId)) } private fun toggleHideMedia() { @@ -741,6 +747,7 @@ class ComposeActivity : private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { if (viewModel.media.value.isEmpty()) { binding.composeHideMediaButton.hide() + binding.descriptionMissingWarningButton.hide() } else { binding.composeHideMediaButton.show() @ColorInt val color = if (contentWarningShown) { @@ -754,28 +761,42 @@ class ComposeActivity : getColor(R.color.tusky_blue) } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) - ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary) } } binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + + var oneMediaWithoutDescription = false + for (media in viewModel.media.value) { + if (media.description == null || media.description.isEmpty()) { + oneMediaWithoutDescription = true + break + } + } + binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE } } private fun updateScheduleButton() { - @ColorInt val color = if (binding.composeScheduleView.time == null) { - ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + if (viewModel.editing) { + // Can't reschedule a published status + enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) } else { - getColor(R.color.tusky_blue) + @ColorInt val color = if (binding.composeScheduleView.time == null) { + MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary) + } else { + getColor(R.color.tusky_blue) + } + binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } - binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } - private fun enableButtons(enable: Boolean) { + private fun enableButtons(enable: Boolean, editing: Boolean) { binding.composeAddMediaButton.isClickable = enable - binding.composeToggleVisibilityButton.isClickable = enable + binding.composeToggleVisibilityButton.isClickable = enable && !editing binding.composeEmojiButton.isClickable = enable binding.composeHideMediaButton.isClickable = enable - binding.composeScheduleButton.isClickable = enable + binding.composeScheduleButton.isClickable = enable && !editing binding.composeTootButton.isEnabled = enable } @@ -792,6 +813,10 @@ class ComposeActivity : else -> R.drawable.ic_lock_open_24dp } binding.composeToggleVisibilityButton.setImageResource(iconRes) + if (viewModel.editing) { + // Can't update visibility on published status + enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false) + } } private fun showComposeOptions() { @@ -828,7 +853,7 @@ class ComposeActivity : binding.emojiView.adapter?.let { if (it.itemCount == 0) { val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) - Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + displayTransientMessage(errorMessage) } else { if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED @@ -958,7 +983,7 @@ class ComposeActivity : val textColor = if (remainingLength < 0) { getColor(R.color.tusky_red) } else { - ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary) } binding.composeCharactersLeftView.setTextColor(textColor) } @@ -996,7 +1021,7 @@ class ComposeActivity : } private fun sendStatus() { - enableButtons(false) + enableButtons(false, viewModel.editing) var contentText = binding.composeEditField.text.toString() var spoilerText = "" if (viewModel.showContentWarning.value) { @@ -1005,27 +1030,19 @@ class ComposeActivity : val characterCount = calculateTextLength() if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) - enableButtons(true) + enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { if (binding.checkboxUseDefaultText.isChecked) { contentText += " ${binding.editTextDefaultText.text}" } - if (viewModel.media.value.isNotEmpty()) { - finishingUploadDialog = ProgressDialog.show( - this, getString(R.string.dialog_title_finishing_media_upload), - getString(R.string.dialog_message_uploading_media), true, true - ) - } - lifecycleScope.launch { viewModel.sendStatus(contentText, spoilerText) - finishingUploadDialog?.dismiss() deleteDraftAndFinish() } } else { binding.composeEditField.error = getString(R.string.error_compose_character_limit) - enableButtons(true) + enableButtons(true, viewModel.editing) } } @@ -1055,7 +1072,7 @@ class ComposeActivity : val photoFile: File = try { createNewImageFile(this) } catch (ex: IOException) { - displayTransientError(R.string.error_media_upload_opening) + displayTransientMessage(R.string.error_media_upload_opening) return } @@ -1070,7 +1087,7 @@ class ComposeActivity : private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable - ThemeUtils.setDrawableTint( + setDrawableTint( this, button.drawable, if (colorActive) android.R.attr.textColorTertiary else R.attr.textColorDisabled @@ -1079,8 +1096,8 @@ class ComposeActivity : private fun enablePollButton(enable: Boolean) { binding.addPollTextActionTextView.isEnabled = enable - val textColor = ThemeUtils.getColor( - this, + val textColor = MaterialColors.getColor( + binding.addPollTextActionTextView, if (enable) android.R.attr.textColorTertiary else R.attr.textColorDisabled ) @@ -1125,7 +1142,7 @@ class ComposeActivity : is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video) else -> getString(R.string.error_media_upload_opening) } - displayTransientError(errorString) + displayTransientMessage(errorString) } } } @@ -1140,7 +1157,7 @@ class ComposeActivity : } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() - ThemeUtils.getColor(this, android.R.attr.textColorTertiary) + MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary) } binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } @@ -1155,7 +1172,6 @@ class ComposeActivity : } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - Log.d(TAG, event.toString()) if (event.action == KeyEvent.ACTION_DOWN) { if (event.isCtrlPressed) { if (keyCode == KeyEvent.KEYCODE_ENTER) { @@ -1177,25 +1193,79 @@ class ComposeActivity : val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() if (viewModel.didChange(contentText, contentWarning)) { - - val warning = if (!viewModel.media.value.isEmpty()) { - R.string.compose_save_draft_loses_media - } else { - R.string.compose_save_draft - } - - AlertDialog.Builder(this) - .setMessage(warning) - .setPositiveButton(R.string.action_save) { _, _ -> - saveDraftAndFinish(contentText, contentWarning) - } - .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } - .show() + when (viewModel.composeKind) { + ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning) + ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning) + ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog() + ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog() + }.show() } else { + viewModel.stopUploads() finishWithoutSlideOutAnimation() } } + /** + * User is editing a new post, and can either save the changes as a draft or discard them. + */ + private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder { + val warning = if (viewModel.media.value.isNotEmpty()) { + R.string.compose_save_draft_loses_media + } else { + R.string.compose_save_draft + } + + return AlertDialog.Builder(this) + .setMessage(warning) + .setPositiveButton(R.string.action_save) { _, _ -> + viewModel.stopUploads() + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_delete) { _, _ -> + viewModel.stopUploads() + deleteDraftAndFinish() + } + } + + /** + * User is editing an existing draft, and can either update the draft with the new changes or + * discard them. + */ + private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder { + val warning = if (viewModel.media.value.isNotEmpty()) { + R.string.compose_save_draft_loses_media + } else { + R.string.compose_save_draft + } + + return AlertDialog.Builder(this) + .setMessage(warning) + .setPositiveButton(R.string.action_save) { _, _ -> + viewModel.stopUploads() + saveDraftAndFinish(contentText, contentWarning) + } + .setNegativeButton(R.string.action_discard) { _, _ -> + viewModel.stopUploads() + finishWithoutSlideOutAnimation() + } + } + + /** + * User is editing a post (scheduled, or posted), and can either go back to editing, or + * discard the changes. + */ + private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder { + return AlertDialog.Builder(this) + .setMessage(R.string.compose_unsaved_changes) + .setPositiveButton(R.string.action_continue_edit) { _, _ -> + // Do nothing, dialog will dismiss, user can continue editing + } + .setNegativeButton(R.string.action_discard) { _, _ -> + viewModel.stopUploads() + finishWithoutSlideOutAnimation() + } + } + private fun deleteDraftAndFinish() { viewModel.deleteDraft() finishWithoutSlideOutAnimation() @@ -1227,7 +1297,8 @@ class ComposeActivity : private fun setEmojiList(emojiList: List?) { if (emojiList != null) { - binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity, animateEmojis) enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty()) } } @@ -1240,14 +1311,18 @@ class ComposeActivity : val uploadPercent: Int = 0, val id: String? = null, val description: String? = null, - val focus: Attachment.Focus? = null + val focus: Attachment.Focus? = null, + val state: State ) { enum class Type { IMAGE, VIDEO, AUDIO; } + enum class State { + UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED + } } - override fun onTimeSet(time: String) { + override fun onTimeSet(time: String?) { viewModel.updateScheduledAt(time) if (verifyScheduledTime()) { scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -1269,6 +1344,24 @@ class ComposeActivity : } } + /** + * Status' kind. This particularly affects how the status is handled if the user + * backs out of the edit. + */ + enum class ComposeKind { + /** Status is new */ + NEW, + + /** Editing a posted status */ + EDIT_POSTED, + + /** Editing a status started as an existing draft */ + EDIT_DRAFT, + + /** Editing an an existing scheduled status */ + EDIT_SCHEDULED + } + @Parcelize data class ComposeOptions( // Let's keep fields var until all consumers are Kotlin @@ -1294,6 +1387,8 @@ class ComposeActivity : var poll: NewPoll? = null, var modifiedInitialState: Boolean? = null, var language: String? = null, + var statusId: String? = null, + var kind: ComposeKind? = null, var tootRightNow: Boolean? = null, ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 98fa2a90b..b78f8654a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -31,18 +31,17 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.service.MediaToSend import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn @@ -70,6 +69,7 @@ class ComposeViewModel @Inject constructor( private var scheduledTootId: String? = null private var startingContentWarning: String = "" private var inReplyToId: String? = null + private var originalStatusId: String? = null private var quoteId: String? = null private var quoteStatusAuthor: String? = null private var quoteStatusContent: String? = null @@ -106,7 +106,7 @@ class ComposeViewModel @Inject constructor( val domain = accountManager.activeAccount?.domain!! - private val mediaToJob = mutableMapOf() + lateinit var composeKind: ComposeActivity.ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null @@ -143,17 +143,18 @@ class ComposeViewModel @Inject constructor( media.updateAndGet { mediaValue -> val mediaItem = QueuedMedia( - localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, + localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, mediaSize = mediaSize, description = description, - focus = focus + focus = focus, + state = QueuedMedia.State.UPLOADING ) stashMediaItem = mediaItem if (replaceItem != null) { - mediaToJob[replaceItem.localId]?.cancel() + mediaUploader.cancelUploadScope(replaceItem.localId) mediaValue.map { if (it.localId == replaceItem.localId) mediaItem else it } @@ -163,13 +164,9 @@ class ComposeViewModel @Inject constructor( } val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that - mediaToJob[mediaItem.localId] = viewModelScope.launch { + viewModelScope.launch { mediaUploader .uploadMedia(mediaItem, instanceInfo.first()) - .catch { error -> - media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } - uploadError.emit(error) - } .collect { event -> val item = media.value.find { it.localId == mediaItem.localId } ?: return@collect @@ -177,7 +174,16 @@ class ComposeViewModel @Inject constructor( is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) is UploadEvent.FinishedEvent -> - item.copy(id = event.mediaId, uploadPercent = -1) + item.copy( + id = event.mediaId, + uploadPercent = -1, + state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED } + ) + is UploadEvent.ErrorEvent -> { + media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } + uploadError.emit(event.error) + return@collect + } } media.update { mediaValue -> mediaValue.map { mediaItem -> @@ -196,21 +202,22 @@ class ComposeViewModel @Inject constructor( private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { media.update { mediaValue -> val mediaItem = QueuedMedia( - localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, + localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, mediaSize = 0, uploadPercent = -1, id = id, description = description, - focus = focus + focus = focus, + state = QueuedMedia.State.PUBLISHED ) mediaValue + mediaItem } } fun removeMediaFromQueue(item: QueuedMedia) { - mediaToJob[item.localId]?.cancel() + mediaUploader.cancelUploadScope(item.localId) media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } } @@ -219,15 +226,8 @@ class ComposeViewModel @Inject constructor( } fun didChange(content: String?, contentWarning: String?): Boolean { - - val textChanged = !( - content.isNullOrEmpty() || - startingText?.startsWith(content.toString()) ?: false - ) - - val contentWarningChanged = showContentWarning.value && - !contentWarning.isNullOrEmpty() && - !startingContentWarning.startsWith(contentWarning.toString()) + val textChanged = content.orEmpty() != startingText.orEmpty() + val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning val mediaChanged = media.value.isNotEmpty() val pollChanged = poll.value != null val didScheduledTimeChange = hasScheduledTimeChanged @@ -248,6 +248,10 @@ class ComposeViewModel @Inject constructor( } } + fun stopUploads() { + mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray()) + } + fun shouldShowSaveDraftDialog(): Boolean { // if any of the media files need to be downloaded first it could take a while, so show a loading dialog return media.value.any { mediaValue -> @@ -280,6 +284,7 @@ class ComposeViewModel @Inject constructor( failedToSend = false, scheduledAt = scheduledAt.value, language = postLanguage, + statusId = originalStatusId, ) } @@ -296,47 +301,37 @@ class ComposeViewModel @Inject constructor( api.deleteScheduledStatus(scheduledTootId!!) } - media - .filter { items -> items.all { it.uploadPercent == -1 } } - .first { - val mediaIds: MutableList = mutableListOf() - val mediaUris: MutableList = mutableListOf() - val mediaDescriptions: MutableList = mutableListOf() - val mediaFocus: MutableList = mutableListOf() - val mediaProcessed: MutableList = mutableListOf() - media.value.forEach { item -> - mediaIds.add(item.id!!) - mediaUris.add(item.uri) - mediaDescriptions.add(item.description ?: "") - mediaFocus.add(item.focus) - mediaProcessed.add(false) - } - val tootToSend = StatusToSend( - text = content, - warningText = spoilerText, - visibility = statusVisibility.value.serverString(), - sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), - mediaIds = mediaIds, - mediaUris = mediaUris.map { it.toString() }, - mediaDescriptions = mediaDescriptions, - mediaFocus = mediaFocus, - scheduledAt = scheduledAt.value, - inReplyToId = inReplyToId, - poll = poll.value, - replyingStatusContent = null, - replyingStatusAuthorUsername = null, - quoteId = quoteId, - accountId = accountManager.activeAccount!!.id, - draftId = draftId, - idempotencyKey = randomAlphanumericString(16), - retries = 0, - mediaProcessed = mediaProcessed, - language = postLanguage, - ) + val attachedMedia = media.value.map { item -> + MediaToSend( + localId = item.localId, + id = item.id, + uri = item.uri.toString(), + description = item.description, + focus = item.focus, + processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED + ) + } + val tootToSend = StatusToSend( + text = content, + warningText = spoilerText, + visibility = statusVisibility.value.serverString(), + sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), + media = attachedMedia, + scheduledAt = scheduledAt.value, + inReplyToId = inReplyToId, + poll = poll.value, + replyingStatusContent = null, + replyingStatusAuthorUsername = null, + quoteId = quoteId, + accountId = accountManager.activeAccount!!.id, + draftId = draftId, + idempotencyKey = randomAlphanumericString(16), + retries = 0, + language = postLanguage, + statusId = originalStatusId + ) - serviceClient.sendToot(tootToSend) - true - } + serviceClient.sendToot(tootToSend) } // Updates a QueuedMedia item arbitrarily, then sends description and focus to server @@ -367,15 +362,15 @@ class ComposeViewModel @Inject constructor( } suspend fun updateDescription(localId: Int, description: String): Boolean { - return updateMediaItem(localId, { mediaItem -> + return updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) - }) + } } suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { - return updateMediaItem(localId, { mediaItem -> + return updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) - }) + } } fun searchAutocompleteSuggestions(token: String): List { @@ -423,6 +418,8 @@ class ComposeViewModel @Inject constructor( return } + composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW + val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN @@ -467,6 +464,7 @@ class ComposeViewModel @Inject constructor( draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId + originalStatusId = composeOptions?.statusId startingText = composeOptions?.content postLanguage = composeOptions?.language @@ -517,6 +515,9 @@ class ComposeViewModel @Inject constructor( scheduledAt.value = newScheduledAt } + val editing: Boolean + get() = !originalStatusId.isNullOrEmpty() + private companion object { const val TAG = "ComposeViewModel" } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 2855e6969..cababaf04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -48,10 +48,13 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 val removeId = 4 - popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { - popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) - popup.menu.add(0, editImageId, 0, R.string.action_edit_image) + if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { + // Already-published items can't have their metadata edited + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + popup.menu.add(0, editImageId, 0, R.string.action_edit_image) + } } popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 7b2008ad9..d7ddf8722 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -17,13 +17,14 @@ package com.keylesspalace.tusky.components.compose import android.content.ContentResolver import android.content.Context +import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE import android.net.Uri import android.os.Environment import android.util.Log import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri -import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia @@ -35,28 +36,44 @@ import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.shareIn import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody +import retrofit2.HttpException import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.util.Date import javax.inject.Inject +import javax.inject.Singleton + +sealed interface FinalUploadEvent sealed class UploadEvent { data class ProgressEvent(val percentage: Int) : UploadEvent() - data class FinishedEvent(val mediaId: String) : UploadEvent() + data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent + data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent } +data class UploadData( + val flow: Flow, + val scope: CoroutineScope +) + fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { // Create an image file name val randomId = randomAlphanumericString(12) @@ -76,14 +93,38 @@ class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() +@Singleton class MediaUploader @Inject constructor( private val context: Context, private val mediaUploadApi: MediaUploadApi ) { + private val uploads = mutableMapOf() + + private var mostRecentId: Int = 0 + + fun getNewLocalMediaId(): Int { + return mostRecentId++ + } + + suspend fun getMediaUploadState(localId: Int): FinalUploadEvent { + return uploads[localId]?.flow + ?.filterIsInstance() + ?.first() + ?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found")) + } + + /** + * Uploads media. + * @param media the media to upload + * @param instanceInfo info about the current media to make sure the media gets resized correctly + * @return A Flow emitting upload events. + * The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope]. + */ @OptIn(ExperimentalCoroutinesApi::class) fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow { - return flow { + val uploadScope = CoroutineScope(Dispatchers.IO) + val uploadFlow = flow { if (shouldResizeMedia(media, instanceInfo)) { emit(downsize(media, instanceInfo)) } else { @@ -91,7 +132,23 @@ class MediaUploader @Inject constructor( } } .flatMapLatest { upload(it) } - .flowOn(Dispatchers.IO) + .catch { exception -> + emit(UploadEvent.ErrorEvent(exception)) + } + .shareIn(uploadScope, SharingStarted.Lazily, 1) + + uploads[media.localId] = UploadData(uploadFlow, uploadScope) + return uploadFlow + } + + /** + * Cancels the CoroutineScope of a media upload. + * Call this when to abort the upload or to clean up resources after upload info is no longer needed + */ + fun cancelUploadScope(vararg localMediaIds: Int) { + localMediaIds.forEach { localId -> + uploads.remove(localId)?.scope?.cancel() + } } fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia { @@ -193,6 +250,19 @@ class MediaUploader @Inject constructor( private suspend fun upload(media: QueuedMedia): Flow { return callbackFlow { var mimeType = contentResolver.getType(media.uri) + + // Android's MIME type suggestions from file extensions is broken for at least + // .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details. + // Sniff the content of the file to determine the actual type. + if (mimeType != null && ( + mimeType.startsWith("audio/", ignoreCase = true) || + mimeType.startsWith("video/", ignoreCase = true) + ) + ) { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, media.uri) + mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE) + } val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) val filename = "%s_%s_%s.%s".format( @@ -231,21 +301,25 @@ class MediaUploader @Inject constructor( null } - mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> + val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus) + val responseBody = uploadResponse.body() + if (uploadResponse.isSuccessful && responseBody != null) { if (media.uri.scheme == "file") { media.uri.path?.let { File(it).delete() } } - send(UploadEvent.FinishedEvent(result.id)) - }, { throwable -> - val errorMessage = throwable.getServerErrorMessage() + send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200)) + } else { + val error = HttpException(uploadResponse) + val errorMessage = error.getServerErrorMessage() if (errorMessage == null) { - throw throwable + throw error } else { throw UploadServerError(errorMessage) } - }) + } + awaitClose() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java deleted file mode 100644 index 14c574a15..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.java +++ /dev/null @@ -1,248 +0,0 @@ -/* Copyright 2019 kyori19 - * - * 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.compose.view; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.ContextCompat; - -import com.google.android.material.datepicker.CalendarConstraints; -import com.google.android.material.datepicker.DateValidatorPointForward; -import com.google.android.material.datepicker.MaterialDatePicker; -import com.google.android.material.timepicker.MaterialTimePicker; -import com.google.android.material.timepicker.TimeFormat; -import com.keylesspalace.tusky.R; - -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Date; -import java.util.Locale; -import java.util.TimeZone; - -public class ComposeScheduleView extends ConstraintLayout { - - public interface OnTimeSetListener { - void onTimeSet(String time); - } - - private OnTimeSetListener listener; - - private DateFormat dateFormat; - private DateFormat timeFormat; - private SimpleDateFormat iso8601; - - private Button resetScheduleButton; - private TextView scheduledDateTimeView; - private TextView invalidScheduleWarningView; - - private Calendar scheduleDateTime; - public static int MINIMUM_SCHEDULED_SECONDS = 330; // Minimum is 5 minutes, pad 30 seconds for posting - - public ComposeScheduleView(Context context) { - super(context); - init(); - } - - public ComposeScheduleView(Context context, AttributeSet attrs) { - super(context, attrs); - init(); - } - - public ComposeScheduleView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - inflate(getContext(), R.layout.view_compose_schedule, this); - - dateFormat = SimpleDateFormat.getDateInstance(); - timeFormat = SimpleDateFormat.getTimeInstance(); - iso8601 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()); - iso8601.setTimeZone(TimeZone.getTimeZone("UTC")); - - resetScheduleButton = findViewById(R.id.resetScheduleButton); - scheduledDateTimeView = findViewById(R.id.scheduledDateTime); - invalidScheduleWarningView = findViewById(R.id.invalidScheduleWarning); - - scheduledDateTimeView.setOnClickListener(v -> openPickDateDialog()); - invalidScheduleWarningView.setText(R.string.warning_scheduling_interval); - - scheduleDateTime = null; - - setScheduledDateTime(); - - setEditIcons(); - } - - public void setListener(OnTimeSetListener listener) { - this.listener = listener; - } - - private void setScheduledDateTime() { - if (scheduleDateTime == null) { - scheduledDateTimeView.setText(""); - invalidScheduleWarningView.setVisibility(GONE); - } else { - Date scheduled = scheduleDateTime.getTime(); - scheduledDateTimeView.setText(String.format("%s %s", - dateFormat.format(scheduled), - timeFormat.format(scheduled))); - verifyScheduledTime(scheduled); - } - } - - private void setEditIcons() { - Drawable icon = ContextCompat.getDrawable(getContext(), R.drawable.ic_create_24dp); - if (icon == null) { - return; - } - - final int size = scheduledDateTimeView.getLineHeight(); - - icon.setBounds(0, 0, size, size); - - scheduledDateTimeView.setCompoundDrawables(null, null, icon, null); - } - - public void setResetOnClickListener(OnClickListener listener) { - resetScheduleButton.setOnClickListener(listener); - } - - public void resetSchedule() { - scheduleDateTime = null; - setScheduledDateTime(); - } - - public void openPickDateDialog() { - long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000; - CalendarConstraints calendarConstraints = new CalendarConstraints.Builder() - .setValidator( - DateValidatorPointForward.from(yesterday)) - .build(); - initializeSuggestedTime(); - MaterialDatePicker picker = MaterialDatePicker.Builder - .datePicker() - .setSelection(scheduleDateTime.getTimeInMillis()) - .setCalendarConstraints(calendarConstraints) - .build(); - picker.addOnPositiveButtonClickListener(this::onDateSet); - picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "date_picker"); - } - - private void openPickTimeDialog() { - MaterialTimePicker.Builder pickerBuilder = new MaterialTimePicker.Builder(); - if (scheduleDateTime != null) { - pickerBuilder.setHour(scheduleDateTime.get(Calendar.HOUR_OF_DAY)) - .setMinute(scheduleDateTime.get(Calendar.MINUTE)); - } - if (android.text.format.DateFormat.is24HourFormat(this.getContext())) { - pickerBuilder.setTimeFormat(TimeFormat.CLOCK_24H); - } else { - pickerBuilder.setTimeFormat(TimeFormat.CLOCK_12H); - } - - MaterialTimePicker picker = pickerBuilder.build(); - picker.addOnPositiveButtonClickListener(v -> onTimeSet(picker.getHour(), picker.getMinute())); - - picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker"); - } - - public Date getDateTime(String scheduledAt) { - if (scheduledAt != null) { - try { - return iso8601.parse(scheduledAt); - } catch (ParseException e) { - } - } - return null; - } - - public void setDateTime(String scheduledAt) { - Date date; - try { - date = iso8601.parse(scheduledAt); - } catch (ParseException e) { - return; - } - initializeSuggestedTime(); - scheduleDateTime.setTime(date); - setScheduledDateTime(); - } - - public boolean verifyScheduledTime(@Nullable Date scheduledTime) { - boolean valid; - if (scheduledTime != null) { - Calendar minimumScheduledTime = getCalendar(); - minimumScheduledTime.add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS); - valid = scheduledTime.after(minimumScheduledTime.getTime()); - } else { - valid = true; - } - invalidScheduleWarningView.setVisibility(valid ? GONE : VISIBLE); - return valid; - } - - private void onDateSet(long selection) { - initializeSuggestedTime(); - Calendar newDate = getCalendar(); - // working around bug in DatePicker where date is UTC #1720 - // see https://github.com/material-components/material-components-android/issues/882 - newDate.setTimeZone(TimeZone.getTimeZone("UTC")); - newDate.setTimeInMillis(selection); - scheduleDateTime.set(newDate.get(Calendar.YEAR), newDate.get(Calendar.MONTH), newDate.get(Calendar.DATE)); - openPickTimeDialog(); - } - - private void onTimeSet(int hourOfDay, int minute) { - initializeSuggestedTime(); - scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay); - scheduleDateTime.set(Calendar.MINUTE, minute); - setScheduledDateTime(); - if (listener != null) { - listener.onTimeSet(getTime()); - } - } - - public String getTime() { - if (scheduleDateTime == null) { - return null; - } - return iso8601.format(scheduleDateTime.getTime()); - } - - @NonNull - public static Calendar getCalendar() { - return Calendar.getInstance(TimeZone.getDefault()); - } - - private void initializeSuggestedTime() { - if (scheduleDateTime == null) { - scheduleDateTime = getCalendar(); - scheduleDateTime.add(Calendar.MINUTE, 15); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt new file mode 100644 index 000000000..f2e00d341 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt @@ -0,0 +1,210 @@ +/* Copyright 2019 kyori19 + * + * 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.compose.view + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ViewComposeScheduleBinding +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class ComposeScheduleView +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + interface OnTimeSetListener { + fun onTimeSet(time: String?) + } + + private var binding = ViewComposeScheduleBinding.inflate( + (context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater), + this + ) + private var listener: OnTimeSetListener? = null + private var dateFormat = SimpleDateFormat.getDateInstance() + private var timeFormat = SimpleDateFormat.getTimeInstance() + private var iso8601 = SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + Locale.getDefault() + ).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + private var scheduleDateTime: Calendar? = null + + init { + binding.scheduledDateTime.setOnClickListener { openPickDateDialog() } + binding.invalidScheduleWarning.setText(R.string.warning_scheduling_interval) + updateScheduleUi() + setEditIcons() + } + + fun setListener(listener: OnTimeSetListener?) { + this.listener = listener + } + + private fun updateScheduleUi() { + if (scheduleDateTime == null) { + binding.scheduledDateTime.text = "" + binding.invalidScheduleWarning.visibility = GONE + return + } + + val scheduled = scheduleDateTime!!.time + binding.scheduledDateTime.text = String.format( + "%s %s", + dateFormat.format(scheduled), + timeFormat.format(scheduled) + ) + verifyScheduledTime(scheduled) + } + + private fun setEditIcons() { + val icon = ContextCompat.getDrawable(context, R.drawable.ic_create_24dp) ?: return + val size = binding.scheduledDateTime.lineHeight + icon.setBounds(0, 0, size, size) + binding.scheduledDateTime.setCompoundDrawables(null, null, icon, null) + } + + fun setResetOnClickListener(listener: OnClickListener?) { + binding.resetScheduleButton.setOnClickListener(listener) + } + + fun resetSchedule() { + scheduleDateTime = null + updateScheduleUi() + } + + fun openPickDateDialog() { + val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000 + val calendarConstraints = CalendarConstraints.Builder() + .setValidator( + DateValidatorPointForward.from(yesterday) + ) + .build() + initializeSuggestedTime() + val picker = MaterialDatePicker.Builder + .datePicker() + .setSelection(scheduleDateTime!!.timeInMillis) + .setCalendarConstraints(calendarConstraints) + .build() + picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) } + picker.show((context as AppCompatActivity).supportFragmentManager, "date_picker") + } + + private fun getTimeFormat(context: Context): Int { + return if (android.text.format.DateFormat.is24HourFormat(context)) { + TimeFormat.CLOCK_24H + } else { + TimeFormat.CLOCK_12H + } + } + + private fun openPickTimeDialog() { + val pickerBuilder = MaterialTimePicker.Builder() + scheduleDateTime?.let { + pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY]) + .setMinute(it[Calendar.MINUTE]) + } + + pickerBuilder.setTimeFormat(getTimeFormat(context)) + + val picker = pickerBuilder.build() + picker.addOnPositiveButtonClickListener { onTimeSet(picker.hour, picker.minute) } + picker.show((context as AppCompatActivity).supportFragmentManager, "time_picker") + } + + fun getDateTime(scheduledAt: String?): Date? { + scheduledAt?.let { + try { + return iso8601.parse(it) + } catch (_: ParseException) { + } + } + return null + } + + fun setDateTime(scheduledAt: String?) { + val date = getDateTime(scheduledAt) ?: return + initializeSuggestedTime() + scheduleDateTime!!.time = date + updateScheduleUi() + } + + fun verifyScheduledTime(scheduledTime: Date?): Boolean { + val valid: Boolean = if (scheduledTime != null) { + val minimumScheduledTime = calendar() + minimumScheduledTime.add( + Calendar.SECOND, + MINIMUM_SCHEDULED_SECONDS + ) + scheduledTime.after(minimumScheduledTime.time) + } else { + true + } + binding.invalidScheduleWarning.visibility = if (valid) GONE else VISIBLE + return valid + } + + private fun onDateSet(selection: Long) { + initializeSuggestedTime() + val newDate = calendar() + // working around bug in DatePicker where date is UTC #1720 + // see https://github.com/material-components/material-components-android/issues/882 + newDate.timeZone = TimeZone.getTimeZone("UTC") + newDate.timeInMillis = selection + scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] + openPickTimeDialog() + } + + private fun onTimeSet(hourOfDay: Int, minute: Int) { + initializeSuggestedTime() + scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay) + scheduleDateTime?.set(Calendar.MINUTE, minute) + updateScheduleUi() + listener?.onTimeSet(time) + } + + val time: String? + get() = scheduleDateTime?.time?.let { iso8601.format(it) } + + private fun initializeSuggestedTime() { + if (scheduleDateTime == null) { + scheduleDateTime = calendar().apply { + add(Calendar.MINUTE, 15) + } + } + } + + companion object { + var MINIMUM_SCHEDULED_SECONDS = 330 // Minimum is 5 minutes, pad 30 seconds for posting + fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault()) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index 2a1c74467..087a70359 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -63,4 +63,16 @@ class EditTextTyped @JvmOverloads constructor( editorInfo )!! } + + /** + * Override pasting to ensure that formatted content is always pasted as + * plain text. + */ + override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.paste) { + return super.onTextContextMenuItem(android.R.id.pasteAsPlainText) + } + + return super.onTextContextMenuItem(id) + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java deleted file mode 100644 index 3be800e78..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java +++ /dev/null @@ -1,121 +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.components.compose.view; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.RectF; -import android.graphics.drawable.Drawable; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.widget.AppCompatImageView; -import android.util.AttributeSet; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.view.MediaPreviewImageView; -import at.connyduck.sparkbutton.helpers.Utils; - -public final class ProgressImageView extends MediaPreviewImageView { - - private int progress = -1; - private final RectF progressRect = new RectF(); - private final RectF biggerRect = new RectF(); - private final Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint clearPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint markBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private Drawable captionDrawable; - - public ProgressImageView(Context context) { - super(context); - init(); - } - - public ProgressImageView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - init(); - } - - public ProgressImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - circlePaint.setColor(getContext().getColor(R.color.tusky_blue)); - circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4)); - circlePaint.setStyle(Paint.Style.STROKE); - - clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); - - markBgPaint.setStyle(Paint.Style.FILL); - markBgPaint.setColor(getContext().getColor(R.color.tusky_grey_10)); - captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck); - } - - public void setProgress(int progress) { - this.progress = progress; - if (progress != -1) { - setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY); - } else { - clearColorFilter(); - } - invalidate(); - } - - public void setChecked(boolean checked) { - this.markBgPaint.setColor(getContext().getColor(checked ? R.color.tusky_blue : R.color.tusky_grey_10)); - invalidate(); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - - float angle = (progress / 100f) * 360 - 90; - float halfWidth = getWidth() / 2; - float halfHeight = getHeight() / 2; - progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f); - biggerRect.set(progressRect); - int margin = 8; - biggerRect.set(progressRect.left - margin, progressRect.top - margin, progressRect.right + margin, progressRect.bottom + margin); - canvas.saveLayer(biggerRect, null, Canvas.ALL_SAVE_FLAG); - if (progress != -1) { - canvas.drawOval(progressRect, circlePaint); - canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint); - } - canvas.restore(); - - int circleRadius = Utils.dpToPx(getContext(), 14); - int circleMargin = Utils.dpToPx(getContext(), 14); - - int circleY = getHeight() - circleMargin - circleRadius / 2; - int circleX = getWidth() - circleMargin - circleRadius / 2; - - canvas.drawCircle(circleX, circleY, circleRadius, markBgPaint); - - captionDrawable.setBounds(getWidth() - circleMargin - circleRadius, - getHeight() - circleMargin - circleRadius, - getWidth() - circleMargin, - getHeight() - circleMargin); - captionDrawable.setTint(Color.WHITE); - captionDrawable.draw(canvas); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt new file mode 100644 index 000000000..6678356ea --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt @@ -0,0 +1,103 @@ +/* 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.components.compose.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.util.AttributeSet +import androidx.appcompat.content.res.AppCompatResources +import at.connyduck.sparkbutton.helpers.Utils +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.view.MediaPreviewImageView + +class ProgressImageView +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MediaPreviewImageView(context, attrs, defStyleAttr) { + private var progress = -1 + private val progressRect = RectF() + private val biggerRect = RectF() + private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(R.color.tusky_blue) + strokeWidth = Utils.dpToPx(context, 4).toFloat() + style = Paint.Style.STROKE + } + private val clearPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) + } + private val markBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = context.getColor(R.color.tusky_grey_10) + } + private val captionDrawable = AppCompatResources.getDrawable( + context, + R.drawable.spellcheck + )!!.apply { + setTint(Color.WHITE) + } + private val circleRadius = Utils.dpToPx(context, 14) + private val circleMargin = Utils.dpToPx(context, 14) + + fun setProgress(progress: Int) { + this.progress = progress + if (progress != -1) { + setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY) + } else { + clearColorFilter() + } + invalidate() + } + + fun setChecked(checked: Boolean) { + markBgPaint.color = + context.getColor(if (checked) R.color.tusky_blue else R.color.tusky_grey_10) + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val angle = progress / 100f * 360 - 90 + val halfWidth = width / 2f + val halfHeight = height / 2f + progressRect[halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f] = halfHeight * 1.25f + biggerRect.set(progressRect) + val margin = 8 + biggerRect[progressRect.left - margin, progressRect.top - margin, progressRect.right + margin] = + progressRect.bottom + margin + canvas.saveLayer(biggerRect, null) + if (progress != -1) { + canvas.drawOval(progressRect, circlePaint) + canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint) + } + canvas.restore() + val circleY = height - circleMargin - circleRadius / 2 + val circleX = width - circleMargin - circleRadius / 2 + canvas.drawCircle(circleX.toFloat(), circleY.toFloat(), circleRadius.toFloat(), markBgPaint) + captionDrawable.setBounds( + width - circleMargin - circleRadius, + height - circleMargin - circleRadius, + width - circleMargin, + height - circleMargin + ) + captionDrawable.draw(canvas) + } +} 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 22da6de27..fc561d403 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 @@ -83,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); - setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); + setMetaData(statusViewData, statusDisplayOptions, listener); setIsReply(status.getInReplyToId() != null); setFavourited(status.getFavourited()); setBookmarked(status.getBookmarked()); @@ -121,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { if (payloads instanceof List) { for (Object item : (List) payloads) { if (Key.KEY_CREATED.equals(item)) { - setCreatedAt(status.getCreatedAt(), status.getEditedAt(), statusDisplayOptions); + setMetaData(statusViewData, statusDisplayOptions, listener); } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 61023ddbe..d5ac079c8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -65,6 +65,7 @@ class DraftHelper @Inject constructor( failedToSend: Boolean, scheduledAt: String?, language: String?, + statusId: String?, ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -124,6 +125,7 @@ class DraftHelper @Inject constructor( failedToSend = failedToSend, scheduledAt = scheduledAt, language = language, + statusId = statusId, ) draftDao.insertOrReplace(draft) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index dfa361684..7cdcd2597 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -25,8 +25,7 @@ import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose +import at.connyduck.calladapter.networkresult.fold import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -37,7 +36,6 @@ import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import retrofit2.HttpException @@ -88,13 +86,17 @@ class DraftsActivity : BaseActivity(), DraftActionListener { } override fun onOpenDraft(draft: DraftEntity) { + if (draft.inReplyToId == null) { + openDraftWithoutReply(draft) + return + } - if (draft.inReplyToId != null) { + val context = this as Context + + lifecycleScope.launch { bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED viewModel.getStatus(draft.inReplyToId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this)) - .subscribe( + .fold( { status -> val composeOptions = ComposeActivity.ComposeOptions( draftId = draft.id, @@ -109,14 +111,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener { visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, + statusId = draft.statusId, + kind = ComposeActivity.ComposeKind.EDIT_DRAFT ) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN - startActivity(ComposeActivity.startIntent(this, composeOptions)) + startActivity(ComposeActivity.startIntent(context, composeOptions)) }, { throwable -> - bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN Log.w(TAG, "failed loading reply information", throwable) @@ -124,7 +127,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { if (throwable is HttpException && throwable.code() == 404) { // the original status to which a reply was drafted has been deleted // let's open the ComposeActivity without reply information - Toast.makeText(this, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show() + Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show() openDraftWithoutReply(draft) } else { Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) @@ -132,8 +135,6 @@ class DraftsActivity : BaseActivity(), DraftActionListener { } } ) - } else { - openDraftWithoutReply(draft) } } @@ -148,6 +149,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener { visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, + statusId = draft.statusId, + kind = ComposeActivity.ComposeKind.EDIT_DRAFT ) startActivity(ComposeActivity.startIntent(this, composeOptions)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index 0c370222f..694398033 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -20,12 +20,12 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.launch import javax.inject.Inject @@ -60,7 +60,7 @@ class DraftsViewModel @Inject constructor( } } - fun getStatus(statusId: String): Single { + suspend fun getStatus(statusId: String): NetworkResult { return api.status(statusId) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index af70cce2e..b855049d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -174,11 +174,11 @@ public class NotificationHelper { notificationId++; builder.setContentTitle(titleForType(context, body, account)) - .setContentText(bodyForType(body, context)); + .setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler())); if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) { builder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(bodyForType(body, context))); + .bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler()))); } //load the avatar synchronously @@ -371,6 +371,7 @@ public class NotificationHelper { composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setModifiedInitialState(true); composeOptions.setLanguage(actionableStatus.getLanguage()); + composeOptions.setKind(ComposeActivity.ComposeKind.NEW); Intent composeIntent = ComposeActivity.startIntent( context, @@ -694,7 +695,7 @@ public class NotificationHelper { return null; } - private static String bodyForType(Notification notification, Context context) { + private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) { switch (notification.getType()) { case FOLLOW: case FOLLOW_REQUEST: @@ -704,13 +705,13 @@ public class NotificationHelper { case FAVOURITE: case REBLOG: case STATUS: - if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { + if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) { return notification.getStatus().getSpoilerText(); } else { return parseAsMastodonHtml(notification.getStatus().getContent()).toString(); } case POLL: - if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { + if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) { return notification.getStatus().getSpoilerText(); } else { StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent())); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index d60210ba5..3c919327c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -16,11 +16,13 @@ package com.keylesspalace.tusky.components.preference import android.content.Intent +import android.graphics.Color import android.os.Build import android.os.Bundle import android.util.Log import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BaseActivity @@ -47,7 +49,6 @@ import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference -import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.getInitialLanguage import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName @@ -80,7 +81,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.pref_title_edit_notification_settings) icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).apply { sizeRes = R.dimen.preference_icon_size - colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) } setOnPreferenceClickListener { openNotificationPrefs() @@ -135,7 +136,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.action_view_blocks) icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply { sizeRes = R.dimen.preference_icon_size - colorInt = ThemeUtils.getColor(context, R.attr.iconColor) + colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) } setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) @@ -323,6 +324,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + override fun onResume() { + super.onResume() + requireActivity().setTitle(R.string.action_view_account_preferences) + } + private fun openNotificationPrefs() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index b6eb43894..83a96ed5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -204,6 +204,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + override fun onResume() { + super.onResume() + requireActivity().setTitle(R.string.pref_title_edit_notification_settings) + } + companion object { fun newInstance(): NotificationPreferencesFragment { return NotificationPreferencesFragment() 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 ca6f8f1dd..627439393 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 @@ -23,6 +23,8 @@ import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.fragment.app.commit +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.MainActivity @@ -31,8 +33,9 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.getNonNullString +import com.keylesspalace.tusky.util.setAppNightMode import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject @@ -40,6 +43,7 @@ import javax.inject.Inject class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener, + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, HasAndroidInjector { @Inject @@ -81,8 +85,6 @@ class PreferencesActivity : GENERAL_PREFERENCES -> PreferencesFragment.newInstance() ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance() NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance() - TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance() - PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance() else -> throw IllegalArgumentException("preferenceType not known") } @@ -90,18 +92,34 @@ class PreferencesActivity : replace(R.id.fragment_container, fragment, fragmentTag) } - when (preferenceType) { - GENERAL_PREFERENCES -> setTitle(R.string.action_view_preferences) - ACCOUNT_PREFERENCES -> setTitle(R.string.action_view_account_preferences) - NOTIFICATION_PREFERENCES -> setTitle(R.string.pref_title_edit_notification_settings) - TAB_FILTER_PREFERENCES -> setTitle(R.string.pref_title_post_tabs) - PROXY_PREFERENCES -> setTitle(R.string.pref_title_http_proxy_settings) - } - onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } + override fun onPreferenceStartFragment( + caller: PreferenceFragmentCompat, + pref: Preference + ): Boolean { + val args = pref.extras + val fragment = supportFragmentManager.fragmentFactory.instantiate( + classLoader, + pref.fragment!! + ) + fragment.arguments = args + fragment.setTargetFragment(caller, 0) + supportFragmentManager.commit { + setCustomAnimations( + R.anim.slide_from_right, + R.anim.slide_to_left, + R.anim.slide_from_left, + R.anim.slide_to_right + ) + replace(R.id.fragment_container, fragment) + addToBackStack(null) + } + return true + } + override fun onResume() { super.onResume() PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) @@ -124,9 +142,9 @@ class PreferencesActivity : override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { when (key) { "appTheme" -> { - val theme = sharedPreferences.getNonNullString("appTheme", ThemeUtils.APP_THEME_DEFAULT) + val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT) Log.d("activeTheme", theme) - ThemeUtils.setAppNightMode(theme) + setAppNightMode(theme) restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() @@ -158,8 +176,6 @@ class PreferencesActivity : const val GENERAL_PREFERENCES = 0 const val ACCOUNT_PREFERENCES = 1 const val NOTIFICATION_PREFERENCES = 2 - const val TAB_FILTER_PREFERENCES = 3 - const val PROXY_PREFERENCES = 4 private const val EXTRA_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE" private const val EXTRA_RESTART_ON_BACK = "restart" 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 7fadb4979..bf750a313 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 @@ -35,7 +35,6 @@ import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.deserialize -import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.makeIcon import com.keylesspalace.tusky.util.serialize import com.mikepenz.iconics.IconicsDrawable @@ -52,7 +51,26 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { lateinit var localeManager: LocaleManager private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } - private var httpProxyPref: Preference? = null + + enum class ReadingOrder { + /** User scrolls up, reading statuses oldest to newest */ + OLDEST_FIRST, + + /** User scrolls down, reading statuses newest to oldest. Default behaviour. */ + NEWEST_FIRST; + + companion object { + fun from(s: String?): ReadingOrder { + s ?: return NEWEST_FIRST + + return try { + valueOf(s.uppercase()) + } catch (_: Throwable) { + NEWEST_FIRST + } + } + } + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { @@ -96,6 +114,16 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) } + listPreference { + setDefaultValue(ReadingOrder.NEWEST_FIRST.name) + setEntries(R.array.reading_order_names) + setEntryValues(R.array.reading_order_values) + key = PrefKeys.READING_ORDER + setSummaryProvider { entry } + setTitle(R.string.pref_title_reading_order) + icon = makeIcon(GoogleMaterial.Icon.gmd_sort) + } + listPreference { setDefaultValue("top") setEntries(R.array.pref_main_nav_position_options) @@ -246,14 +274,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceCategory(R.string.pref_title_timeline_filters) { preference { setTitle(R.string.pref_title_post_tabs) - setOnPreferenceClickListener { - activity?.let { activity -> - val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) - activity.startActivity(intent) - activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) - } - true - } + fragment = TabFilterPreferencesFragment::class.qualifiedName } } @@ -297,16 +318,10 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } preferenceCategory(R.string.pref_title_proxy_settings) { - httpProxyPref = preference { + preference { setTitle(R.string.pref_title_http_proxy_settings) - setOnPreferenceClickListener { - activity?.let { activity -> - val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.PROXY_PREFERENCES) - activity.startActivity(intent) - activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) - } - true - } + fragment = ProxyPreferencesFragment::class.qualifiedName + summaryProvider = ProxyPreferencesFragment.SummaryProvider } } @@ -358,28 +373,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { override fun onResume() { super.onResume() - updateHttpProxySummary() - } - - private fun updateHttpProxySummary() { - preferenceManager.sharedPreferences?.let { sharedPreferences -> - val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false) - val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "") - - try { - val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1") - .toInt() - - if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) { - httpProxyPref?.summary = "$httpServer:$httpPort" - return - } - } catch (e: NumberFormatException) { - // user has entered wrong port, fall back to empty summary - } - - httpProxyPref?.summary = "" - } + requireActivity().setTitle(R.string.action_view_preferences) } override fun onDisplayPreferenceDialog(preference: Preference) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index 322b0c1da..223184406 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -16,12 +16,18 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.settings.editTextPreference +import com.keylesspalace.tusky.settings.ProxyConfiguration +import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MAX_PROXY_PORT +import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MIN_PROXY_PORT import com.keylesspalace.tusky.settings.makePreferenceScreen +import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference +import com.keylesspalace.tusky.settings.validatedEditTextPreference +import com.keylesspalace.tusky.util.getNonNullString import kotlin.system.exitProcess class ProxyPreferencesFragment : PreferenceFragmentCompat() { @@ -36,22 +42,38 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { setDefaultValue(false) } - editTextPreference { - setTitle(R.string.pref_title_http_proxy_server) - key = PrefKeys.HTTP_PROXY_SERVER - isIconSpaceReserved = false - setSummaryProvider { text } - } + preferenceCategory { category -> + category.dependency = PrefKeys.HTTP_PROXY_ENABLED + category.isIconSpaceReserved = false - editTextPreference { - setTitle(R.string.pref_title_http_proxy_port) - key = PrefKeys.HTTP_PROXY_PORT - isIconSpaceReserved = false - setSummaryProvider { text } + validatedEditTextPreference(null, ProxyConfiguration::isValidHostname) { + setTitle(R.string.pref_title_http_proxy_server) + key = PrefKeys.HTTP_PROXY_SERVER + isIconSpaceReserved = false + setSummaryProvider { text } + } + + val portErrorMessage = getString( + R.string.pref_title_http_proxy_port_message, + ProxyConfiguration.MIN_PROXY_PORT, + ProxyConfiguration.MAX_PROXY_PORT + ) + + validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) { + setTitle(R.string.pref_title_http_proxy_port) + key = PrefKeys.HTTP_PROXY_PORT + isIconSpaceReserved = false + setSummaryProvider { text } + } } } } + override fun onResume() { + super.onResume() + requireActivity().setTitle(R.string.pref_title_http_proxy_settings) + } + override fun onPause() { super.onPause() if (pendingRestart) { @@ -60,6 +82,33 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { } } + object SummaryProvider : Preference.SummaryProvider { + override fun provideSummary(preference: Preference): CharSequence { + val sharedPreferences = preference.sharedPreferences + sharedPreferences ?: return "" + + if (!sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)) { + return preference.context.getString(R.string.pref_summary_http_proxy_disabled) + } + + val missing = preference.context.getString(R.string.pref_summary_http_proxy_missing) + + val server = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, missing) + val port = try { + sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1").toInt() + } catch (e: NumberFormatException) { + -1 + } + + if (port < MIN_PROXY_PORT || port > MAX_PROXY_PORT) { + val invalid = preference.context.getString(R.string.pref_summary_http_proxy_invalid) + return "$server:$invalid" + } + + return "$server:$port" + } + } + companion object { fun newInstance(): ProxyPreferencesFragment { return ProxyPreferencesFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index c0a763294..023905291 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -46,6 +46,11 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() { } } + override fun onResume() { + super.onResume() + requireActivity().setTitle(R.string.pref_title_post_tabs) + } + companion object { fun newInstance(): TabFilterPreferencesFragment { return TabFilterPreferencesFragment() 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 9f99da530..fe9215a43 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 @@ -154,52 +154,46 @@ class ReportViewModel @Inject constructor( fun toggleMute() { val alreadyMuted = muteStateMutable.value?.data == true - if (alreadyMuted) { - mastodonApi.unmuteAccount(accountId) - } else { - mastodonApi.muteAccount(accountId) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { relationship -> - val muting = relationship.muting - muteStateMutable.value = Success(muting) - if (muting) { - eventHub.dispatch(MuteEvent(accountId)) - } - }, - { error -> - muteStateMutable.value = Error(false, error.message) + viewModelScope.launch { + try { + val relationship = if (alreadyMuted) { + mastodonApi.unmuteAccount(accountId) + } else { + mastodonApi.muteAccount(accountId) } - ).autoDispose() + + val muting = relationship.muting + muteStateMutable.value = Success(muting) + if (muting) { + eventHub.dispatch(MuteEvent(accountId)) + } + } catch (t: Throwable) { + muteStateMutable.value = Error(false, t.message) + } + } muteStateMutable.value = Loading() } fun toggleBlock() { val alreadyBlocked = blockStateMutable.value?.data == true - if (alreadyBlocked) { - mastodonApi.unblockAccount(accountId) - } else { - mastodonApi.blockAccount(accountId) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { relationship -> - val blocking = relationship.blocking - blockStateMutable.value = Success(blocking) - if (blocking) { - eventHub.dispatch(BlockEvent(accountId)) - } - }, - { error -> - blockStateMutable.value = Error(false, error.message) + viewModelScope.launch { + try { + val relationship = if (alreadyBlocked) { + mastodonApi.unblockAccount(accountId) + } else { + mastodonApi.blockAccount(accountId) } - ) - .autoDispose() + val blocking = relationship.blocking + blockStateMutable.value = Success(blocking) + if (blocking) { + eventHub.dispatch(BlockEvent(accountId)) + } + } catch (t: Throwable) { + blockStateMutable.value = Error(false, t.message) + } + } blockStateMutable.value = Loading() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 6dc612b69..f4035606d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -31,8 +31,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER -import com.keylesspalace.tusky.util.TimestampUtils import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.setClickableMentions import com.keylesspalace.tusky.util.setClickableText @@ -161,7 +161,7 @@ class StatusViewHolder( binding.timestampInfo.text = if (createdAt != null) { val then = createdAt.time val now = System.currentTimeMillis() - TimestampUtils.getRelativeTimeSpanString(binding.timestampInfo.context, then, now) + getRelativeTimeSpanString(binding.timestampInfo.context, then, now) } else { // unknown minutes~ "?m" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index 43ce346d6..ade93322f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -128,7 +128,8 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I inReplyToId = item.params.inReplyToId, visibility = item.params.visibility, scheduledAt = item.scheduledAt, - sensitive = item.params.sensitive + sensitive = item.params.sensitive, + kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED ) ) startActivity(intent) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt index 7b34b7804..9d51bc53c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.scheduled import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil @@ -53,8 +52,7 @@ class ScheduledStatusAdapter( holder.binding.edit.isEnabled = true holder.binding.delete.isEnabled = true holder.binding.text.text = item.params.text - holder.binding.edit.setOnClickListener { v: View -> - v.isEnabled = false + holder.binding.edit.setOnClickListener { listener.edit(item) } holder.binding.delete.setOnClickListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index d5071a554..024d55ca6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -62,6 +63,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { } private fun setupPages() { + binding.pages.reduceSwipeSensitivity() binding.pages.adapter = SearchPagerAdapter(this) binding.pages.offscreenPageLimit = 4 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 93d8ab5a0..1e7201130 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.components.search.adapter.SearchNotestockPagingSourceFactory import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory @@ -34,7 +35,9 @@ import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch import javax.inject.Inject class SearchViewModel @Inject constructor( @@ -123,17 +126,13 @@ class SearchViewModel @Inject constructor( } fun removeItem(statusViewData: StatusViewData.Concrete) { - timelineCases.delete(statusViewData.id) - .subscribe( - { - if (loadedStatuses.remove(statusViewData)) - statusesPagingSourceFactory.invalidate() - }, - { err -> - Log.d(TAG, "Failed to delete status", err) + viewModelScope.launch { + if (timelineCases.delete(statusViewData.id).isSuccess) { + if (loadedStatuses.remove(statusViewData)) { + statusesPagingSourceFactory.invalidate() } - ) - .autoDispose() + } + } } fun removeNotestockItem(statusViewData: StatusViewData.Concrete) { @@ -224,7 +223,9 @@ class SearchViewModel @Inject constructor( } fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { - timelineCases.mute(accountId, notifications, duration) + viewModelScope.launch { + timelineCases.mute(accountId, notifications, duration) + } } fun pinAccount(status: Status, isPin: Boolean) { @@ -232,11 +233,15 @@ class SearchViewModel @Inject constructor( } fun blockAccount(accountId: String) { - timelineCases.block(accountId) + viewModelScope.launch { + timelineCases.block(accountId) + } } - fun deleteStatus(id: String): Single { - return timelineCases.delete(id) + fun deleteStatusAsync(id: String): Deferred> { + return viewModelScope.async { + timelineCases.delete(id) + } } fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt index c4a3e8261..a8b913081 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -19,24 +19,27 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AccountViewHolder +import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.LinkListener -class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) : +class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean, private val showBotOverlay: Boolean) : PagingDataAdapter(ACCOUNT_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_account, parent, false) - return AccountViewHolder(view) + val binding = ItemAccountBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return AccountViewHolder(binding) } override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { getItem(position)?.let { item -> holder.apply { - setupWithAccount(item, animateAvatars, animateEmojis) + setupWithAccount(item, animateAvatars, animateEmojis, showBotOverlay) setupLinkListener(linkListener) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index f59f84ff6..87be69807 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -30,7 +30,8 @@ class SearchAccountsFragment : SearchFragment() { return SearchAccountsAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 7e9479948..3ebf73b96 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -22,6 +22,7 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.Flow @@ -38,6 +39,9 @@ abstract class SearchFragment : @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var mastodonApi: MastodonApi + protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } protected val binding by viewBinding(FragmentSearchBinding::bind) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt index b835a69d4..293a5e4c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt @@ -16,13 +16,13 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import autodispose2.androidx.lifecycle.autoDispose +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity @@ -42,8 +42,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch class SearchNotestockFragment : SearchFragment(), StatusActionListener { @@ -446,7 +446,7 @@ class SearchNotestockFragment : SearchFragment(), Statu AlertDialog.Builder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) + viewModel.deleteStatusAsync(id) removeItem(position) } .setNegativeButton(android.R.string.cancel, null) @@ -459,35 +459,38 @@ class SearchNotestockFragment : SearchFragment(), Statu AlertDialog.Builder(it) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe({ deletedStatus -> - removeItem(position) + lifecycleScope.launch { + viewModel.deleteStatusAsync(id).await().fold( + { deletedStatus -> + removeItem(position) - val redraftStatus = if (deletedStatus.isEmpty()) { - status.toDeletedStatus() - } else { - deletedStatus - } + val redraftStatus = if (deletedStatus.isEmpty()) { + status.toDeletedStatus() + } else { + deletedStatus + } - val intent = ComposeActivity.startIntent( - requireContext(), - ComposeOptions( - content = redraftStatus.text ?: "", - inReplyToId = redraftStatus.inReplyToId, - visibility = redraftStatus.visibility, - contentWarning = redraftStatus.spoilerText, - mediaAttachments = redraftStatus.attachments, - sensitive = redraftStatus.sensitive, - poll = redraftStatus.poll?.toNewPoll(status.createdAt) + val intent = ComposeActivity.startIntent( + requireContext(), + ComposeOptions( + content = redraftStatus.text ?: "", + inReplyToId = redraftStatus.inReplyToId, + visibility = redraftStatus.visibility, + contentWarning = redraftStatus.spoilerText, + mediaAttachments = redraftStatus.attachments, + sensitive = redraftStatus.sensitive, + poll = redraftStatus.poll?.toNewPoll(status.createdAt) + ) ) - ) - startActivity(intent) - }, { error -> - Log.w("SearchStatusesFragment", "error deleting status", error) - Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() - }) + startActivity(intent) + }, + { error -> + Log.w("SearchStatusesFragment", "error deleting status", error) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT) + .show() + } + ) + } } .setNegativeButton(android.R.string.cancel, null) .show() 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 e958f0944..9e2259708 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 @@ -32,14 +32,14 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity @@ -60,8 +60,8 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch class SearchStatusesFragment : SearchFragment(), StatusActionListener { @@ -226,6 +226,7 @@ class SearchStatusesFragment : SearchFragment(), Status replyingStatusAuthor = actionableStatus.account.localUsername, replyingStatusContent = status.content.toString(), language = actionableStatus.language, + kind = ComposeActivity.ComposeKind.NEW ) ) bottomSheetActivity?.startActivityWithSlideInAnimation(intent) @@ -381,6 +382,10 @@ class SearchStatusesFragment : SearchFragment(), Status showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } + R.id.status_edit -> { + editStatus(id, position, status) + return@setOnMenuItemClickListener true + } R.id.pin -> { viewModel.pinAccount(status, !status.isPinned()) return@setOnMenuItemClickListener true @@ -466,7 +471,7 @@ class SearchStatusesFragment : SearchFragment(), Status AlertDialog.Builder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) + viewModel.deleteStatusAsync(id) removeItem(position) } .setNegativeButton(android.R.string.cancel, null) @@ -479,10 +484,8 @@ class SearchStatusesFragment : SearchFragment(), Status AlertDialog.Builder(it) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> - viewModel.deleteStatus(id) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( + lifecycleScope.launch { + viewModel.deleteStatusAsync(id).await().fold( { deletedStatus -> removeItem(position) @@ -503,6 +506,7 @@ class SearchStatusesFragment : SearchFragment(), Status sensitive = redraftStatus.sensitive, poll = redraftStatus.poll?.toNewPoll(status.createdAt), language = redraftStatus.language, + kind = ComposeActivity.ComposeKind.NEW ) ) startActivity(intent) @@ -512,9 +516,39 @@ class SearchStatusesFragment : SearchFragment(), Status Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() } ) + } } .setNegativeButton(android.R.string.cancel, null) .show() } } + + private fun editStatus(id: String, position: Int, status: Status) { + lifecycleScope.launch { + mastodonApi.statusSource(id).fold( + { source -> + val composeOptions = ComposeOptions( + content = source.text, + inReplyToId = status.inReplyToId, + visibility = status.visibility, + contentWarning = source.spoilerText, + mediaAttachments = status.attachments, + sensitive = status.sensitive, + language = status.language, + statusId = source.id, + poll = status.poll?.toNewPoll(status.createdAt), + kind = ComposeActivity.ComposeKind.EDIT_POSTED, + ) + startActivity(ComposeActivity.startIntent(requireContext(), composeOptions)) + }, + { + Snackbar.make( + requireView(), + getString(R.string.error_status_source_load), + Snackbar.LENGTH_SHORT + ).show() + } + ) + } + } } 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 af2660761..224711a8e 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 @@ -44,11 +44,11 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.QuickReplyEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding -import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Status @@ -66,6 +66,7 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +import com.keylesspalace.tusky.viewdata.StatusViewData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.collectLatest @@ -89,9 +90,6 @@ class TimelineFragment : @Inject lateinit var eventHub: EventHub - @Inject - lateinit var accountManager: AccountManager - private val viewModel: TimelineViewModel by lazy { if (kind == TimelineViewModel.Kind.HOME) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] @@ -109,6 +107,38 @@ class TimelineFragment : private var isSwipeToRefreshEnabled = true private var hideFab = false + /** + * Adapter position of the placeholder that was most recently clicked to "Load more". If null + * then there is no active "Load more" operation + */ + private var loadMorePosition: Int? = null + + /** ID of the status immediately below the most recent "Load more" placeholder click */ + // The Paging library assumes that the user will be scrolling down a list of items, + // and if new items are loaded but not visible then it's reasonable to scroll to the top + // of the inserted items. It does not seem to be possible to disable that behaviour. + // + // That behaviour should depend on the user's preferred reading order. If they prefer to + // read oldest first then the list should be scrolled to the bottom of the freshly + // inserted statuses. + // + // To do this: + // + // 1. When "Load more" is clicked (onLoadMore()): + // a. Remember the adapter position of the "Load more" item in loadMorePosition + // b. Remember the ID of the status immediately below the "Load more" item in + // statusIdBelowLoadMore + // 2. After the new items have been inserted, search the adapter for the position of the + // status with id == statusIdBelowLoadMore. + // 3. If this position is still visible on screen then do nothing, otherwise, scroll the view + // so that the status is visible. + // + // The user can then scroll up to read the new statuses. + private var statusIdBelowLoadMore: String? = null + + /** The user's preferred reading order */ + private lateinit var readingOrder: ReadingOrder + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -142,6 +172,8 @@ class TimelineFragment : isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) + val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, @@ -229,6 +261,9 @@ class TimelineFragment : } } } + if (readingOrder == ReadingOrder.OLDEST_FIRST) { + updateReadingPositionForOldestFirst() + } } }) @@ -275,6 +310,33 @@ class TimelineFragment : } } + /** + * Set the correct reading position in the timeline after the user clicked "Load more", + * assuming the reading position should be below the freshly-loaded statuses. + */ + // Note: The positionStart parameter to onItemRangeInserted() does not always + // match the adapter position where data was inserted (which is why loadMorePosition + // is tracked manually, see this bug report for another example: + // https://github.com/android/architecture-components-samples/issues/726). + private fun updateReadingPositionForOldestFirst() { + var position = loadMorePosition ?: return + val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return + + var status: StatusViewData? + while (adapter.peek(position).let { status = it; it != null }) { + if (status?.id == statusIdBelowLoadMore) { + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + if (position > lastVisiblePosition) { + binding.recyclerView.scrollToPosition(position) + } + break + } + position++ + } + loadMorePosition = null + } + private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.setOnRefreshListener(this) @@ -386,6 +448,8 @@ class TimelineFragment : override fun onLoadMore(position: Int) { val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return + loadMorePosition = position + statusIdBelowLoadMore = adapter.peek(position + 1)?.id viewModel.loadMore(placeholder.id) } @@ -446,6 +510,11 @@ class TimelineFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } } + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from( + sharedPreferences.getString(PrefKeys.READING_ORDER, null) + ) + } } } 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 4ecb4d3d0..8bdcd8239 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 @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline +import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity @@ -30,6 +31,8 @@ import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date +private const val TAG = "TimelineTypeMappers" + data class Placeholder( val id: String, val loading: Boolean @@ -151,8 +154,9 @@ fun Status.toEntity( ) } -fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { - if (this.status.authorServerId == null) { +fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { + if (this.status.isPlaceholder) { + Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) } @@ -267,6 +271,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, - isCollapsed = this.status.contentCollapsed + isCollapsed = this.status.contentCollapsed, + isDetailed = isDetailed ) } 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 b29ab0cf6..a15577646 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 @@ -153,7 +153,14 @@ class CachedTimelineRemoteMediator( if (oldStatus != null) break } - val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + // The "expanded" property for Placeholders determines whether or not they are + // in the "loading" state, and should not be affected by the account's + // "alwaysOpenSpoiler" preference + val expanded = if (oldStatus?.isPlaceholder == true) { + oldStatus.expanded + } else { + oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler + } val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive val contentCollapsed = oldStatus?.contentCollapsed ?: true 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 20c5ea5cc..6e1c6a151 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,6 +32,8 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent +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.toEntity import com.keylesspalace.tusky.components.timeline.toViewData @@ -173,13 +175,23 @@ class CachedTimelineViewModel @Inject constructor( val response = db.withTransaction { val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) - val nextPlaceholderId = - timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) - api.homeTimeline( - maxId = idAbovePlaceholder, - sinceId = nextPlaceholderId, - limit = LOAD_AT_ONCE - ) + val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) + when (readingOrder) { + // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately + // after minId and no larger than maxId + OLDEST_FIRST -> api.homeTimeline( + maxId = idAbovePlaceholder, + minId = idBelowPlaceholder, + limit = LOAD_AT_ONCE + ) + // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before + // maxId, and no smaller than minId. + NEWEST_FIRST -> api.homeTimeline( + maxId = idAbovePlaceholder, + sinceId = idBelowPlaceholder, + limit = LOAD_AT_ONCE + ) + } } val statuses = response.body() @@ -222,12 +234,16 @@ class CachedTimelineViewModel @Inject constructor( /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { - /* This overrides the last of the newly loaded statuses with a placeholder + /* This overrides the first/last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ + val idToConvert = when (readingOrder) { + OLDEST_FIRST -> statuses.first().id + NEWEST_FIRST -> statuses.last().id + } timelineDao.insertStatus( Placeholder( - statuses.last().id, + idToConvert, loading = false ).toEntity(activeAccount.id) ) 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 a55834370..e8f670d35 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 @@ -283,7 +283,7 @@ class NetworkTimelineViewModel @Inject constructor( limit: Int ): Response> { return when (kind) { - Kind.HOME -> api.homeTimeline(fromId, uptoId, limit) + Kind.HOME -> api.homeTimeline(maxId = fromId, sinceId = uptoId, limit = limit) Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) Kind.TAG -> { 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 e1ea74865..ecf404670 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -20,6 +20,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent @@ -34,6 +35,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StreamUpdateEvent import com.keylesspalace.tusky.appstore.UnfollowEvent +import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter @@ -58,7 +60,7 @@ abstract class TimelineViewModel( private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, - private val sharedPreferences: SharedPreferences, + protected val sharedPreferences: SharedPreferences, private val filterModel: FilterModel, private val streamingManager: StreamingManager, ) : ViewModel() { @@ -76,6 +78,7 @@ abstract class TimelineViewModel( protected var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false + protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST val shouldReplyInQuick by lazy { when (kind) { @@ -146,6 +149,8 @@ abstract class TimelineViewModel( filterRemoveReblogs = !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } + readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) + this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler @@ -183,7 +188,7 @@ abstract class TimelineViewModel( timelineCases.bookmark(status.actionableId, bookmark).await() } catch (t: Exception) { ifExpected(t) { - Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } @@ -272,6 +277,9 @@ abstract class TimelineViewModel( alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia } + PrefKeys.READING_ORDER -> { + readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) + } } } @@ -346,10 +354,8 @@ abstract class TimelineViewModel( private fun reloadFilters() { viewModelScope.launch { - val filters = try { - api.getFilters().await() - } catch (t: Exception) { - Log.e(TAG, "Failed to fetch filters", t) + val filters = api.getFilters().getOrElse { + Log.e(TAG, "Failed to fetch filters", it) return@launch } filterModel.initWithFilters( 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 9e0903b06..7f900de6c 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 @@ -62,6 +62,7 @@ class ThreadAdapter( } companion object { + private const val TAG = "ThreadAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_DETAILED = 1 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 76cf930f4..148a1f6cb 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 @@ -21,6 +21,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.annotation.CheckResult +import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager @@ -34,6 +36,7 @@ import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -49,6 +52,9 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject @@ -106,6 +112,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, binding.toolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } + binding.toolbar.inflateMenu(R.menu.view_thread_toolbar) binding.toolbar.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.action_reveal -> { @@ -141,24 +148,50 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) + var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) + viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> when (uiState) { is ThreadUiState.Loading -> { updateRevealButton(RevealButtonState.NO_BUTTON) + binding.recyclerView.hide() binding.statusView.hide() - binding.progressBar.show() + + initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) + initialProgressBar.start() + } + is ThreadUiState.LoadingThread -> { + if (uiState.statusViewDatum == null) { + // no detailed statuses available, e.g. because author is blocked + activity?.finish() + return@collect + } + + initialProgressBar.cancel() + threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) + threadProgressBar.start() + + adapter.submitList(listOf(uiState.statusViewDatum)) + + updateRevealButton(uiState.revealButton) + binding.swipeRefreshLayout.isRefreshing = false + + binding.recyclerView.show() + binding.statusView.hide() } is ThreadUiState.Error -> { Log.w(TAG, "failed to load status", uiState.throwable) + initialProgressBar.cancel() + threadProgressBar.cancel() updateRevealButton(RevealButtonState.NO_BUTTON) binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() - binding.progressBar.hide() if (uiState.throwable is IOException) { binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { @@ -171,28 +204,31 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } } is ThreadUiState.Success -> { - if (uiState.statuses.none { viewData -> viewData.isDetailed }) { + if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { // no detailed statuses available, e.g. because author is blocked activity?.finish() return@collect } - adapter.submitList(uiState.statuses) { + threadProgressBar.cancel() + + adapter.submitList(uiState.statusViewData) { if (viewModel.isInitialLoad) { viewModel.isInitialLoad = false - val detailedPosition = adapter.currentList.indexOfFirst { viewData -> - viewData.isDetailed - } - binding.recyclerView.scrollToPosition(detailedPosition) + + // Ensure the top of the status is visible + (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0) } } updateRevealButton(uiState.revealButton) - binding.swipeRefreshLayout.isRefreshing = uiState.refreshing + binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() - binding.progressBar.hide() + } + is ThreadUiState.Refreshing -> { + threadProgressBar.cancel() } } } @@ -212,6 +248,28 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.loadThread(thisThreadsStatusId) } + /** + * Create a job to implement a delayed-visible progress bar. + * + * Delaying the visibility of the progress bar can improve user perception of UI speed because + * fewer UI elements are appearing and disappearing. + * + * When started the job will wait `delayMs` then show `view`. If the job is cancelled at + * any time `view` is hidden. + */ + @CheckResult() + private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch( + start = CoroutineStart.LAZY + ) { + try { + delay(delayMs) + view.show() + awaitCancellation() + } finally { + view.hide() + } + } + private fun updateRevealButton(state: RevealButtonState) { val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal) @@ -331,6 +389,17 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.voteInPoll(choices, status) } + override fun onShowEdits(position: Int) { + val status = adapter.currentList[position] + val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) + + parentFragmentManager.commit { + setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) + replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id") + addToBackStack(null) + } + } + companion object { private const val TAG = "ViewThreadFragment" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index f4d7e14d5..181b26420 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse +import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub @@ -28,8 +29,10 @@ import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel @@ -54,7 +57,9 @@ class ViewThreadViewModel @Inject constructor( private val filterModel: FilterModel, private val timelineCases: TimelineCases, eventHub: EventHub, - accountManager: AccountManager + accountManager: AccountManager, + private val db: AppDatabase, + private val gson: Gson ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(ThreadUiState.Loading) @@ -95,36 +100,70 @@ class ViewThreadViewModel @Inject constructor( } fun loadThread(id: String) { + _uiState.value = ThreadUiState.Loading + viewModelScope.launch { + Log.d(TAG, "Finding status with: $id") val contextCall = async { api.statusContext(id) } - val statusCall = async { api.statusAsync(id) } + val timelineStatus = db.timelineDao().getStatus(id) - val contextResult = contextCall.await() - val statusResult = statusCall.await() + var detailedStatus = if (timelineStatus != null) { + Log.d(TAG, "Loaded status from local timeline") + val viewData = timelineStatus.toViewData( + gson, + isDetailed = true + ) as StatusViewData.Concrete - val status = statusResult.getOrElse { exception -> - _uiState.value = ThreadUiState.Error(exception) - return@launch + // Return the correct status, depending on which one matched. If you do not do + // this the status IDs will be different between the status that's displayed with + // ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent + // status content is the same. Then the status flickers as it is drawn twice. + if (viewData.actionableId == id) { + viewData.actionable.toViewData(isDetailed = true) + } else { + viewData + } + } else { + Log.d(TAG, "Loaded status from network") + val result = api.status(id).getOrElse { exception -> + _uiState.value = ThreadUiState.Error(exception) + return@launch + } + result.toViewData(isDetailed = true) } - contextResult.fold({ statusContext -> + _uiState.value = ThreadUiState.LoadingThread( + statusViewDatum = detailedStatus, + revealButton = detailedStatus.getRevealButtonState() + ) + // If the detailedStatus was loaded from the database it might be out-of-date + // compared to the remote one. Now the user has a working UI do a background fetch + // for the status. Ignore errors, the user still has a functioning UI if the fetch + // failed. + if (timelineStatus != null) { + val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true) + if (viewData != null) { detailedStatus = viewData } + } + + val contextResult = contextCall.await() + + contextResult.fold({ statusContext -> val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter() - val detailedStatus = status.toViewData(true) val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() val statuses = ancestors + detailedStatus + descendants _uiState.value = ThreadUiState.Success( - statuses = statuses, - revealButton = statuses.getRevealButtonState(), - refreshing = false + statusViewData = statuses, + detailedStatusPosition = ancestors.size, + revealButton = statuses.getRevealButtonState() ) }, { throwable -> _errors.emit(throwable) _uiState.value = ThreadUiState.Success( - statuses = listOf(status.toViewData(true)), + statusViewData = listOf(detailedStatus), + detailedStatusPosition = 0, revealButton = RevealButtonState.NO_BUTTON, - refreshing = false ) }) } @@ -136,15 +175,17 @@ class ViewThreadViewModel @Inject constructor( } fun refresh(id: String) { - updateSuccess { uiState -> - uiState.copy(refreshing = true) - } + _uiState.value = ThreadUiState.Refreshing loadThread(id) } fun detailedStatus(): StatusViewData.Concrete? { - return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status -> - status.isDetailed + return when (val uiState = _uiState.value) { + is ThreadUiState.Success -> uiState.statusViewData.find { status -> + status.isDetailed + } + is ThreadUiState.LoadingThread -> uiState.statusViewDatum + else -> null } } @@ -173,7 +214,7 @@ class ViewThreadViewModel @Inject constructor( timelineCases.bookmark(status.actionableId, bookmark).await() } catch (t: Exception) { ifExpected(t) { - Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } @@ -201,14 +242,14 @@ class ViewThreadViewModel @Inject constructor( fun removeStatus(statusToRemove: StatusViewData.Concrete) { updateSuccess { uiState -> uiState.copy( - statuses = uiState.statuses.filterNot { status -> status == statusToRemove } + statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove } ) } } fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { updateSuccess { uiState -> - val statuses = uiState.statuses.map { viewData -> + val statuses = uiState.statusViewData.map { viewData -> if (viewData.id == status.id) { viewData.copy(isExpanded = expanded) } else { @@ -216,7 +257,7 @@ class ViewThreadViewModel @Inject constructor( } } uiState.copy( - statuses = statuses, + statusViewData = statuses, revealButton = statuses.getRevealButtonState() ) } @@ -261,7 +302,7 @@ class ViewThreadViewModel @Inject constructor( private fun removeAllByAccountId(accountId: String) { updateSuccess { uiState -> uiState.copy( - statuses = uiState.statuses.filter { viewData -> + statusViewData = uiState.statusViewData.filter { viewData -> viewData.status.account.id != accountId } ) @@ -271,7 +312,7 @@ class ViewThreadViewModel @Inject constructor( private fun handleStatusComposedEvent(event: StatusComposedEvent) { val eventStatus = event.status updateSuccess { uiState -> - val statuses = uiState.statuses + val statuses = uiState.statusViewData val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } if (detailedIndex != -1 && repliedIndex >= detailedIndex) { @@ -279,7 +320,7 @@ class ViewThreadViewModel @Inject constructor( val newStatuses = statuses.subList(0, repliedIndex + 1) + eventStatus.toViewData() + statuses.subList(repliedIndex + 1, statuses.size) - uiState.copy(statuses = newStatuses) + uiState.copy(statusViewData = newStatuses) } else { uiState } @@ -289,7 +330,7 @@ class ViewThreadViewModel @Inject constructor( private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { updateSuccess { uiState -> uiState.copy( - statuses = uiState.statuses.filter { status -> + statusViewData = uiState.statusViewData.filter { status -> status.id != event.statusId } ) @@ -300,13 +341,13 @@ class ViewThreadViewModel @Inject constructor( updateSuccess { uiState -> when (uiState.revealButton) { RevealButtonState.HIDE -> uiState.copy( - statuses = uiState.statuses.map { viewData -> + statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = false) }, revealButton = RevealButtonState.REVEAL ) RevealButtonState.REVEAL -> uiState.copy( - statuses = uiState.statuses.map { viewData -> + statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = true) }, revealButton = RevealButtonState.HIDE @@ -316,16 +357,11 @@ class ViewThreadViewModel @Inject constructor( } } - private fun List.getRevealButtonState(): RevealButtonState { - val hasWarnings = any { viewData -> - viewData.status.spoilerText.isNotEmpty() - } + private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState { + val hasWarnings = status.spoilerText.isNotEmpty() return if (hasWarnings) { - val allExpanded = none { viewData -> - !viewData.isExpanded - } - if (allExpanded) { + if (isExpanded) { RevealButtonState.HIDE } else { RevealButtonState.REVEAL @@ -335,14 +371,38 @@ class ViewThreadViewModel @Inject constructor( } } + /** + * Get the reveal button state based on the state of all the statuses in the list. + * + * - If any status sets it to REVEAL, use REVEAL + * - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE + * - Otherwise use NO_BUTTON + */ + private fun List.getRevealButtonState(): RevealButtonState { + var seenHide = false + + forEach { + when (val state = it.getRevealButtonState()) { + RevealButtonState.NO_BUTTON -> return@forEach + RevealButtonState.REVEAL -> return state + RevealButtonState.HIDE -> seenHide = true + } + } + + if (seenHide) { + return RevealButtonState.HIDE + } + + return RevealButtonState.NO_BUTTON + } + private fun loadFilters() { viewModelScope.launch { - val filters = try { - api.getFilters().await() - } catch (t: Exception) { - Log.w(TAG, "Failed to fetch filters", t) + val filters = api.getFilters().getOrElse { + Log.w(TAG, "Failed to fetch filters", it) return@launch } + filterModel.initWithFilters( filters.filter { filter -> filter.context.contains(Filter.THREAD) @@ -350,9 +410,9 @@ class ViewThreadViewModel @Inject constructor( ) updateSuccess { uiState -> - val statuses = uiState.statuses.filter() + val statuses = uiState.statusViewData.filter() uiState.copy( - statuses = statuses, + statusViewData = statuses, revealButton = statuses.getRevealButtonState() ) } @@ -365,13 +425,15 @@ class ViewThreadViewModel @Inject constructor( } } - private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { - val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id } + private fun Status.toViewData( + isDetailed: Boolean = false + ): StatusViewData.Concrete { + val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id } return toViewData( isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, - isCollapsed = oldStatus?.isCollapsed ?: !detailed, - isDetailed = oldStatus?.isDetailed ?: detailed + isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, + isDetailed = oldStatus?.isDetailed ?: isDetailed ) } @@ -388,7 +450,7 @@ class ViewThreadViewModel @Inject constructor( private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { updateSuccess { uiState -> uiState.copy( - statuses = uiState.statuses.map { viewData -> + statusViewData = uiState.statusViewData.map { viewData -> if (viewData.id == statusId) { updater(viewData) } else { @@ -413,13 +475,27 @@ class ViewThreadViewModel @Inject constructor( } sealed interface ThreadUiState { + /** The initial load of the detailed status for this thread */ object Loading : ThreadUiState - class Error(val throwable: Throwable) : ThreadUiState - data class Success( - val statuses: List, - val revealButton: RevealButtonState, - val refreshing: Boolean + + /** Loading the detailed status has completed, now loading ancestors/descendants */ + data class LoadingThread( + val statusViewDatum: StatusViewData.Concrete?, + val revealButton: RevealButtonState ) : ThreadUiState + + /** An error occurred at any point */ + class Error(val throwable: Throwable) : ThreadUiState + + /** Successfully loaded the full thread */ + data class Success( + val statusViewData: List, + val revealButton: RevealButtonState, + val detailedStatusPosition: Int + ) : ThreadUiState + + /** Refreshing the thread with a swipe */ + object Refreshing : ThreadUiState } enum class RevealButtonState { 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 new file mode 100644 index 000000000..931f88b81 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -0,0 +1,185 @@ +package com.keylesspalace.tusky.components.viewthread.edits + +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.google.android.material.color.MaterialColors +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.PollAdapter +import com.keylesspalace.tusky.adapter.PollAdapter.Companion.MULTIPLE +import com.keylesspalace.tusky.adapter.PollAdapter.Companion.SINGLE +import com.keylesspalace.tusky.databinding.ItemStatusEditBinding +import com.keylesspalace.tusky.entity.Attachment.Focus +import com.keylesspalace.tusky.entity.StatusEdit +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.aspectRatios +import com.keylesspalace.tusky.util.decodeBlurHash +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.toViewData + +class ViewEditsAdapter( + private val edits: List, + private val animateAvatars: Boolean, + private val animateEmojis: Boolean, + private val useBlurhash: Boolean, + private val listener: LinkListener +) : RecyclerView.Adapter>() { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.statusEditMediaPreview.clipToOutline = true + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + + val edit = edits[position] + + val binding = holder.binding + + val context = binding.root.context + + val avatarRadius: Int = context.resources + .getDimensionPixelSize(R.dimen.avatar_radius_48dp) + + loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars) + + val infoStringRes = if (position == edits.size - 1) { + R.string.status_created_info + } else { + R.string.status_edit_info + } + + val timestamp = absoluteTimeFormatter.format(edit.createdAt, false) + + binding.statusEditInfo.text = context.getString( + infoStringRes, + edit.account.name, + timestamp + ).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis) + + if (edit.spoilerText.isEmpty()) { + binding.statusEditContentWarningDescription.hide() + binding.statusEditContentWarningSeparator.hide() + } else { + binding.statusEditContentWarningDescription.show() + binding.statusEditContentWarningSeparator.show() + binding.statusEditContentWarningDescription.text = edit.spoilerText.emojify( + edit.emojis, + binding.statusEditContentWarningDescription, + animateEmojis + ) + } + + val emojifiedText = edit.content.parseAsMastodonHtml().emojify(edit.emojis, binding.statusEditContent, animateEmojis) + setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) + + if (edit.poll == null) { + binding.statusEditPollOptions.hide() + binding.statusEditPollDescription.hide() + } else { + binding.statusEditPollOptions.show() + + // not used for now since not reported by the api + // https://github.com/mastodon/mastodon/issues/22571 + // binding.statusEditPollDescription.show() + + val pollAdapter = PollAdapter() + binding.statusEditPollOptions.adapter = pollAdapter + binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context) + + pollAdapter.setup( + options = edit.poll.options.map { it.toViewData(false) }, + voteCount = 0, + votersCount = null, + emojis = edit.emojis, + mode = if (edit.poll.multiple) { // not reported by the api + MULTIPLE + } else { + SINGLE + }, + resultClickListener = null, + animateEmojis = animateEmojis, + enabled = false + ) + } + + if (edit.mediaAttachments.isEmpty()) { + binding.statusEditMediaPreview.hide() + binding.statusEditMediaSensitivity.hide() + } else { + binding.statusEditMediaPreview.show() + binding.statusEditMediaPreview.aspectRatios = edit.mediaAttachments.aspectRatios() + + binding.statusEditMediaPreview.forEachIndexed { index, imageView, descriptionIndicator -> + + val attachment = edit.mediaAttachments[index] + val hasDescription = !attachment.description.isNullOrBlank() + + if (hasDescription) { + imageView.contentDescription = attachment.description + } else { + imageView.contentDescription = + imageView.context.getString(R.string.action_view_media) + } + descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE + + val blurhash = attachment.blurhash + + val placeholder: Drawable = if (blurhash != null && useBlurhash) { + decodeBlurHash(context, blurhash) + } else { + ColorDrawable(MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent)) + } + + if (attachment.previewUrl.isNullOrEmpty()) { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView) + } else { + val focus: Focus? = attachment.meta?.focus + + if (focus != null) { + imageView.setFocalPoint(focus) + Glide.with(imageView.context) + .load(attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView) + } else { + imageView.removeFocalPoint() + Glide.with(imageView) + .load(attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView) + } + } + } + binding.statusEditMediaSensitivity.visible(edit.sensitive) + } + } + + override fun getItemCount() = edits.size +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt new file mode 100644 index 000000000..3985d6ffd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -0,0 +1,152 @@ +/* Copyright 2022 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.viewthread.edits + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.StatusListActivity +import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentViewThreadBinding::bind) + + private lateinit var statusId: String + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.toolbar.setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + binding.toolbar.title = getString(R.string.title_edits) + binding.swipeRefreshLayout.isEnabled = false + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + + val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + statusId = requireArguments().getString(STATUS_ID_EXTRA)!! + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) + val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiState.collect { uiState -> + when (uiState) { + EditsUiState.Initial -> {} + EditsUiState.Loading -> { + binding.recyclerView.hide() + binding.statusView.hide() + binding.initialProgressBar.show() + } + is EditsUiState.Error -> { + Log.w(TAG, "failed to load edits", uiState.throwable) + + binding.recyclerView.hide() + binding.statusView.show() + binding.initialProgressBar.hide() + + if (uiState.throwable is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + viewModel.loadEdits(statusId, force = true) + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + viewModel.loadEdits(statusId, force = true) + } + } + } + is EditsUiState.Success -> { + binding.recyclerView.show() + binding.statusView.hide() + binding.initialProgressBar.hide() + + binding.recyclerView.adapter = ViewEditsAdapter( + edits = uiState.edits, + animateAvatars = animateAvatars, + animateEmojis = animateEmojis, + useBlurhash = useBlurhash, + listener = this@ViewEditsFragment + ) + } + } + } + } + + viewModel.loadEdits(statusId) + } + + override fun onViewAccount(id: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + } + + override fun onViewTag(tag: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } + + override fun onViewUrl(url: String, text: String) { + bottomSheetActivity?.viewUrl(url, text = text) + } + + private val bottomSheetActivity + get() = (activity as? BottomSheetActivity) + + companion object { + private const val TAG = "ViewEditsFragment" + + private const val STATUS_ID_EXTRA = "id" + + fun newInstance(statusId: String): ViewEditsFragment { + val arguments = Bundle(1) + val fragment = ViewEditsFragment() + arguments.putString(STATUS_ID_EXTRA, statusId) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt new file mode 100644 index 000000000..a76078ed6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -0,0 +1,63 @@ +/* Copyright 2022 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.viewthread.edits + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.entity.StatusEdit +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ViewEditsViewModel @Inject constructor( + private val api: MastodonApi +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) + val uiState: Flow + get() = _uiState + + fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { + if (force || _uiState.value is EditsUiState.Initial) { + if (!refreshing) { + _uiState.value = EditsUiState.Loading + } + viewModelScope.launch { + api.statusEdits(statusId).fold( + { edits -> + val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() + _uiState.value = EditsUiState.Success(sortedEdits) + }, + { throwable -> + _uiState.value = EditsUiState.Error(throwable) + } + ) + } + } + } +} + +sealed interface EditsUiState { + object Initial : EditsUiState + object Loading : EditsUiState + class Error(val throwable: Throwable) : EditsUiState + data class Success( + val edits: List + ) : EditsUiState +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index f20660083..852088f33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -62,6 +62,7 @@ data class AccountEntity( var defaultMediaSensitivity: Boolean = false, var defaultPostLanguage: String = "", var alwaysShowSensitiveMedia: Boolean = false, + /** True if content behind a content warning is shown by default */ var alwaysOpenSpoiler: Boolean = false, var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", 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 843e20b21..2b4ed61f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 45) + }, version = 46) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -633,4 +633,11 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER"); } }; + + public static final Migration MIGRATION_45_46 = new Migration(45, 46) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `statusId` TEXT"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 2d65fba45..1d9a3281f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -130,13 +130,13 @@ class Converters @Inject constructor ( } @TypeConverter - fun dateToLong(date: Date): Long { - return date.time + fun dateToLong(date: Date?): Long? { + return date?.time } @TypeConverter - fun longToDate(date: Long): Date { - return Date(date) + fun longToDate(date: Long?): Date? { + return date?.let { Date(it) } } @TypeConverter diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 79b7243f1..d5f9edc9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -42,6 +42,7 @@ data class DraftEntity( val failedToSend: Boolean, val scheduledAt: String?, val language: String?, + val statusId: String?, ) /** diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 9882c70d6..cb38752f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -54,6 +54,29 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" ) abstract fun getStatuses(account: Long): PagingSource + @Query( + """ +SELECT s.serverId, s.url, s.timelineUserId, +s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, +s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, +s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, +s.quote, +a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', +a.localUsername as 'a_localUsername', a.username as 'a_username', +a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', +a.emojis as 'a_emojis', a.bot as 'a_bot', +rb.serverId as 'rb_serverId', rb.timelineUserId 'rb_timelineUserId', +rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', +rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', +rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' +FROM TimelineStatusEntity s +LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) +LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) +WHERE s.serverId = :statusId OR s.reblogServerId = :statusId""" + ) + abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? + @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) @@ -193,6 +216,13 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1") abstract suspend fun getIdAbove(accountId: Long, serverId: String): String? + /** + * Returns the ID directly below [serverId], or null if [serverId] is the ID of the bottom + * status + */ + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? + /** * Returns the id of the next placeholder after [serverId] */ @@ -201,4 +231,12 @@ AND timelineUserId = :accountId @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") abstract suspend fun getStatusCount(accountId: Long): Int + + /** Developer tools: Find N most recent status IDs */ + @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count") + abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List + + /** Developer tools: Convert a status to a placeholder */ + @Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId") + abstract suspend fun convertStatustoPlaceholder(serverId: String) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 0d3ed1c26..47cb48770 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -77,14 +77,18 @@ data class TimelineStatusEntity( val reblogAccountId: String?, val poll: String?, val muted: Boolean?, - val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder + /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ + val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, val pinned: Boolean, val card: String?, val language: String?, val quote: String?, -) +) { + val isPlaceholder: Boolean + get() = this.authorServerId == null +} @Entity( primaryKeys = ["serverId", "timelineUserId"] diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index a8e34c55e..91d355fa1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -73,7 +73,7 @@ class AppModule { AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, - AppDatabase.MIGRATION_44_45, + AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index bea0ba789..7c7516ab0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragme import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module @@ -52,6 +53,9 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun viewThreadFragment(): ViewThreadFragment + @ContributesAndroidInjector + abstract fun viewEditsFragment(): ViewEditsFragment + @ContributesAndroidInjector abstract fun timelineFragment(): TimelineFragment diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 094c06392..08fa00a0e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di import android.content.Context import android.content.SharedPreferences import android.os.Build +import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -28,6 +29,10 @@ import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.NotestockApi +import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED +import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT +import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER +import com.keylesspalace.tusky.settings.ProxyConfiguration import com.keylesspalace.tusky.util.getNonNullString import dagger.Module import dagger.Provides @@ -40,6 +45,7 @@ import retrofit2.Retrofit import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create +import java.net.IDN import java.net.InetSocketAddress import java.net.Proxy import java.util.Date @@ -66,9 +72,9 @@ class NetworkModule { context: Context, preferences: SharedPreferences ): OkHttpClient { - val httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false) - val httpServer = preferences.getNonNullString("httpProxyServer", "") - val httpPort = preferences.getNonNullString("httpProxyPort", "-1").toIntOrNull() ?: -1 + val httpProxyEnabled = preferences.getBoolean(HTTP_PROXY_ENABLED, false) + val httpServer = preferences.getNonNullString(HTTP_PROXY_SERVER, "") + val httpPort = preferences.getNonNullString(HTTP_PROXY_PORT, "-1").toIntOrNull() ?: -1 val cacheSize = 25 * 1024 * 1024L // 25 MiB val builder = OkHttpClient.Builder() .addInterceptor { chain -> @@ -89,10 +95,13 @@ class NetworkModule { .writeTimeout(30, TimeUnit.SECONDS) .cache(Cache(context.cacheDir, cacheSize)) - if (httpProxyEnabled && httpServer.isNotEmpty() && httpPort > 0 && httpPort < 65535) { - val address = InetSocketAddress.createUnresolved(httpServer, httpPort) - builder.proxy(Proxy(Proxy.Type.HTTP, address)) + if (httpProxyEnabled) { + ProxyConfiguration.create(httpServer, httpPort)?.also { conf -> + val address = InetSocketAddress.createUnresolved(IDN.toASCII(conf.hostname), conf.port) + builder.proxy(Proxy(Proxy.Type.HTTP, address)) + } ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)") } + return builder .apply { addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) @@ -149,4 +158,8 @@ class NetworkModule { .build() return retrofit.create(NotestockApi::class.java) } + + companion object { + private const val TAG = "NetworkModule" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 8e23d2d40..221362a16 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -19,6 +19,7 @@ import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel +import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -119,6 +120,11 @@ abstract class ViewModelModule { @ViewModelKey(ViewThreadViewModel::class) internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ViewEditsViewModel::class) + internal abstract fun viewEditsViewModel(viewModel: ViewEditsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(AccountMediaViewModel::class) 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 9b4ef6704..07e202f6b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -145,7 +145,7 @@ data class Status( ) } - private fun getEditableText(): String { + fun getEditableText(): String { val contentSpanned = content.parseAsMastodonHtml() val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { @@ -154,7 +154,9 @@ data class Status( if (url == url1) { val start = builder.getSpanStart(span) val end = builder.getSpanEnd(span) - builder.replace(start, end, "@$username") + if (start >= 0 && end >= 0) { + builder.replace(start, end, "@$username") + } break } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt new file mode 100644 index 000000000..0e77b0fd9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt @@ -0,0 +1,15 @@ +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class StatusEdit( + val content: String, + @SerializedName("spoiler_text") val spoilerText: String, + val sensitive: Boolean, + @SerializedName("created_at") val createdAt: Date, + val account: TimelineAccount, + val poll: Poll?, + @SerializedName("media_attachments") val mediaAttachments: List, + val emojis: List +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt new file mode 100644 index 000000000..aea6bdd47 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -0,0 +1,24 @@ +/* Copyright 2022 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.entity + +import com.google.gson.annotations.SerializedName + +data class StatusSource( + val id: String, + val text: String, + @SerializedName("spoiler_text") val spoilerText: String, +) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index 497239653..9fa321d30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -20,6 +20,7 @@ import android.util.Log import android.view.View import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DividerItemDecoration @@ -53,10 +54,9 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.launch import retrofit2.Response import java.io.IOException -import java.util.HashMap import javax.inject.Inject class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable { @@ -95,17 +95,18 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct val pm = PreferenceManager.getDefaultSharedPreferences(view.context) val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) adapter = when (type) { - Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis) - Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis) + Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) + Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true) - val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis) + val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter } - else -> FollowAdapter(this, animateAvatar, animateEmojis) + else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay) } if (binding.recyclerView.adapter == null) { binding.recyclerView.adapter = adapter @@ -133,20 +134,18 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - if (!mute) { - api.unmuteAccount(id) - } else { - api.muteAccount(id, notifications) - } - .autoDispose(from(this)) - .subscribe( - { - onMuteSuccess(mute, id, position, notifications) - }, - { - onMuteFailure(mute, id, notifications) + 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) { @@ -181,20 +180,18 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onBlock(block: Boolean, id: String, position: Int) { - if (!block) { - api.unblockAccount(id) - } else { - api.blockAccount(id) - } - .autoDispose(from(this)) - .subscribe( - { - onBlockSuccess(block, id, position) - }, - { - onBlockFailure(block, id) + 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) { @@ -255,7 +252,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct followRequestsAdapter.removeItem(position) } - private fun getFetchCallByListType(fromId: String?): Single>> { + private suspend fun getFetchCallByListType(fromId: String?): Response> { return when (type) { Type.FOLLOWS -> { val accountId = requireId(type, id) @@ -293,24 +290,27 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct binding.recyclerView.post { adapter.setBottomLoading(true) } } - getFetchCallByListType(fromId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { response -> - val accountList = response.body() - - if (response.isSuccessful && accountList != null) { - val linkHeader = response.headers()["Link"] - onFetchAccountsSuccess(accountList, linkHeader) - } else { - onFetchAccountsFailure(Exception(response.message())) - } - }, - { throwable -> - onFetchAccountsFailure(throwable) + viewLifecycleOwner.lifecycleScope.launch { + try { + val response = getFetchCallByListType(fromId) + if (!response.isSuccessful) { + onFetchAccountsFailure(Exception(response.message())) + return@launch } - ) + + val accountList = response.body() + + if (accountList == null) { + onFetchAccountsFailure(Exception(response.message())) + return@launch + } + + val linkHeader = response.headers()["Link"] + onFetchAccountsSuccess(accountList, linkHeader) + } catch (exception: IOException) { + onFetchAccountsFailure(exception) + } + } } private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { @@ -394,7 +394,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment { return AccountListFragment().apply { - arguments = Bundle(2).apply { + arguments = Bundle(3).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) putBoolean(ARG_ACCOUNT_LOCKED, accountLocked) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index a7c145c31..6b1f62d76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -924,11 +924,11 @@ public class NotificationsFragment extends SFragment implements private void onFetchNotificationsSuccess(List notifications, String linkHeader, FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next"); + List links = HttpHeaderLink.Companion.parse(linkHeader); + HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); String fromId = null; if (next != null) { - fromId = next.uri.getQueryParameter("max_id"); + fromId = next.getUri().getQueryParameter("max_id"); } switch (fetchEnd) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java deleted file mode 100644 index b70ffceea..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ /dev/null @@ -1,518 +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.fragment; - -import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; - -import android.Manifest; -import android.app.DownloadManager; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Environment; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.PopupMenu; -import androidx.core.app.ActivityOptionsCompat; -import androidx.core.view.ViewCompat; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle; - -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.BaseActivity; -import com.keylesspalace.tusky.BottomSheetActivity; -import com.keylesspalace.tusky.PostLookupFallbackBehavior; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.StatusListActivity; -import com.keylesspalace.tusky.ViewMediaActivity; -import com.keylesspalace.tusky.components.compose.ComposeActivity; -import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; -import com.keylesspalace.tusky.components.report.ReportActivity; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Attachment; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.usecase.TimelineCases; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.StatusParsingHelper; -import com.keylesspalace.tusky.view.MuteAccountDialog; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; - -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -import javax.inject.Inject; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import kotlin.Unit; - -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an - * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature - * of that is complicated by how they're coupled with Status and Notification and the corresponding - * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also - * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear - * up what needs to be where. */ -public abstract class SFragment extends Fragment implements Injectable { - - protected abstract void removeItem(int position); - - protected abstract void onReblog(final boolean reblog, final int position); - - private BottomSheetActivity bottomSheetActivity; - - @Inject - public MastodonApi mastodonApi; - @Inject - public AccountManager accountManager; - @Inject - public TimelineCases timelineCases; - - private static final String TAG = "SFragment"; - - @Override - public void startActivity(Intent intent) { - super.startActivity(intent); - getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - if (context instanceof BottomSheetActivity) { - bottomSheetActivity = (BottomSheetActivity) context; - } else { - throw new IllegalStateException("Fragment must be attached to a BottomSheetActivity!"); - } - } - - protected void openReblog(@Nullable final Status status) { - if (status == null) return; - bottomSheetActivity.viewAccount(status.getAccount().getId()); - } - - protected void viewThread(String statusId, @Nullable String statusUrl) { - bottomSheetActivity.viewThread(statusId, statusUrl); - } - - protected void viewAccount(String accountId) { - bottomSheetActivity.viewAccount(accountId); - } - - public void onViewUrl(String url, String text) { - bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text); - } - - protected void reply(Status status) { - String inReplyToId = status.getActionableId(); - Status actionableStatus = status.getActionableStatus(); - Status.Visibility replyVisibility = actionableStatus.getVisibility(); - String contentWarning = actionableStatus.getSpoilerText(); - List mentions = actionableStatus.getMentions(); - Set mentionedUsernames = new LinkedHashSet<>(); - mentionedUsernames.add(actionableStatus.getAccount().getUsername()); - String loggedInUsername = null; - AccountEntity activeAccount = accountManager.getActiveAccount(); - if (activeAccount != null) { - loggedInUsername = activeAccount.getUsername(); - } - for (Status.Mention mention : mentions) { - mentionedUsernames.add(mention.getUsername()); - } - mentionedUsernames.remove(loggedInUsername); - ComposeOptions composeOptions = new ComposeOptions(); - composeOptions.setInReplyToId(inReplyToId); - composeOptions.setReplyVisibility(replyVisibility); - composeOptions.setContentWarning(contentWarning); - composeOptions.setMentionedUsernames(mentionedUsernames); - composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); - composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString()); - composeOptions.setLanguage(actionableStatus.getLanguage()); - - Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); - getActivity().startActivity(intent); - } - - protected void quote(Status status) { - String id = status.getActionableId(); - Status actionableStatus = status.getActionableStatus(); - Status.Visibility visibility = actionableStatus.getVisibility(); - List mentions = actionableStatus.getMentions(); - Set mentionedUsernames = new LinkedHashSet<>(); - mentionedUsernames.add(actionableStatus.getAccount().getUsername()); - String loggedInUsername = null; - AccountEntity activeAccount = accountManager.getActiveAccount(); - if(activeAccount != null) { - loggedInUsername = activeAccount.getUsername(); - } - for (Status.Mention mention : mentions) { - mentionedUsernames.add(mention.getUsername()); - } - mentionedUsernames.remove(loggedInUsername); - ComposeOptions composeOptions = new ComposeOptions(); - composeOptions.setQuoteId(id); - composeOptions.setQuoteStatusAuthor(actionableStatus.getAccount().getLocalUsername()); - composeOptions.setQuoteStatusContent(actionableStatus.getContent().toString()); - composeOptions.setReplyVisibility(visibility); - composeOptions.setMentionedUsernames(mentionedUsernames); - - Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); - startActivity(intent); - } - - protected void more(@NonNull final Status status, View view, final int position) { - final String id = status.getActionableId(); - final String accountId = status.getActionableStatus().getAccount().getId(); - final String accountUsername = status.getActionableStatus().getAccount().getUsername(); - final String statusUrl = status.getActionableStatus().getUrl(); - - String loggedInAccountId = null; - AccountEntity activeAccount = accountManager.getActiveAccount(); - if (activeAccount != null) { - loggedInAccountId = activeAccount.getAccountId(); - } - - PopupMenu popup = new PopupMenu(getContext(), view); - // Give a different menu depending on whether this is the user's own toot or not. - boolean statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId.equals(accountId); - if (statusIsByCurrentUser) { - popup.inflate(R.menu.status_more_for_user); - Menu menu = popup.getMenu(); - switch (status.getVisibility()) { - case PUBLIC: - case UNLISTED: { - final String textId = - getString(status.isPinned() ? R.string.unpin_action : R.string.pin_action); - menu.add(0, R.id.pin, 1, textId); - break; - } - case PRIVATE: { - boolean reblogged = status.getReblogged(); - if (status.getReblog() != null) reblogged = status.getReblog().getReblogged(); - menu.findItem(R.id.status_reblog_private).setVisible(!reblogged); - menu.findItem(R.id.status_unreblog_private).setVisible(reblogged); - break; - } - } - } else { - popup.inflate(R.menu.status_more); - Menu menu = popup.getMenu(); - menu.findItem(R.id.status_download_media).setVisible(!status.getAttachments().isEmpty()); - } - - Menu menu = popup.getMenu(); - MenuItem openAsItem = menu.findItem(R.id.status_open_as); - String openAsText = ((BaseActivity)getActivity()).getOpenAsText(); - if (openAsText == null) { - openAsItem.setVisible(false); - } else { - openAsItem.setTitle(openAsText); - } - - MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation); - boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions()); - muteConversationItem.setVisible(mutable); - if (mutable) { - muteConversationItem.setTitle((status.getMuted() == null || !status.getMuted()) ? - R.string.action_mute_conversation : - R.string.action_unmute_conversation); - } - - popup.setOnMenuItemClickListener(item -> { - switch (item.getItemId()) { - case R.id.post_share_content: { - Status statusToShare = status; - if (statusToShare.getReblog() != null) - statusToShare = statusToShare.getReblog(); - - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - - String stringToShare = statusToShare.getAccount().getUsername() + - " - " + - StatusParsingHelper.parseAsMastodonHtml(statusToShare.getContent()).toString(); - sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); - sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); - sendIntent.setType("text/plain"); - startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_content_to))); - return true; - } - case R.id.post_share_link: { - Intent sendIntent = new Intent(); - sendIntent.setAction(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl); - sendIntent.setType("text/plain"); - startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_link_to))); - return true; - } - case R.id.status_copy_link: { - ClipboardManager clipboard = (ClipboardManager) - getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText(null, statusUrl); - clipboard.setPrimaryClip(clip); - return true; - } - case R.id.status_open_as: { - showOpenAsDialog(statusUrl, item.getTitle()); - return true; - } - case R.id.status_download_media: { - requestDownloadAllMedia(status); - return true; - } - case R.id.status_mute: { - onMute(accountId, accountUsername); - return true; - } - case R.id.status_block: { - onBlock(accountId, accountUsername); - return true; - } - case R.id.status_report: { - openReportPage(accountId, accountUsername, id); - return true; - } - case R.id.status_unreblog_private: { - onReblog(false, position); - return true; - } - case R.id.status_reblog_private: { - onReblog(true, position); - return true; - } - case R.id.status_delete: { - showConfirmDeleteDialog(id, position); - return true; - } - case R.id.status_delete_and_redraft: { - showConfirmEditDialog(id, position, status); - return true; - } - case R.id.pin: { - timelineCases.pin(status.getId(), !status.isPinned()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(e -> { - String message = e.getMessage(); - if (message == null) { - message = getString(status.isPinned() ? R.string.failed_to_unpin : R.string.failed_to_pin); - } - Snackbar.make(view, message, Snackbar.LENGTH_LONG).show(); - }) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(); - return true; - } - case R.id.status_mute_conversation: { - timelineCases.muteConversation(status.getId(), status.getMuted() == null || !status.getMuted()) - .onErrorReturnItem(status) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(); - return true; - } - } - return false; - }); - popup.show(); - } - - private void onMute(String accountId, String accountUsername) { - MuteAccountDialog.showMuteAccountDialog( - this.getActivity(), - accountUsername, - (notifications, duration) -> { - timelineCases.mute(accountId, notifications, duration); - return Unit.INSTANCE; - } - ); - } - - private void onBlock(String accountId, String accountUsername) { - new AlertDialog.Builder(requireContext()) - .setMessage(getString(R.string.dialog_block_warning, accountUsername)) - .setPositiveButton(android.R.string.ok, (__, ___) -> timelineCases.block(accountId)) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private static boolean accountIsInMentions(AccountEntity account, List mentions) { - if (account == null) { - return false; - } - - for (Status.Mention mention : mentions) { - if (account.getUsername().equals(mention.getUsername())) { - Uri uri = Uri.parse(mention.getUrl()); - if (uri != null && account.getDomain().equals(uri.getHost())) { - return true; - } - } - } - return false; - } - - protected void viewMedia(int urlIndex, List attachments, @Nullable View view) { - final AttachmentViewData active = attachments.get(urlIndex); - Attachment.Type type = active.getAttachment().getType(); - switch (type) { - case GIFV: - case VIDEO: - case IMAGE: - case AUDIO: { - final Intent intent = ViewMediaActivity.newIntent(getContext(), attachments, - urlIndex); - if (view != null) { - String url = active.getAttachment().getUrl(); - view.setTransitionName(url); - ActivityOptionsCompat options = - ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), - view, url); - startActivity(intent, options.toBundle()); - } else { - startActivity(intent); - } - break; - } - default: - case UNKNOWN: { - LinkHelper.openLink(requireContext(), active.getAttachment().getUrl()); - break; - } - } - } - - protected void viewTag(String tag) { - Intent intent = StatusListActivity.newHashtagIntent(requireContext(), tag); - startActivity(intent); - } - - protected void openReportPage(String accountId, String accountUsername, String statusId) { - startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId)); - } - - protected void showConfirmDeleteDialog(final String id, final int position) { - new AlertDialog.Builder(getActivity()) - .setMessage(R.string.dialog_delete_post_warning) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { - timelineCases.delete(id) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - deletedStatus -> { - }, - error -> { - Log.w("SFragment", "error deleting status", error); - Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); - }); - removeItem(position); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void showConfirmEditDialog(final String id, final int position, final Status status) { - if (getActivity() == null) { - return; - } - new AlertDialog.Builder(getActivity()) - .setMessage(R.string.dialog_redraft_post_warning) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { - timelineCases.delete(id) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(deletedStatus -> { - removeItem(position); - - if (deletedStatus.isEmpty()) { - deletedStatus = status.toDeletedStatus(); - } - ComposeOptions composeOptions = new ComposeOptions(); - composeOptions.setContent(deletedStatus.getText()); - composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); - composeOptions.setVisibility(deletedStatus.getVisibility()); - composeOptions.setContentWarning(deletedStatus.getSpoilerText()); - composeOptions.setMediaAttachments(deletedStatus.getAttachments()); - composeOptions.setSensitive(deletedStatus.getSensitive()); - composeOptions.setModifiedInitialState(true); - composeOptions.setLanguage(deletedStatus.getLanguage()); - if (deletedStatus.getPoll() != null) { - composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); - } - - Intent intent = ComposeActivity - .startIntent(getContext(), composeOptions); - startActivity(intent); - }, - error -> { - Log.w("SFragment", "error deleting status", error); - Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show(); - }); - - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) { - BaseActivity activity = (BaseActivity) getActivity(); - activity.showAccountChooserDialog(dialogTitle, false, account -> activity.openAsAccount(statusUrl, account)); - } - - private void downloadAllMedia(Status status) { - Toast.makeText(getContext(), R.string.downloading_media, Toast.LENGTH_SHORT).show(); - for (Attachment attachment : status.getAttachments()) { - String url = attachment.getUrl(); - Uri uri = Uri.parse(url); - String filename = uri.getLastPathSegment(); - - DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Request request = new DownloadManager.Request(uri); - request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename); - downloadManager.enqueue(request); - } - } - - private void requestDownloadAllMedia(Status status) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; - ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status); - } else { - Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); - } - }); - } else { - downloadAllMedia(status); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt new file mode 100644 index 000000000..74dd1aa85 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -0,0 +1,542 @@ +/* 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.fragment + +import android.Manifest +import android.app.DownloadManager +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.PopupMenu +import androidx.core.app.ActivityOptionsCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.fold +import autodispose2.AutoDispose +import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.PostLookupFallbackBehavior +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.StatusListActivity.Companion.newHashtagIntent +import com.keylesspalace.tusky.ViewMediaActivity.Companion.newIntent +import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent +import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.AccountSelectionListener +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.view.showMuteAccountDialog +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch +import javax.inject.Inject + +/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an + * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature + * of that is complicated by how they're coupled with Status and Notification and the corresponding + * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also + * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear + * up what needs to be where. */ +abstract class SFragment : Fragment(), Injectable { + protected abstract fun removeItem(position: Int) + protected abstract fun onReblog(reblog: Boolean, position: Int) + private lateinit var bottomSheetActivity: BottomSheetActivity + + @Inject + lateinit var mastodonApi: MastodonApi + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var timelineCases: TimelineCases + + override fun startActivity(intent: Intent) { + super.startActivity(intent) + requireActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + bottomSheetActivity = if (context is BottomSheetActivity) { + context + } else { + throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") + } + } + + protected fun openReblog(status: Status?) { + if (status == null) return + bottomSheetActivity.viewAccount(status.account.id) + } + + protected fun viewThread(statusId: String?, statusUrl: String?) { + bottomSheetActivity.viewThread(statusId!!, statusUrl) + } + + protected fun viewAccount(accountId: String?) { + bottomSheetActivity.viewAccount(accountId!!) + } + + open fun onViewUrl(url: String, text: String) { + bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER, text) + } + + protected fun reply(status: Status) { + val actionableStatus = status.actionableStatus + val account = actionableStatus.account + var loggedInUsername: String? = null + val activeAccount = accountManager.activeAccount + if (activeAccount != null) { + loggedInUsername = activeAccount.username + } + val mentionedUsernames = LinkedHashSet( + listOf(account.username) + actionableStatus.mentions.map { it.username } + ).apply { remove(loggedInUsername) } + + val composeOptions = ComposeOptions( + inReplyToId = status.actionableId, + replyVisibility = actionableStatus.visibility, + contentWarning = actionableStatus.spoilerText, + mentionedUsernames = mentionedUsernames, + replyingStatusAuthor = account.localUsername, + replyingStatusContent = actionableStatus.content.parseAsMastodonHtml().toString(), + language = actionableStatus.language, + kind = ComposeActivity.ComposeKind.NEW + ) + + val intent = startIntent(requireContext(), composeOptions) + requireActivity().startActivity(intent) + } + + protected fun quote(status: Status) { + val actionableStatus = status.actionableStatus + val account = actionableStatus.account + var loggedInUsername: String? = null + val activeAccount = accountManager.activeAccount + if (activeAccount != null) { + loggedInUsername = activeAccount.username + } + val mentionedUsernames = LinkedHashSet( + listOf(account.username) + actionableStatus.mentions.map { it.username } + ).apply { remove(loggedInUsername) } + + val composeOptions = ComposeOptions( + quoteId = status.actionableId, + replyVisibility = actionableStatus.visibility, + contentWarning = actionableStatus.spoilerText, + mentionedUsernames = mentionedUsernames, + quoteStatusAuthor = account.localUsername, + quoteStatusContent = actionableStatus.content.parseAsMastodonHtml().toString(), + language = actionableStatus.language, + kind = ComposeActivity.ComposeKind.NEW, + ) + + val intent = startIntent(requireContext(), composeOptions) + requireActivity().startActivity(intent) + } + + protected fun more(status: Status, view: View, position: Int) { + val id = status.actionableId + val accountId = status.actionableStatus.account.id + val accountUsername = status.actionableStatus.account.username + val statusUrl = status.actionableStatus.url + var loggedInAccountId: String? = null + val activeAccount = accountManager.activeAccount + if (activeAccount != null) { + loggedInAccountId = activeAccount.accountId + } + val popup = PopupMenu(requireContext(), view) + // Give a different menu depending on whether this is the user's own toot or not. + val statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId == accountId + if (statusIsByCurrentUser) { + popup.inflate(R.menu.status_more_for_user) + val menu = popup.menu + when (status.visibility) { + Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { + menu.add(0, R.id.pin, 1, getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action)) + } + Status.Visibility.PRIVATE -> { + val reblogged = status.reblog?.reblogged ?: status.reblogged + menu.findItem(R.id.status_reblog_private).isVisible = !reblogged + menu.findItem(R.id.status_unreblog_private).isVisible = reblogged + } + else -> {} + } + } else { + popup.inflate(R.menu.status_more) + popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() + } + val menu = popup.menu + val openAsItem = menu.findItem(R.id.status_open_as) + val openAsText = (activity as BaseActivity?)?.openAsText + if (openAsText == null) { + openAsItem.isVisible = false + } else { + openAsItem.title = openAsText + } + val muteConversationItem = menu.findItem(R.id.status_mute_conversation) + val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) + muteConversationItem.isVisible = mutable + if (mutable) { + muteConversationItem.setTitle( + if (status.muted != true) { + R.string.action_mute_conversation + } else { + R.string.action_unmute_conversation + } + ) + } + popup.setOnMenuItemClickListener { item: MenuItem -> + when (item.itemId) { + R.id.post_share_content -> { + val statusToShare = status.reblog ?: status + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "${statusToShare.account.username} - ${statusToShare.content.parseAsMastodonHtml()}" + ) + putExtra(Intent.EXTRA_SUBJECT, statusUrl) + } + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_content_to) + ) + ) + return@setOnMenuItemClickListener true + } + R.id.post_share_link -> { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, statusUrl) + type = "text/plain" + } + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_link_to) + ) + ) + return@setOnMenuItemClickListener true + } + R.id.status_copy_link -> { + (requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply { + setPrimaryClip(ClipData.newPlainText(null, statusUrl)) + } + return@setOnMenuItemClickListener true + } + R.id.status_open_as -> { + showOpenAsDialog(statusUrl, item.title) + return@setOnMenuItemClickListener true + } + R.id.status_download_media -> { + requestDownloadAllMedia(status) + return@setOnMenuItemClickListener true + } + R.id.status_mute -> { + onMute(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + R.id.status_block -> { + onBlock(accountId, accountUsername) + return@setOnMenuItemClickListener true + } + R.id.status_report -> { + openReportPage(accountId, accountUsername, id) + return@setOnMenuItemClickListener true + } + R.id.status_unreblog_private -> { + onReblog(false, position) + return@setOnMenuItemClickListener true + } + R.id.status_reblog_private -> { + onReblog(true, position) + return@setOnMenuItemClickListener true + } + R.id.status_delete -> { + showConfirmDeleteDialog(id, position) + return@setOnMenuItemClickListener true + } + R.id.status_delete_and_redraft -> { + showConfirmEditDialog(id, position, status) + return@setOnMenuItemClickListener true + } + R.id.status_edit -> { + editStatus(id, status) + return@setOnMenuItemClickListener true + } + R.id.pin -> { + timelineCases.pin(status.id, !status.isPinned()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError { e: Throwable -> + val message = e.message ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) + Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() + } + .to( + AutoDispose.autoDisposable( + AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY) + ) + ) + .subscribe() + return@setOnMenuItemClickListener true + } + R.id.status_mute_conversation -> { + timelineCases.muteConversation(status.id, status.muted != true) + .onErrorReturnItem(status) + .observeOn(AndroidSchedulers.mainThread()) + .to( + AutoDispose.autoDisposable( + AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY) + ) + ) + .subscribe() + return@setOnMenuItemClickListener true + } + } + false + } + popup.show() + } + + private fun onMute(accountId: String, accountUsername: String) { + + showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> + lifecycleScope.launch { + timelineCases.mute(accountId, notifications == true, duration) + } + } + } + + private fun onBlock(accountId: String, accountUsername: String) { + AlertDialog.Builder(requireContext()) + .setMessage(getString(R.string.dialog_block_warning, accountUsername)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + lifecycleScope.launch { + timelineCases.block(accountId) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + protected fun viewMedia(urlIndex: Int, attachments: List, view: View?) { + 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) + if (view != null) { + val url = attachment.url + view.transitionName = url + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, url + ) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Attachment.Type.UNKNOWN -> { + requireContext().openLink(attachment.url) + } + } + } + + protected fun viewTag(tag: String) { + startActivity(newHashtagIntent(requireContext(), tag)) + } + + private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { + startActivity(getIntent(requireContext(), accountId, accountUsername, statusId)) + } + + private fun showConfirmDeleteDialog(id: String, position: Int) { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.dialog_delete_post_warning) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + lifecycleScope.launch { + val result = timelineCases.delete(id).exceptionOrNull() + if (result != null) { + Log.w("SFragment", "error deleting status", result) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + } + // XXX: Removes the item even if there was an error. This is probably not + // correct (see similar code in showConfirmEditDialog() which only + // removes the item if the timelineCases.delete() call succeeded. + // + // Either way, this logic should be in the view model. + removeItem(position) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun showConfirmEditDialog(id: String, position: Int, status: Status) { + if (activity == null) { + return + } + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.dialog_redraft_post_warning) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + lifecycleScope.launch { + timelineCases.delete(id).fold( + { deletedStatus -> + removeItem(position) + val sourceStatus = if (deletedStatus.isEmpty()) { + status.toDeletedStatus() + } else { + deletedStatus + } + val composeOptions = ComposeOptions( + content = sourceStatus.text, + inReplyToId = sourceStatus.inReplyToId, + visibility = sourceStatus.visibility, + contentWarning = sourceStatus.spoilerText, + mediaAttachments = sourceStatus.attachments, + sensitive = sourceStatus.sensitive, + modifiedInitialState = true, + language = sourceStatus.language, + poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt), + kind = ComposeActivity.ComposeKind.NEW + ) + startActivity(startIntent(requireContext(), composeOptions)) + }, + { error: Throwable? -> + Log.w("SFragment", "error deleting status", error) + Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT) + .show() + } + ) + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun editStatus(id: String, status: Status) { + lifecycleScope.launch { + mastodonApi.statusSource(id).fold( + { source -> + val composeOptions = ComposeOptions( + content = source.text, + inReplyToId = status.inReplyToId, + visibility = status.visibility, + contentWarning = source.spoilerText, + mediaAttachments = status.attachments, + sensitive = status.sensitive, + language = status.language, + statusId = source.id, + poll = status.poll?.toNewPoll(status.createdAt), + kind = ComposeActivity.ComposeKind.EDIT_POSTED + ) + startActivity(startIntent(requireContext(), composeOptions)) + }, + { + Snackbar.make( + requireView(), + getString(R.string.error_status_source_load), + Snackbar.LENGTH_SHORT + ).show() + } + ) + } + } + + private fun showOpenAsDialog(statusUrl: String?, dialogTitle: CharSequence?) { + if (statusUrl == null) { + return + } + + (activity as BaseActivity).apply { + showAccountChooserDialog( + dialogTitle, + false, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + openAsAccount(statusUrl, account) + } + } + ) + } + } + + private fun downloadAllMedia(status: Status) { + Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() + val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + + for ((_, url) in status.attachments) { + val uri = Uri.parse(url) + downloadManager.enqueue( + DownloadManager.Request(uri).apply { + setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment) + } + ) + } + } + + private fun requestDownloadAllMedia(status: Status) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + (activity as BaseActivity).requestPermissions(permissions) { _: Array?, grantResults: IntArray -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status) + } else { + Toast.makeText( + context, + R.string.error_media_download_permission, + Toast.LENGTH_SHORT + ).show() + } + } + } else { + downloadAllMedia(status) + } + } + + companion object { + private const val TAG = "SFragment" + private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { + return mentions.any { mention -> + account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 28991c262..566aa6472 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -66,19 +66,21 @@ class ViewVideoFragment : ViewMediaFragment() { videoActionsListener = context as VideoActionsListener } - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - // Start/pause/resume video playback as fragment is shown/hidden - super.setUserVisibleHint(isVisibleToUser) - if (_binding == null) { - return - } + override fun onResume() { + super.onResume() - if (isVisibleToUser) { + if (_binding != null) { if (mediaActivity.isToolbarVisible) { handler.postDelayed(hideToolbar, TOOLBAR_HIDE_DELAY_MS) } binding.videoView.start() - } else { + } + } + + override fun onPause() { + super.onPause() + + if (_binding != null) { handler.removeCallbacks(hideToolbar) binding.videoView.pause() mediaController.hide() @@ -161,9 +163,6 @@ class ViewVideoFragment : ViewMediaFragment() { binding.progressBar.hide() mp.isLooping = true - if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { - binding.videoView.start() - } } if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index ffa552940..1725784f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -64,4 +64,6 @@ public interface StatusActionListener extends LinkListener { void onVoteInPoll(int position, @NonNull List choices); + default void onShowEdits(int position) {} + } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt index 090fe5e37..c1241abcc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt @@ -16,6 +16,8 @@ */ package com.keylesspalace.tusky.json +import android.util.Log +import com.google.gson.JsonParseException import com.google.gson.TypeAdapter import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonToken @@ -42,7 +44,12 @@ class Rfc3339DateJsonAdapter : TypeAdapter() { null } else -> { - reader.nextString().parseIsoDate() + try { + reader.nextString().parseIsoDate() + } catch (jpe: JsonParseException) { + Log.w("Rfc3339DateJsonAdapter", jpe) + null + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 42652a122..5633ad3d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -39,6 +39,8 @@ import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext +import com.keylesspalace.tusky.entity.StatusEdit +import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody @@ -82,12 +84,13 @@ interface MastodonApi { suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") - fun getFilters(): Single> + suspend fun getFilters(): NetworkResult> @GET("api/v1/timelines/home") @Throws(Exception::class) suspend fun homeTimeline( @Query("max_id") maxId: String? = null, + @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null ): Response> @@ -165,36 +168,55 @@ interface MastodonApi { ): NetworkResult @GET("api/v1/statuses/{id}") - fun status( + suspend fun status( @Path("id") statusId: String - ): Single + ): NetworkResult + + @PUT("api/v1/statuses/{id}") + suspend fun editStatus( + @Path("id") statusId: String, + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Header("Idempotency-Key") idempotencyKey: String, + @Body editedStatus: NewStatus, + ): NetworkResult @GET("api/v1/statuses/{id}") suspend fun statusAsync( @Path("id") statusId: String ): NetworkResult + @GET("api/v1/statuses/{id}/source") + suspend fun statusSource( + @Path("id") statusId: String + ): NetworkResult + @GET("api/v1/statuses/{id}/context") suspend fun statusContext( @Path("id") statusId: String ): NetworkResult + @GET("api/v1/statuses/{id}/history") + suspend fun statusEdits( + @Path("id") statusId: String + ): NetworkResult> + @GET("api/v1/statuses/{id}/reblogged_by") - fun statusRebloggedBy( + suspend fun statusRebloggedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? - ): Single>> + ): Response> @GET("api/v1/statuses/{id}/favourited_by") - fun statusFavouritedBy( + suspend fun statusFavouritedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? - ): Single>> + ): Response> @DELETE("api/v1/statuses/{id}") - fun deleteStatus( + suspend fun deleteStatus( @Path("id") statusId: String - ): Single + ): NetworkResult @POST("api/v1/statuses/{id}/reblog") fun reblogStatus( @@ -331,52 +353,52 @@ interface MastodonApi { ): Response> @GET("api/v1/accounts/{id}/followers") - fun accountFollowers( + suspend fun accountFollowers( @Path("id") accountId: String, @Query("max_id") maxId: String? - ): Single>> + ): Response> @GET("api/v1/accounts/{id}/following") - fun accountFollowing( + suspend fun accountFollowing( @Path("id") accountId: String, @Query("max_id") maxId: String? - ): Single>> + ): Response> @FormUrlEncoded @POST("api/v1/accounts/{id}/follow") - fun followAccount( + suspend fun followAccount( @Path("id") accountId: String, @Field("reblogs") showReblogs: Boolean? = null, @Field("notify") notify: Boolean? = null - ): Single + ): Relationship @POST("api/v1/accounts/{id}/unfollow") - fun unfollowAccount( + suspend fun unfollowAccount( @Path("id") accountId: String - ): Single + ): Relationship @POST("api/v1/accounts/{id}/block") - fun blockAccount( + suspend fun blockAccount( @Path("id") accountId: String - ): Single + ): Relationship @POST("api/v1/accounts/{id}/unblock") - fun unblockAccount( + suspend fun unblockAccount( @Path("id") accountId: String - ): Single + ): Relationship @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") - fun muteAccount( + suspend fun muteAccount( @Path("id") accountId: String, @Field("notifications") notifications: Boolean? = null, @Field("duration") duration: Int? = null - ): Single + ): Relationship @POST("api/v1/accounts/{id}/unmute") - fun unmuteAccount( + suspend fun unmuteAccount( @Path("id") accountId: String - ): Single + ): Relationship @GET("api/v1/accounts/relationships") fun relationships( @@ -384,24 +406,24 @@ interface MastodonApi { ): Single> @POST("api/v1/pleroma/accounts/{id}/subscribe") - fun subscribeAccount( + suspend fun subscribeAccount( @Path("id") accountId: String - ): Single + ): Relationship @POST("api/v1/pleroma/accounts/{id}/unsubscribe") - fun unsubscribeAccount( + suspend fun unsubscribeAccount( @Path("id") accountId: String - ): Single + ): Relationship @GET("api/v1/blocks") - fun blocks( + suspend fun blocks( @Query("max_id") maxId: String? - ): Single>> + ): Response> @GET("api/v1/mutes") - fun mutes( + suspend fun mutes( @Query("max_id") maxId: String? - ): Single>> + ): Response> @GET("api/v1/domain_blocks") fun domainBlocks( @@ -436,9 +458,9 @@ interface MastodonApi { ): Response> @GET("api/v1/follow_requests") - fun followRequests( + suspend fun followRequests( @Query("max_id") maxId: String? - ): Single>> + ): Response> @POST("api/v1/follow_requests/{id}/authorize") fun authorizeFollowRequest( diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt index 24636a641..0cfab9b72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -1,8 +1,8 @@ package com.keylesspalace.tusky.network -import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.entity.MediaUploadResult import okhttp3.MultipartBody +import retrofit2.Response import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part @@ -17,5 +17,5 @@ interface MediaUploadApi { @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null, @Part focus: MultipartBody.Part? = null - ): NetworkResult + ): Response } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 4e547fe5f..b7cde0ad9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -86,10 +86,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { warningText = spoiler, visibility = visibility.serverString(), sensitive = false, - mediaIds = emptyList(), - mediaUris = emptyList(), - mediaDescriptions = emptyList(), - mediaFocus = emptyList(), + media = emptyList(), scheduledAt = null, inReplyToId = citedStatusId, poll = null, @@ -100,8 +97,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { draftId = -1, idempotencyKey = randomAlphanumericString(16), retries = 0, - mediaProcessed = mutableListOf(), - null, + language = null, + statusId = null, ) ) 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 f7a70a07d..6851f9c66 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -22,6 +22,8 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent +import com.keylesspalace.tusky.components.compose.MediaUploader +import com.keylesspalace.tusky.components.compose.UploadEvent import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager @@ -54,6 +56,8 @@ class SendStatusService : Service(), Injectable { lateinit var eventHub: EventHub @Inject lateinit var draftHelper: DraftHelper + @Inject + lateinit var mediaUploader: MediaUploader private val supervisorJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) @@ -131,14 +135,33 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ sendJobs[statusId] = serviceScope.launch { + + // first, wait for media uploads to finish + val media = statusToSend.media.map { mediaItem -> + if (mediaItem.id == null) { + when (val uploadState = mediaUploader.getMediaUploadState(mediaItem.localId)) { + is UploadEvent.FinishedEvent -> mediaItem.copy(id = uploadState.mediaId, processed = uploadState.processed) + is UploadEvent.ErrorEvent -> { + Log.w(TAG, "failed uploading media", uploadState.error) + failSending(statusId) + stopSelfWhenDone() + return@launch + } + } + } else { + mediaItem + } + } + + // then wait until server finished processing the media try { var mediaCheckRetries = 0 - while (statusToSend.mediaProcessed.any { !it }) { + while (media.any { mediaItem -> !mediaItem.processed }) { delay(1000L * mediaCheckRetries) - statusToSend.mediaProcessed.forEachIndexed { index, processed -> - if (!processed) { - when (mastodonApi.getMedia(statusToSend.mediaIds[index]).code()) { - 200 -> statusToSend.mediaProcessed[index] = true // success + media.forEach { mediaItem -> + if (!mediaItem.processed) { + when (mastodonApi.getMedia(mediaItem.id!!).code()) { + 200 -> mediaItem.processed = true // success 206 -> { } // media is still being processed, continue checking else -> { // some kind of server error, retrying probably doesn't make sense failSending(statusId) @@ -156,31 +179,46 @@ class SendStatusService : Service(), Injectable { return@launch } + // finally, send the new status val newStatus = NewStatus( - statusToSend.text, - statusToSend.warningText, - statusToSend.inReplyToId, - statusToSend.visibility, - statusToSend.sensitive, - statusToSend.mediaIds, - statusToSend.scheduledAt, - statusToSend.poll, - statusToSend.language, - statusToSend.quoteId, + status = statusToSend.text, + warningText = statusToSend.warningText, + inReplyToId = statusToSend.inReplyToId, + visibility = statusToSend.visibility, + sensitive = statusToSend.sensitive, + mediaIds = media.map { it.id!! }, + scheduledAt = statusToSend.scheduledAt, + poll = statusToSend.poll, + language = statusToSend.language, + quoteId = statusToSend.quoteId, ) - mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus - ).fold({ sentStatus -> + val sendResult = if (statusToSend.statusId == null) { + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } else { + mastodonApi.editStatus( + statusToSend.statusId, + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } + + sendResult.fold({ sentStatus -> statusesToSend.remove(statusId) // If the status was loaded from a draft, delete the draft and associated media files. if (statusToSend.draftId != 0) { draftHelper.deleteDraftAndAttachments(statusToSend.draftId) } + mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) + val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() if (scheduled) { @@ -226,6 +264,8 @@ class SendStatusService : Service(), Injectable { val failedStatus = statusesToSend.remove(statusId) if (failedStatus != null) { + mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray()) + saveStatusToDrafts(failedStatus) val notification = buildDraftNotification( @@ -243,6 +283,9 @@ class SendStatusService : Service(), Injectable { private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { + + mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray()) + val sendJob = sendJobs.remove(statusId) sendJob?.cancel() @@ -272,13 +315,14 @@ class SendStatusService : Service(), Injectable { contentWarning = status.warningText, sensitive = status.sensitive, visibility = Status.Visibility.byString(status.visibility), - mediaUris = status.mediaUris, - mediaDescriptions = status.mediaDescriptions, - mediaFocus = status.mediaFocus, + mediaUris = status.media.map { it.uri }, + mediaDescriptions = status.media.map { it.description }, + mediaFocus = status.media.map { it.focus }, poll = status.poll, failedToSend = true, scheduledAt = status.scheduledAt, language = status.language, + statusId = status.statusId, ) } @@ -346,17 +390,17 @@ class SendStatusService : Service(), Injectable { val intent = Intent(context, SendStatusService::class.java) intent.putExtra(KEY_STATUS, statusToSend) - if (statusToSend.mediaUris.isNotEmpty()) { + if (statusToSend.media.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Status Media", arrayOf("image/*", "video/*")), - ClipData.Item(statusToSend.mediaUris[0]) + ClipData.Item(statusToSend.media[0].uri) ) - statusToSend.mediaUris + statusToSend.media .drop(1) - .forEach { mediaUri -> - uriClip.addItem(ClipData.Item(mediaUri)) + .forEach { mediaItem -> + uriClip.addItem(ClipData.Item(mediaItem.uri)) } intent.clipData = uriClip @@ -373,10 +417,7 @@ data class StatusToSend( val warningText: String, val visibility: String, val sensitive: Boolean, - val mediaIds: List, - val mediaUris: List, - val mediaDescriptions: List, - val mediaFocus: List, + val media: List, val scheduledAt: String?, val inReplyToId: String?, val poll: NewPoll?, @@ -387,6 +428,16 @@ data class StatusToSend( val draftId: Int, val idempotencyKey: String, var retries: Int, - val mediaProcessed: MutableList, val language: String?, + val statusId: String?, +) : Parcelable + +@Parcelize +data class MediaToSend( + val localId: Int, + val id: String?, // null if media is not yet completely uploaded + val uri: String, + val description: String?, + val focus: Attachment.Focus?, + var processed: Boolean ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt new file mode 100644 index 000000000..f1e04bd56 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt @@ -0,0 +1,32 @@ +package com.keylesspalace.tusky.settings + +import java.net.IDN + +class ProxyConfiguration private constructor( + val hostname: String, + val port: Int +) { + companion object { + fun create(hostname: String, port: Int): ProxyConfiguration? { + if (isValidHostname(IDN.toASCII(hostname)) && isValidProxyPort(port)) { + return ProxyConfiguration(hostname, port) + } + return null + } + fun isValidProxyPort(value: Any): Boolean = when (value) { + is String -> if (value == "") true else value.runCatching(String::toInt).map( + PROXY_RANGE::contains + ).getOrDefault(false) + is Int -> PROXY_RANGE.contains(value) + else -> false + } + fun isValidHostname(hostname: String): Boolean = + IP_ADDRESS_REGEX.matches(hostname) || HOSTNAME_REGEX.matches(hostname) + const val MIN_PROXY_PORT = 1 + const val MAX_PROXY_PORT = 65535 + } +} + +private val PROXY_RANGE = IntRange(ProxyConfiguration.MIN_PROXY_PORT, ProxyConfiguration.MAX_PROXY_PORT) +private val IP_ADDRESS_REGEX = Regex("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$") +private val HOSTNAME_REGEX = Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$") 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 e0ab6b33f..29bf1b6e5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -21,6 +21,7 @@ object PrefKeys { const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" + const val READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 852700811..fc7a51c58 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -1,8 +1,10 @@ package com.keylesspalace.tusky.settings import android.content.Context +import android.widget.Button import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes +import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleOwner import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference @@ -50,10 +52,25 @@ inline fun PreferenceParent.switchPreference( return pref } -inline fun PreferenceParent.editTextPreference( +inline fun PreferenceParent.validatedEditTextPreference( + errorMessage: String?, + crossinline isValid: (a: String) -> Boolean, builder: EditTextPreference.() -> Unit ): EditTextPreference { val pref = EditTextPreference(context) + pref.setOnBindEditTextListener { editText -> + editText.doAfterTextChanged { editable -> + requireNotNull(editable) + val btn = editText.rootView.findViewById