Merge remote-tracking branch 'tuskyapp/develop'

# Conflicts:
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
#	app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt
#	app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt
#	app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
#	app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java
#	app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt
#	app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java
#	app/src/main/res/layout/item_status_detailed.xml
#	app/src/main/res/values-cy/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-es/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-ja/strings.xml
#	app/src/main/res/values-nb-rNO/strings.xml
#	app/src/main/res/values-oc/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values/strings.xml
#	fastlane/metadata/android/sv/changelogs/74.txt
#	fastlane/metadata/android/tr/changelogs/58.txt
#	gradle/libs.versions.toml
This commit is contained in:
kyori19 2023-01-25 08:46:02 +09:00
commit eeb6c3c96b
No known key found for this signature in database
GPG Key ID: F7BDE7DD42BF366A
223 changed files with 8548 additions and 3213 deletions

47
Release.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.keylesspalace.tusky">
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,8 @@ import com.keylesspalace.tusky.util.removeDuplicates
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
var accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean
protected val animateEmojis: Boolean,
protected val showBotOverlay: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false

View File

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

View File

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

View File

@ -31,11 +31,13 @@ import com.keylesspalace.tusky.util.loadAvatar
class BlocksAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
animateEmojis: Boolean,
showBotOverlay: Boolean,
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis
animateEmojis,
showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
val view = LayoutInflater.from(parent.context)

View File

@ -27,7 +27,8 @@ import java.util.Locale
class EmojiAdapter(
emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener
private val onEmojiSelectedListener: OnEmojiSelectedListener,
private val animate: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList: List<Emoji> = 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)

View File

@ -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<AccountViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<AccountViewHolder>(
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)
}
}

View File

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

View File

@ -23,8 +23,9 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
class FollowRequestsAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<FollowRequestViewHolder>(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)
}
}

View File

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

View File

@ -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<MutesAdapter.MutedUserViewHolder>(
animateEmojis: Boolean,
showBotOverlay: Boolean
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
accountActionListener,
animateAvatar,
animateEmojis
animateEmojis,
showBotOverlay
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
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<ItemMutedUserBinding> {
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<ItemMutedUserBinding>, 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<String, Boolean>?) {
mutingNotificationsMap.putAll(newMutingNotificationsMap!!)
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>) {
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) }
}
}
}

View File

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

View File

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

View File

@ -38,7 +38,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var emojis: List<Emoji> = emptyList()
private var resultClickListener: View.OnClickListener? = null
private var animateEmojis = false
private var enabled = true
@JvmOverloads
fun setup(
options: List<PollOptionViewData>,
voteCount: Int,
@ -46,7 +48,8 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
emojis: List<Emoji>,
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<BindingHolder<ItemPollBinding>>() {
this.mode = mode
this.resultClickListener = resultClickListener
this.animateEmojis = animateEmojis
this.enabled = enabled
notifyDataSetChanged()
}
@ -82,6 +86,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
radioButton.visible(mode == SINGLE)
checkBox.visible(mode == MULTIPLE)
radioButton.isEnabled = enabled
checkBox.isEnabled = enabled
when (mode) {
RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount)

View File

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

View File

@ -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<Emoji> 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<Attachment> attachments, boolean sensitive,
final StatusActionListener listener, boolean showingContent,
boolean useBlurhash) {
protected void setMediaPreviews(
final List<Attachment> 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Emoji>?) {
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

View File

@ -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<Int, Job>()
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<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = 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<AutocompleteResult> {
@ -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"
}

View File

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

View File

@ -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<UploadEvent>,
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<Int, UploadData>()
private var mostRecentId: Int = 0
fun getNewLocalMediaId(): Int {
return mostRecentId++
}
suspend fun getMediaUploadState(localId: Int): FinalUploadEvent {
return uploads[localId]?.flow
?.filterIsInstance<FinalUploadEvent>()
?.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<UploadEvent> {
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<UploadEvent> {
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()
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<Long> 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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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())
}
}

View File

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

View File

@ -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 <http://www.gnu.org/licenses>. */
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);
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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)
}
}

View File

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

View File

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

View File

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

View File

@ -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<Status> {
suspend fun getStatus(statusId: String): NetworkResult<Status> {
return api.status(statusId)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Preference> {
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DeletedStatus> {
return timelineCases.delete(id)
fun deleteStatusAsync(id: String): Deferred<NetworkResult<DeletedStatus>> {
return viewModelScope.async {
timelineCases.delete(id)
}
}
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {

View File

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

View File

@ -30,7 +30,8 @@ class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
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)
)
}

View File

@ -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<T : Any> :
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var mastodonApi: MastodonApi
protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory }
protected val binding by viewBinding(FragmentSearchBinding::bind)

View File

@ -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<StatusViewData.Concrete>(), StatusActionListener {
@ -446,7 +446,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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()

View File

@ -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<StatusViewData.Concrete>(), StatusActionListener {
@ -226,6 +226,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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()
}
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -283,7 +283,7 @@ class NetworkTimelineViewModel @Inject constructor(
limit: Int
): Response<List<Status>> {
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 -> {

View File

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

View File

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

View File

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

View File

@ -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<ThreadUiState> = 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<StatusViewData.Concrete>.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<StatusViewData.Concrete>.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<StatusViewData.Concrete>,
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<StatusViewData.Concrete>,
val revealButton: RevealButtonState,
val detailedStatusPosition: Int
) : ThreadUiState
/** Refreshing the thread with a swipe */
object Refreshing : ThreadUiState
}
enum class RevealButtonState {

View File

@ -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<StatusEdit>,
private val animateAvatars: Boolean,
private val animateEmojis: Boolean,
private val useBlurhash: Boolean,
private val listener: LinkListener
) : RecyclerView.Adapter<BindingHolder<ItemStatusEditBinding>>() {
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusEditBinding> {
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.statusEditMediaPreview.clipToOutline = true
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemStatusEditBinding>, 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
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
val uiState: Flow<EditsUiState>
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<StatusEdit>
) : EditsUiState
}

View File

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

View File

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

View File

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

View File

@ -42,6 +42,7 @@ data class DraftEntity(
val failedToSend: Boolean,
val scheduledAt: String?,
val language: String?,
val statusId: String?,
)
/**

View File

@ -54,6 +54,29 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
)
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
@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<String>
/** Developer tools: Convert a status to a placeholder */
@Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId")
abstract suspend fun convertStatustoPlaceholder(serverId: String)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Attachment>,
val emojis: List<Emoji>
)

View File

@ -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 <http://www.gnu.org/licenses>. */
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,
)

View File

@ -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<Response<List<TimelineAccount>>> {
private suspend fun getFetchCallByListType(fromId: String?): Response<List<TimelineAccount>> {
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<TimelineAccount>, 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)

View File

@ -924,11 +924,11 @@ public class NotificationsFragment extends SFragment implements
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
FetchEnd fetchEnd, int pos) {
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
List<HttpHeaderLink> 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) {

View File

@ -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 <http://www.gnu.org/licenses>. */
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<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> 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<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> 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<Status.Mention> 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<AttachmentViewData> 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);
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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<AttachmentViewData>, 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<String?>?, 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<Status.Mention>): Boolean {
return mentions.any { mention ->
account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host
}
}
}
}

View File

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

View File

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

View File

@ -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<Date?>() {
null
}
else -> {
reader.nextString().parseIsoDate()
try {
reader.nextString().parseIsoDate()
} catch (jpe: JsonParseException) {
Log.w("Rfc3339DateJsonAdapter", jpe)
null
}
}
}
}

View File

@ -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<Instance>
@GET("api/v1/filters")
fun getFilters(): Single<List<Filter>>
suspend fun getFilters(): NetworkResult<List<Filter>>
@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<List<Status>>
@ -165,36 +168,55 @@ interface MastodonApi {
): NetworkResult<Status>
@GET("api/v1/statuses/{id}")
fun status(
suspend fun status(
@Path("id") statusId: String
): Single<Status>
): NetworkResult<Status>
@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<Status>
@GET("api/v1/statuses/{id}")
suspend fun statusAsync(
@Path("id") statusId: String
): NetworkResult<Status>
@GET("api/v1/statuses/{id}/source")
suspend fun statusSource(
@Path("id") statusId: String
): NetworkResult<StatusSource>
@GET("api/v1/statuses/{id}/context")
suspend fun statusContext(
@Path("id") statusId: String
): NetworkResult<StatusContext>
@GET("api/v1/statuses/{id}/history")
suspend fun statusEdits(
@Path("id") statusId: String
): NetworkResult<List<StatusEdit>>
@GET("api/v1/statuses/{id}/reblogged_by")
fun statusRebloggedBy(
suspend fun statusRebloggedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@GET("api/v1/statuses/{id}/favourited_by")
fun statusFavouritedBy(
suspend fun statusFavouritedBy(
@Path("id") statusId: String,
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@DELETE("api/v1/statuses/{id}")
fun deleteStatus(
suspend fun deleteStatus(
@Path("id") statusId: String
): Single<DeletedStatus>
): NetworkResult<DeletedStatus>
@POST("api/v1/statuses/{id}/reblog")
fun reblogStatus(
@ -331,52 +353,52 @@ interface MastodonApi {
): Response<List<Status>>
@GET("api/v1/accounts/{id}/followers")
fun accountFollowers(
suspend fun accountFollowers(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@GET("api/v1/accounts/{id}/following")
fun accountFollowing(
suspend fun accountFollowing(
@Path("id") accountId: String,
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@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>
): Relationship
@POST("api/v1/accounts/{id}/unfollow")
fun unfollowAccount(
suspend fun unfollowAccount(
@Path("id") accountId: String
): Single<Relationship>
): Relationship
@POST("api/v1/accounts/{id}/block")
fun blockAccount(
suspend fun blockAccount(
@Path("id") accountId: String
): Single<Relationship>
): Relationship
@POST("api/v1/accounts/{id}/unblock")
fun unblockAccount(
suspend fun unblockAccount(
@Path("id") accountId: String
): Single<Relationship>
): 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>
): Relationship
@POST("api/v1/accounts/{id}/unmute")
fun unmuteAccount(
suspend fun unmuteAccount(
@Path("id") accountId: String
): Single<Relationship>
): Relationship
@GET("api/v1/accounts/relationships")
fun relationships(
@ -384,24 +406,24 @@ interface MastodonApi {
): Single<List<Relationship>>
@POST("api/v1/pleroma/accounts/{id}/subscribe")
fun subscribeAccount(
suspend fun subscribeAccount(
@Path("id") accountId: String
): Single<Relationship>
): Relationship
@POST("api/v1/pleroma/accounts/{id}/unsubscribe")
fun unsubscribeAccount(
suspend fun unsubscribeAccount(
@Path("id") accountId: String
): Single<Relationship>
): Relationship
@GET("api/v1/blocks")
fun blocks(
suspend fun blocks(
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@GET("api/v1/mutes")
fun mutes(
suspend fun mutes(
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@GET("api/v1/domain_blocks")
fun domainBlocks(
@ -436,9 +458,9 @@ interface MastodonApi {
): Response<List<Status>>
@GET("api/v1/follow_requests")
fun followRequests(
suspend fun followRequests(
@Query("max_id") maxId: String?
): Single<Response<List<TimelineAccount>>>
): Response<List<TimelineAccount>>
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest(

View File

@ -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<MediaUploadResult>
): Response<MediaUploadResult>
}

View File

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

View File

@ -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<String>,
val mediaUris: List<String>,
val mediaDescriptions: List<String>,
val mediaFocus: List<Attachment.Focus?>,
val media: List<MediaToSend>,
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<Boolean>,
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

View File

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

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