Merge tag 'v21.0' into develop

This commit is contained in:
Mike Barnes 2023-07-30 19:51:49 +10:00
commit f9a29a6b76
306 changed files with 14908 additions and 3982 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,10 +22,11 @@ android {
compileSdkVersion 33 compileSdkVersion 33
defaultConfig { defaultConfig {
applicationId APP_ID applicationId APP_ID
namespace "com.keylesspalace.tusky"
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 33 targetSdkVersion 33
versionCode 97 versionCode 100
versionName "20.0" versionName "21.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -72,6 +73,9 @@ android {
returnDefaultValues = true returnDefaultValues = true
includeAndroidResources = true includeAndroidResources = true
} }
unitTests.all {
systemProperty 'robolectric.logging.enabled', 'true'
}
} }
sourceSets { sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
@ -88,8 +92,9 @@ android {
enableSplit = false enableSplit = false
} }
} }
kotlinOptions { dependenciesInfo {
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" includeInApk false
includeInBundle false
} }
} }

View file

@ -111,3 +111,7 @@
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
static void throwUninitializedPropertyAccessException(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,965 @@
{
"formatVersion": 1,
"database": {
"version": 44,
"identityHash": "7b5271980102f35e55438f46777e3d46",
"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)",
"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
}
],
"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, `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, 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": "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
}
],
"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_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.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, '7b5271980102f35e55438f46777e3d46')"
]
}
}

View file

@ -0,0 +1,977 @@
{
"formatVersion": 1,
"database": {
"version": 45,
"identityHash": "cb4d4c0de04e945005adbb43bc534378",
"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)",
"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
}
],
"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, 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
}
],
"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, 'cb4d4c0de04e945005adbb43bc534378')"
]
}
}

View file

@ -0,0 +1,983 @@
{
"formatVersion": 1,
"database": {
"version": 46,
"identityHash": "3cdfad61c4cf7e1ad5c70783e60e6845",
"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, 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
}
],
"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, '3cdfad61c4cf7e1ad5c70783e60e6845')"
]
}
}

View file

@ -0,0 +1,989 @@
{
"formatVersion": 1,
"database": {
"version": 47,
"identityHash": "496e1f2135a296e49eef88551ecbdd2c",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "failedToSendNew",
"columnName": "failedToSendNew",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledAt",
"columnName": "scheduledAt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "statusId",
"columnName": "statusId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"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, 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
}
],
"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, '496e1f2135a296e49eef88551ecbdd2c')"
]
}
}

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools" >
package="com.keylesspalace.tusky">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@ -39,7 +38,18 @@
</activity> </activity>
<activity <activity
android:name=".components.login.LoginActivity" android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:scheme="@string/oauth_scheme" />
</intent-filter>
</activity> </activity>
<activity android:name=".components.login.LoginWebViewActivity" /> <activity android:name=".components.login.LoginWebViewActivity" />
<activity <activity
@ -133,6 +143,7 @@
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" /> <activity android:name=".FiltersActivity" />
<activity android:name=".components.followedtags.FollowedTagsActivity" />
<activity <activity
android:name=".components.report.ReportActivity" android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />

View file

@ -21,6 +21,7 @@ import android.content.SharedPreferences;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.MenuItem; import android.view.MenuItem;
@ -35,6 +36,7 @@ import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity; 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 */ /* set the taskdescription programmatically, the theme would turn it blue */
String appName = getString(R.string.app_name); String appName = getString(R.string.app_name);
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); 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)); setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
int style = textStyle(preferences.getString("statusTextSize", "medium")); int style = textStyle(preferences.getString("statusTextSize", "medium"));
getTheme().applyStyle(style, false); getTheme().applyStyle(style, true);
if(requiresLogin()) { if(requiresLogin()) {
redirectIfNotLoggedIn(); redirectIfNotLoggedIn();

View file

@ -29,10 +29,9 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.looksLikeMastodonUrl
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.net.URI
import java.net.URISyntaxException
import javax.inject.Inject import javax.inject.Inject
/** this is the base class for all activities that open links /** this is the base class for all activities that open links
@ -173,45 +172,6 @@ abstract class BottomSheetActivity : BaseActivity() {
} }
} }
// https://mastodon.foo.bar/@User
// https://mastodon.foo.bar/@User/43456787654678
// https://pleroma.foo.bar/users/User
// https://pleroma.foo.bar/users/9qTHT2ANWUdXzENqC0
// https://pleroma.foo.bar/notice/9sBHWIlwwGZi5QGlHc
// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://friendica.foo.bar/profile/user
// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://misskey.foo.bar/notes/83w6r388br (always lowercase)
// https://pixelfed.social/p/connyduck/391263492998670833
// https://pixelfed.social/connyduck
fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
try {
uri = URI(urlString)
} catch (e: URISyntaxException) {
return false
}
if (uri.query != null ||
uri.fragment != null ||
uri.path == null
) {
return false
}
val path = uri.path
return path.matches("^/@[^/]+$".toRegex()) ||
path.matches("^/@[^/]+/\\d+$".toRegex()) ||
path.matches("^/users/\\w+$".toRegex()) ||
path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
path.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
path.matches("^/notes/[a-z0-9]+$".toRegex()) ||
path.matches("^/display/[-a-f0-9]+$".toRegex()) ||
path.matches("^/profile/\\w+$".toRegex()) ||
path.matches("^/p/\\w+/\\d+$".toRegex()) ||
path.matches("^/\\w+$".toRegex())
}
enum class PostLookupFallbackBehavior { enum class PostLookupFallbackBehavior {
OPEN_IN_BROWSER, OPEN_IN_BROWSER,
DISPLAY_ERROR, DISPLAY_ERROR,

View file

@ -7,6 +7,7 @@ import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding 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.setupEditDialogForFilter
import com.keylesspalace.tusky.view.showAddFilterDialog import com.keylesspalace.tusky.view.showAddFilterDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@ -150,12 +150,10 @@ class FiltersActivity : BaseActivity() {
binding.filterProgressBar.show() binding.filterProgressBar.show()
lifecycleScope.launch { lifecycleScope.launch {
val newFilters = try { val newFilters = api.getFilters().getOrElse {
api.getFilters().await()
} catch (t: Exception) {
binding.filterProgressBar.hide() binding.filterProgressBar.hide()
binding.filterMessageView.show() binding.filterMessageView.show()
if (t is IOException) { if (it is IOException) {
binding.filterMessageView.setup( binding.filterMessageView.setup(
R.drawable.elephant_offline, R.drawable.elephant_offline,
R.string.error_network R.string.error_network

View file

@ -38,12 +38,12 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show 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) return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
.let(this::ListViewHolder) .let(this::ListViewHolder)
.apply { .apply {
val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary)
val context = nameTextView.context 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 } val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)

View file

@ -20,7 +20,6 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
@ -52,6 +51,7 @@ import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition 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
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@ -76,6 +76,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.AccountSelectionListener
@ -83,11 +84,13 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -140,6 +143,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject @Inject
lateinit var logoutUsecase: LogoutUsecase lateinit var logoutUsecase: LogoutUsecase
@Inject
lateinit var draftsAlert: DraftsAlert
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
private val binding by viewBinding(ActivityMainBinding::inflate) private val binding by viewBinding(ActivityMainBinding::inflate)
private lateinit var header: AccountHeaderView private lateinit var header: AccountHeaderView
@ -241,7 +250,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
sizeDp = 20 sizeDp = 20
colorInt = ThemeUtils.getColor(this@MainActivity, android.R.attr.textColorPrimary) colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
} }
setOnMenuItemClickListener { setOnMenuItemClickListener {
startActivity(SearchActivity.getIntent(this@MainActivity)) startActivity(SearchActivity.getIntent(this@MainActivity))
@ -249,6 +258,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
binding.viewPager.reduceSwipeSensitivity()
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar) setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
/* Fetch user info while we're doing other things. This has to be done after setting up the /* Fetch user info while we're doing other things. This has to be done after setting up the
@ -306,6 +317,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
1 1
) )
} }
// "Post failed" dialog should display in this activity
draftsAlert.observeInContext(this, true)
} }
override fun onResume() { override fun onResume() {
@ -384,9 +398,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) { private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
binding.mainToolbar.setNavigationOnClickListener { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
binding.mainDrawerLayout.open()
} binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
header = AccountHeaderView(this).apply { header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
@ -407,7 +423,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) 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) val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
DrawerImageLoader.init(object : AbstractDrawerImageLoader() { DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
@ -503,8 +519,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
} }
badgeStyle = BadgeStyle().apply { badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary)) textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary)) color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary))
} }
}, },
DividerDrawerItem(), DividerDrawerItem(),
@ -556,27 +572,56 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
if (BuildConfig.DEBUG) { 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( binding.mainDrawer.addItems(
DividerDrawerItem(),
secondaryDrawerItem { secondaryDrawerItem {
nameText = "debug" nameText = "Developer tools"
isEnabled = false isEnabled = true
textColor = ColorStateList.valueOf(Color.GREEN) iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
onClick = {
buildDeveloperToolsDialog().show()
}
} }
) )
} }
} }
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) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState))
} }
private fun setupTabs(selectNotificationTab: Boolean) { private fun setupTabs(selectNotificationTab: Boolean) {
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { 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) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
binding.tabLayout.hide() binding.topNav.hide()
binding.bottomTabLayout binding.bottomTabLayout
} else { } else {
binding.bottomNav.hide() binding.bottomNav.hide()
@ -612,7 +657,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin))
val enableSwipeForTabs = preferences.getBoolean("enableSwipeForTabs", true) val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
binding.viewPager.isUserInputEnabled = enableSwipeForTabs binding.viewPager.isUserInputEnabled = enableSwipeForTabs
onTabSelectedListener?.let { onTabSelectedListener?.let {
@ -749,71 +794,117 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
if (animateAvatars) { if (hideTopToolbar) {
glide.asDrawable() val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) { val avatarView = if (navOnBottom) {
if (placeholder != null) { binding.bottomNavAvatar.show()
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) binding.bottomNavAvatar
} } else {
} binding.topNavAvatar.show()
binding.topNavAvatar
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { if (animateAvatars) {
if (resource is Animatable) { Glide.with(this)
resource.start() .load(avatarUrl)
} .placeholder(R.drawable.avatar_default)
binding.mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) .into(avatarView)
} } else {
Glide.with(this)
override fun onLoadCleared(placeholder: Drawable?) { .asBitmap()
if (placeholder != null) { .load(avatarUrl)
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) .placeholder(R.drawable.avatar_default)
} .into(avatarView)
} }
})
} else { } else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
.into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) { binding.bottomNavAvatar.hide()
if (placeholder != null) { binding.topNavAvatar.hide()
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
if (animateAvatars) {
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
} }
} }
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onLoadStarted(placeholder: Drawable?) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(BitmapDrawable(resources, resource), navIconSize, navIconSize) if (placeholder != null) {
} binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onLoadCleared(placeholder: Drawable?) { override fun onResourceReady(
if (placeholder != null) { resource: Drawable,
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) transition: Transition<in Drawable>?
) {
if (resource is Animatable) {
resource.start()
}
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(resource, navIconSize, navIconSize)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
} else {
glide.asBitmap()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
} }
} }
}) .into(object : CustomTarget<Bitmap>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(
resource: Bitmap,
transition: Transition<in Bitmap>?
) {
binding.mainToolbar.navigationIcon = FixedSizeDrawable(
BitmapDrawable(resources, resource),
navIconSize,
navIconSize
)
}
override fun onLoadCleared(placeholder: Drawable?) {
if (placeholder != null) {
binding.mainToolbar.navigationIcon =
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
})
}
} }
} }

View file

@ -25,9 +25,12 @@ import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
@ -39,13 +42,22 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject @Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any> lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate)
private lateinit var kind: Kind private lateinit var kind: Kind
private var hashtag: String? = null private var hashtag: String? = null
private var followTagItem: MenuItem? = null private var followTagItem: MenuItem? = null
private var unfollowTagItem: MenuItem? = null private var unfollowTagItem: MenuItem? = null
private var muteTagItem: MenuItem? = null
private var unmuteTagItem: MenuItem? = null
/** The filter muting hashtag, null if unknown or hashtag is not filtered */
private var mutedFilter: Filter? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.d("StatusListActivity", "onCreate")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
@ -89,10 +101,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
menuInflater.inflate(R.menu.view_hashtag_toolbar, menu) menuInflater.inflate(R.menu.view_hashtag_toolbar, menu)
followTagItem = menu.findItem(R.id.action_follow_hashtag) followTagItem = menu.findItem(R.id.action_follow_hashtag)
unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag) unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag)
muteTagItem = menu.findItem(R.id.action_mute_hashtag)
unmuteTagItem = menu.findItem(R.id.action_unmute_hashtag)
followTagItem?.isVisible = tagEntity.following == false followTagItem?.isVisible = tagEntity.following == false
unfollowTagItem?.isVisible = tagEntity.following == true unfollowTagItem?.isVisible = tagEntity.following == true
followTagItem?.setOnMenuItemClickListener { followTag() } followTagItem?.setOnMenuItemClickListener { followTag() }
unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() } unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() }
muteTagItem?.setOnMenuItemClickListener { muteTag() }
unmuteTagItem?.setOnMenuItemClickListener { unmuteTag() }
updateMuteTagMenuItems()
}, },
{ {
Log.w(TAG, "Failed to query tag #$tag", it) Log.w(TAG, "Failed to query tag #$tag", it)
@ -144,6 +161,90 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
return true return true
} }
/**
* Determine if the current hashtag is muted, and update the UI state accordingly.
*/
private fun updateMuteTagMenuItems() {
val tag = hashtag ?: return
muteTagItem?.isVisible = true
muteTagItem?.isEnabled = false
unmuteTagItem?.isVisible = false
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
},
{ throwable ->
Log.e(TAG, "Error getting filters: $throwable")
}
)
}
}
private fun muteTag(): Boolean {
val tag = hashtag ?: return true
lifecycleScope.launch {
mastodonApi.createFilter(
tag,
listOf(Filter.HOME),
irreversible = false,
wholeWord = true,
expiresInSeconds = null
).fold(
{ filter ->
mutedFilter = filter
muteTagItem?.isVisible = false
unmuteTagItem?.isVisible = true
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
},
{
Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to mute #$tag", it)
}
)
}
return true
}
private fun unmuteTag(): Boolean {
val filter = mutedFilter ?: return true
lifecycleScope.launch {
mastodonApi.deleteFilter(filter.id).fold(
{
muteTagItem?.isVisible = true
unmuteTagItem?.isVisible = false
eventHub.dispatch(PreferenceChangedEvent(filter.context[0]))
mutedFilter = null
},
{
Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, filter.phrase), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to unmute #${filter.phrase}", it)
}
)
}
return true
}
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {

View file

@ -22,8 +22,9 @@ import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector 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.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.setAppNightMode
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
@ -72,8 +73,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
// init night mode // init night mode
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
ThemeUtils.setAppNightMode(theme) setAppNightMode(theme)
localeManager.setLocale() localeManager.setLocale()

View file

@ -52,6 +52,7 @@ import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.fragment.ViewVideoFragment
import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.getTemporaryMediaFilename
@ -68,7 +69,7 @@ import java.util.Locale
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener { class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
private val binding by viewBinding(ActivityViewMediaBinding::inflate) private val binding by viewBinding(ActivityViewMediaBinding::inflate)

View file

@ -26,7 +26,8 @@ import com.keylesspalace.tusky.util.removeDuplicates
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor( abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
var accountActionListener: AccountActionListener, var accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean, protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean protected val animateEmojis: Boolean,
protected val showBotOverlay: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<TimelineAccount>() var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false 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( class BlocksAdapter(
accountActionListener: AccountActionListener, accountActionListener: AccountActionListener,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean,
showBotOverlay: Boolean,
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>( ) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
accountActionListener, accountActionListener,
animateAvatar, animateAvatar,
animateEmojis animateEmojis,
showBotOverlay
) { ) {
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder { override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)

View file

@ -17,6 +17,7 @@ package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding
@ -26,7 +27,8 @@ import java.util.Locale
class EmojiAdapter( class EmojiAdapter(
emojiList: List<Emoji>, emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener private val onEmojiSelectedListener: OnEmojiSelectedListener,
private val animate: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
@ -43,15 +45,23 @@ class EmojiAdapter(
val emoji = emojiList[position] val emoji = emojiList[position]
val emojiImageView = holder.binding.root val emojiImageView = holder.binding.root
Glide.with(emojiImageView) if (animate) {
.load(emoji.url) Glide.with(emojiImageView)
.into(emojiImageView) .load(emoji.url)
.into(emojiImageView)
} else {
Glide.with(emojiImageView)
.asBitmap()
.load(emoji.url)
.into(emojiImageView)
}
emojiImageView.setOnClickListener { emojiImageView.setOnClickListener {
onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) onEmojiSelectedListener.onEmojiSelected(emoji.shortcode)
} }
emojiImageView.contentDescription = emoji.shortcode emojiImageView.contentDescription = emoji.shortcode
TooltipCompat.setTooltipText(emojiImageView, emoji.shortcode)
} }
} }

View file

@ -16,23 +16,37 @@ package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
/** Displays either a follows or following list. */ /** Displays either a follows or following list. */
class FollowAdapter( class FollowAdapter(
accountActionListener: AccountActionListener, accountActionListener: AccountActionListener,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean,
) : AccountAdapter<AccountViewHolder>(accountActionListener, animateAvatar, animateEmojis) { showBotOverlay: Boolean
) : AccountAdapter<AccountViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis,
showBotOverlay
) {
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
val view = LayoutInflater.from(parent.context) val binding = ItemAccountBinding.inflate(
.inflate(R.layout.item_account, parent, false) LayoutInflater.from(parent.context),
return AccountViewHolder(view) parent,
false
)
return AccountViewHolder(binding)
} }
override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) { override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) viewHolder.setupWithAccount(
accountList[position],
animateAvatar,
animateEmojis,
showBotOverlay
)
viewHolder.setupActionListener(accountActionListener) viewHolder.setupActionListener(accountActionListener)
} }
} }

View file

@ -34,7 +34,12 @@ class FollowRequestViewHolder(
private val showHeader: Boolean private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) { ) : 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 wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName binding.displayNameTextView.text = emojifiedName

View file

@ -23,8 +23,9 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
class FollowRequestsAdapter( class FollowRequestsAdapter(
accountActionListener: AccountActionListener, accountActionListener: AccountActionListener,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean,
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis) { showBotOverlay: Boolean
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) {
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate( val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context), parent, false LayoutInflater.from(parent.context), parent, false
@ -33,7 +34,7 @@ class FollowRequestsAdapter(
} }
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { 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) viewHolder.setupActionListener(accountActionListener, accountList[position].id)
} }
} }

View file

@ -21,14 +21,15 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.TextView 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 com.keylesspalace.tusky.util.modernLanguageCode
import java.util.Locale import java.util.Locale
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) { class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getView(position, convertView, parent) as TextView).apply { 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 typeface = Typeface.DEFAULT_BOLD
text = super.getItem(position)?.modernLanguageCode?.uppercase() text = super.getItem(position)?.modernLanguageCode?.uppercase()
} }
@ -36,9 +37,8 @@ class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : Ar
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getDropDownView(position, convertView, parent) as TextView).apply { 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))
val locale = super.getItem(position) text = super.getItem(position)?.getTuskyDisplayName(context)
text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})"
} }
} }
} }

View file

@ -1,19 +1,14 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R 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.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import java.util.HashMap
/** /**
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
@ -22,29 +17,68 @@ import java.util.HashMap
class MutesAdapter( class MutesAdapter(
accountActionListener: AccountActionListener, accountActionListener: AccountActionListener,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean,
) : AccountAdapter<MutesAdapter.MutedUserViewHolder>( showBotOverlay: Boolean
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
accountActionListener, accountActionListener,
animateAvatar, animateAvatar,
animateEmojis animateEmojis,
showBotOverlay
) { ) {
private val mutingNotificationsMap = HashMap<String, Boolean>() private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): MutedUserViewHolder { override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
val view = LayoutInflater.from(parent.context) val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
.inflate(R.layout.item_muted_user, parent, false) return BindingHolder(binding)
return MutedUserViewHolder(view)
} }
override fun onBindAccountViewHolder(viewHolder: MutedUserViewHolder, position: Int) { override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
val account = accountList[position] val account = accountList[position]
viewHolder.setupWithAccount( val binding = viewHolder.binding
account, val context = binding.root.context
mutingNotificationsMap[account.id],
animateAvatar, val mutingNotifications = mutingNotificationsMap[account.id]
animateEmojis
) val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis)
viewHolder.setupActionListener(accountActionListener) 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) { fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) {
@ -52,81 +86,8 @@ class MutesAdapter(
notifyItemChanged(position) notifyItemChanged(position)
} }
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>?) { fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>) {
mutingNotificationsMap.putAll(newMutingNotificationsMap!!) mutingNotificationsMap.putAll(newMutingNotificationsMap)
notifyDataSetChanged() 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

@ -42,6 +42,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
@ -80,7 +81,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_FOLLOW = 2; private static final int VIEW_TYPE_FOLLOW = 2;
private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; private static final int VIEW_TYPE_FOLLOW_REQUEST = 3;
private static final int VIEW_TYPE_PLACEHOLDER = 4; private static final int VIEW_TYPE_PLACEHOLDER = 4;
private static final int VIEW_TYPE_UNKNOWN = 5; private static final int VIEW_TYPE_REPORT = 5;
private static final int VIEW_TYPE_UNKNOWN = 6;
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
@ -137,6 +139,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
.inflate(R.layout.item_status_placeholder, parent, false); .inflate(R.layout.item_status_placeholder, parent, false);
return new PlaceholderViewHolder(view); return new PlaceholderViewHolder(view);
} }
case VIEW_TYPE_REPORT: {
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
return new ReportNotificationViewHolder(binding);
}
default: default:
case VIEW_TYPE_UNKNOWN: { case VIEW_TYPE_UNKNOWN: {
View view = new View(parent.getContext()); View view = new View(parent.getContext());
@ -247,11 +253,18 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW_REQUEST: { case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) { if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; 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()); holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
} }
break; break;
} }
case VIEW_TYPE_REPORT: {
if (payloadForHolder == null) {
ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder;
holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId());
}
}
default: default:
} }
} }
@ -304,6 +317,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case FOLLOW_REQUEST: { case FOLLOW_REQUEST: {
return VIEW_TYPE_FOLLOW_REQUEST; return VIEW_TYPE_FOLLOW_REQUEST;
} }
case REPORT: {
return VIEW_TYPE_REPORT;
}
default: { default: {
return VIEW_TYPE_UNKNOWN; return VIEW_TYPE_UNKNOWN;
} }
@ -322,6 +338,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
void onViewStatusForNotificationId(String notificationId); void onViewStatusForNotificationId(String notificationId);
void onViewReport(String reportId);
void onExpandedChange(boolean expanded, int position); void onExpandedChange(boolean expanded, int position);
/** /**
@ -418,7 +436,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
statusNameBar = itemView.findViewById(R.id.status_name_bar); statusNameBar = itemView.findViewById(R.id.status_name_bar);
displayName = itemView.findViewById(R.id.status_display_name); displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username); 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); statusContent = itemView.findViewById(R.id.notification_content);
statusAvatar = itemView.findViewById(R.id.notification_status_avatar); statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);

View file

@ -15,26 +15,52 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import android.view.View import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import androidx.recyclerview.widget.RecyclerView 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.R
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
/** /**
* Placeholder for different timelines. * 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) { class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val loadMoreButton: Button = itemView.findViewById(R.id.button_load_more) private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar) private val drawable = IndeterminateDrawable.createCircularDrawable(
itemView.context,
CircularProgressIndicatorSpec(itemView.context, null)
)
fun setup(listener: StatusActionListener, progress: Boolean) { fun setup(listener: StatusActionListener, loading: Boolean) {
loadMoreButton.visibility = if (progress) View.GONE else View.VISIBLE itemView.isEnabled = !loading
progressBar.visibility = if (progress) View.VISIBLE else View.GONE loadMoreButton.isEnabled = !loading
loadMoreButton.isEnabled = true
loadMoreButton.setOnClickListener { v: View? -> 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.isEnabled = false
loadMoreButton.icon = drawable
loadMoreButton.text = ""
listener.onLoadMore(bindingAdapterPosition) listener.onLoadMore(bindingAdapterPosition)
} }
} }

View file

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

View file

@ -0,0 +1,90 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding
import com.keylesspalace.tusky.entity.Report
import com.keylesspalace.tusky.entity.TimelineAccount
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
class ReportNotificationViewHolder(
private val binding: ItemReportNotificationBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) {
val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis)
val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis)
val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp)
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, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0)
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)
// Fancy avatar inset
val padding = Utils.dpToPx(binding.notificationReporteeAvatar.context, 12)
binding.notificationReporteeAvatar.setPaddingRelative(0, 0, padding, padding)
loadAvatar(
report.targetAccount.avatar,
binding.notificationReporteeAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp),
animateAvatar,
)
loadAvatar(
reporter.avatar,
binding.notificationReporterAvatar,
itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp),
animateAvatar,
)
}
fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) {
binding.notificationReporteeAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporteeId)
}
}
binding.notificationReporterAvatar.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewAccount(reporterId)
}
}
itemView.setOnClickListener { listener.onViewReport(reportId) }
}
private fun getTranslatedCategory(context: Context, rawCategory: String): String {
return when (rawCategory) {
"violation" -> context.getString(R.string.report_category_violation)
"spam" -> context.getString(R.string.report_category_spam)
"other" -> context.getString(R.string.report_category_other)
else -> rawCategory
}
}
}

View file

@ -1,5 +1,7 @@
package com.keylesspalace.tusky.adapter; package com.keylesspalace.tusky.adapter;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
import android.content.Context; import android.content.Context;
import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.ColorDrawable;
@ -23,6 +25,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat; import androidx.core.text.HtmlCompat;
import androidx.core.view.ViewKt;
import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -30,6 +33,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.RequestBuilder;
import com.google.android.material.button.MaterialButton; 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.imageview.ShapeableImageView;
import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.ShapeAppearanceModel;
@ -50,9 +54,10 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.util.TimestampUtils; import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.util.TouchDelegateHelper;
import com.keylesspalace.tusky.view.MediaPreviewImageView; import com.keylesspalace.tusky.view.MediaPreviewImageView;
import com.keylesspalace.tusky.view.MediaPreviewLayout;
import com.keylesspalace.tusky.viewdata.PollOptionViewData; import com.keylesspalace.tusky.viewdata.PollOptionViewData;
import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewData;
import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.PollViewDataKt;
@ -66,12 +71,11 @@ import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public static class Key { public static class Key {
public static final String KEY_CREATED = "created"; public static final String KEY_CREATED = "created";
} }
private TextView displayName; private TextView displayName;
private TextView username; private TextView username;
private ImageButton replyButton; private ImageButton replyButton;
@ -81,8 +85,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private SparkButton bookmarkButton; private SparkButton bookmarkButton;
private ImageButton moreButton; private ImageButton moreButton;
private ConstraintLayout mediaContainer; private ConstraintLayout mediaContainer;
protected MediaPreviewImageView[] mediaPreviews; protected MediaPreviewLayout mediaPreview;
private ImageView[] mediaOverlays;
private TextView sensitiveMediaWarning; private TextView sensitiveMediaWarning;
private View sensitiveMediaShow; private View sensitiveMediaShow;
protected TextView[] mediaLabels; protected TextView[] mediaLabels;
@ -91,7 +94,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private ImageView avatarInset; private ImageView avatarInset;
public ImageView avatar; public ImageView avatar;
public TextView timestampInfo; public TextView metaInfo;
public TextView content; public TextView content;
public TextView contentWarningDescription; public TextView contentWarningDescription;
@ -120,7 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
super(itemView); super(itemView);
displayName = itemView.findViewById(R.id.status_display_name); displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username); 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); content = itemView.findViewById(R.id.status_content);
avatar = itemView.findViewById(R.id.status_avatar); avatar = itemView.findViewById(R.id.status_avatar);
replyButton = itemView.findViewById(R.id.status_reply); replyButton = itemView.findViewById(R.id.status_reply);
@ -132,19 +135,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
mediaContainer = itemView.findViewById(R.id.status_media_preview_container); mediaContainer = itemView.findViewById(R.id.status_media_preview_container);
mediaContainer.setClipToOutline(true); mediaContainer.setClipToOutline(true);
mediaPreview = itemView.findViewById(R.id.status_media_preview);
mediaPreviews = new MediaPreviewImageView[]{
itemView.findViewById(R.id.status_media_preview_0),
itemView.findViewById(R.id.status_media_preview_1),
itemView.findViewById(R.id.status_media_preview_2),
itemView.findViewById(R.id.status_media_preview_3)
};
mediaOverlays = new ImageView[]{
itemView.findViewById(R.id.status_media_overlay_0),
itemView.findViewById(R.id.status_media_overlay_1),
itemView.findViewById(R.id.status_media_overlay_2),
itemView.findViewById(R.id.status_media_overlay_3)
};
sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning);
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button); sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
mediaLabels = new TextView[]{ mediaLabels = new TextView[]{
@ -178,10 +170,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); 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));
}
protected abstract int getMediaPreviewHeight(Context context); TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton));
}
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) { protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify( CharSequence emojifiedName = CustomEmojiHelper.emojify(
@ -318,19 +310,30 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
Status status = statusViewData.getActionable();
Date createdAt = status.getCreatedAt();
Date editedAt = status.getEditedAt();
String timestampText;
if (statusDisplayOptions.useAbsoluteTime()) { if (statusDisplayOptions.useAbsoluteTime()) {
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); timestampText = absoluteTimeFormatter.format(createdAt, true);
} else { } else {
if (createdAt == null) { if (createdAt == null) {
timestampInfo.setText("?m"); timestampText = "?m";
} else { } else {
long then = createdAt.getTime(); long then = createdAt.getTime();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
timestampInfo.setText(readout); timestampText = readout;
} }
} }
if (editedAt != null) {
timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText);
}
metaInfo.setText(timestampText);
} }
private CharSequence getCreatedAtDescription(Date createdAt, private CharSequence getCreatedAtDescription(Date createdAt,
@ -427,14 +430,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.load(placeholder) .load(placeholder)
.centerInside() .centerInside()
.into(imageView); .into(imageView);
} else { } else {
Focus focus = meta != null ? meta.getFocus() : null; Focus focus = meta != null ? meta.getFocus() : null;
if (focus != null) { // If there is a focal point for this attachment: if (focus != null) { // If there is a focal point for this attachment:
imageView.setFocalPoint(focus); imageView.setFocalPoint(focus);
Glide.with(imageView) Glide.with(imageView.getContext())
.load(previewUrl) .load(previewUrl)
.placeholder(placeholder) .placeholder(placeholder)
.centerInside() .centerInside()
@ -452,38 +454,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
} }
protected void setMediaPreviews(final List<Attachment> attachments, boolean sensitive, protected void setMediaPreviews(
final StatusActionListener listener, boolean showingContent, final List<Attachment> attachments,
boolean useBlurhash) { boolean sensitive,
Context context = itemView.getContext(); final StatusActionListener listener,
final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS); boolean showingContent,
boolean useBlurhash
) {
mediaPreview.setVisibility(View.VISIBLE);
mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments));
final int mediaPreviewHeight = getMediaPreviewHeight(context); mediaPreview.forEachIndexed((i, imageView, descriptionIndicator) -> {
if (n <= 2) {
mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight * 2;
mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight * 2;
} else {
mediaPreviews[0].getLayoutParams().height = mediaPreviewHeight;
mediaPreviews[1].getLayoutParams().height = mediaPreviewHeight;
mediaPreviews[2].getLayoutParams().height = mediaPreviewHeight;
mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight;
}
for (int i = 0; i < n; i++) {
Attachment attachment = attachments.get(i); Attachment attachment = attachments.get(i);
String previewUrl = attachment.getPreviewUrl(); String previewUrl = attachment.getPreviewUrl();
String description = attachment.getDescription(); String description = attachment.getDescription();
MediaPreviewImageView imageView = mediaPreviews[i]; boolean hasDescription = !TextUtils.isEmpty(description);
imageView.setVisibility(View.VISIBLE); if (hasDescription) {
if (TextUtils.isEmpty(description)) {
imageView.setContentDescription(imageView.getContext()
.getString(R.string.action_view_media));
} else {
imageView.setContentDescription(description); imageView.setContentDescription(description);
} else {
imageView.setContentDescription(imageView.getContext().getString(R.string.action_view_media));
} }
loadImage( loadImage(
@ -495,42 +486,43 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final Attachment.Type type = attachment.getType(); final Attachment.Type type = attachment.getType();
if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) {
mediaOverlays[i].setVisibility(View.VISIBLE); imageView.setForeground(ContextCompat.getDrawable(itemView.getContext(), R.drawable.play_indicator_overlay));
} else { } else {
mediaOverlays[i].setVisibility(View.GONE); imageView.setForeground(null);
} }
setAttachmentClickListener(imageView, listener, i, attachment, true); setAttachmentClickListener(imageView, listener, i, attachment, true);
}
if (sensitive) { if (sensitive) {
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
} else { } else {
sensitiveMediaWarning.setText(R.string.post_media_hidden_title); sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
}
sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE);
sensitiveMediaShow.setVisibility(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);
});
sensitiveMediaWarning.setOnClickListener(v -> {
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
listener.onContentHiddenChange(true, getBindingAdapterPosition());
}
v.setVisibility(View.GONE);
sensitiveMediaShow.setVisibility(View.VISIBLE);
});
sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE);
sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE);
// Hide any of the placeholder previews beyond the ones set. descriptionIndicator.setVisibility(hasDescription && showingContent ? View.VISIBLE : View.GONE);
for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) {
mediaPreviews[i].setVisibility(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) {
listener.onContentHiddenChange(true, getBindingAdapterPosition());
}
v.setVisibility(View.GONE);
sensitiveMediaShow.setVisibility(View.VISIBLE);
descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE);
});
return null;
});
} }
@DrawableRes @DrawableRes
@ -728,7 +720,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status actionable = status.getActionable(); Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(status.getUsername()); setUsername(status.getUsername());
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); setMetaData(status, statusDisplayOptions, listener);
setIsReply(actionable.getInReplyToId() != null); setIsReply(actionable.getInReplyToId() != null);
setReplyCount(actionable.getRepliesCount()); setReplyCount(actionable.getRepliesCount());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
@ -751,10 +743,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} else { } else {
setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); setMediaLabel(attachments, sensitive, listener, status.isShowingContent());
// Hide all unused views. // Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE); mediaPreview.setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
mediaPreviews[2].setVisibility(View.GONE);
mediaPreviews[3].setVisibility(View.GONE);
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
@ -783,7 +772,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (payloads instanceof List) if (payloads instanceof List)
for (Object item : (List<?>) payloads) { for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) { if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions); setMetaData(status, statusDisplayOptions, listener);
} }
} }
@ -809,6 +798,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
getContentWarningDescription(context, status), getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
getReblogDescription(context, status), getReblogDescription(context, status),
status.getUsername(), status.getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
@ -864,7 +854,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) { if (visibility == null) {
return ""; return "";
@ -1153,7 +1143,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
avatarInset.setVisibility(visibility); avatarInset.setVisibility(visibility);
displayName.setVisibility(visibility); displayName.setVisibility(visibility);
username.setVisibility(visibility); username.setVisibility(visibility);
timestampInfo.setVisibility(visibility); metaInfo.setVisibility(visibility);
contentWarningDescription.setVisibility(visibility); contentWarningDescription.setVisibility(visibility);
contentWarningButton.setVisibility(visibility); contentWarningButton.setVisibility(visibility);
content.setVisibility(visibility); content.setVisibility(visibility);

View file

@ -2,12 +2,18 @@ package com.keylesspalace.tusky.adapter;
import android.content.Context; import android.content.Context;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.text.style.DynamicDrawableSpan;
import android.text.style.ImageSpan;
import android.view.View; import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
@ -15,6 +21,7 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
@ -26,6 +33,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView favourites; private final TextView favourites;
private final View infoDivider; private final View infoDivider;
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
public StatusDetailedViewHolder(View view) { public StatusDetailedViewHolder(View view) {
super(view); super(view);
reblogs = view.findViewById(R.id.status_reblogs); reblogs = view.findViewById(R.id.status_reblogs);
@ -34,18 +43,74 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
protected int getMediaPreviewHeight(Context context) { protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
return context.getResources().getDimensionPixelSize(R.dimen.status_detail_media_preview_height);
}
@Override Status status = statusViewData.getActionable();
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) {
if (createdAt == null) { Status.Visibility visibility = status.getVisibility();
timestampInfo.setText(""); Context context = metaInfo.getContext();
} else {
DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); Drawable visibilityIcon = getVisibilityIcon(visibility);
timestampInfo.setText(dateFormat.format(createdAt)); 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) {
sb.append(" ");
sb.append(dateFormat.format(createdAt));
}
Date editedAt = status.getEditedAt();
if (editedAt != null) {
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);
}
}
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) { private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
@ -83,21 +148,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 @Override
public void setupWithStatus(@NonNull final StatusViewData.Concrete status, public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,
@ -105,8 +155,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
@Nullable Object payloads) { @Nullable Object payloads) {
// We never collapse statuses in the detail view // We never collapse statuses in the detail view
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) : status.copyWithCollapsed(false) :
status; status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
@ -119,17 +169,13 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
} else { } else {
hideQuantitativeStats(); hideQuantitativeStats();
} }
setApplication(actionable.getApplication());
setStatusVisibility(actionable.getVisibility());
} }
} }
private void setStatusVisibility(Status.Visibility visibility) { private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) {
if (visibility == null) { if (visibility == null) {
return; return null;
} }
int visibilityIcon; int visibilityIcon;
@ -147,29 +193,26 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
visibilityIcon = R.drawable.ic_email_24dp; visibilityIcon = R.drawable.ic_email_24dp;
break; break;
default: default:
return; return null;
} }
final Drawable visibilityDrawable = this.timestampInfo.getContext() final Drawable visibilityDrawable = AppCompatResources.getDrawable(
.getDrawable(visibilityIcon); this.metaInfo.getContext(), visibilityIcon
);
if (visibilityDrawable == null) { if (visibilityDrawable == null) {
return; return null;
} }
final int size = (int) this.timestampInfo.getTextSize(); final int size = (int) this.metaInfo.getTextSize();
visibilityDrawable.setBounds( visibilityDrawable.setBounds(
0, 0,
0, 0,
size, size,
size size
); );
visibilityDrawable.setTint(this.timestampInfo.getCurrentTextColor()); visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
this.timestampInfo.setCompoundDrawables(
visibilityDrawable, return visibilityDrawable;
null,
null,
null
);
} }
private void hideQuantitativeStats() { private void hideQuantitativeStats() {

View file

@ -53,11 +53,6 @@ public class StatusViewHolder extends StatusBaseViewHolder {
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
} }
@Override
protected int getMediaPreviewHeight(Context context) {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
@Override @Override
public void setupWithStatus(@NonNull StatusViewData.Concrete status, public void setupWithStatus(@NonNull StatusViewData.Concrete status,
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import android.content.res.ColorStateList
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ViewGroup import android.view.ViewGroup
@ -30,8 +29,8 @@ import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding
import com.keylesspalace.tusky.databinding.ItemTabPreferenceSmallBinding import com.keylesspalace.tusky.databinding.ItemTabPreferenceSmallBinding
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
interface ItemInteractionListener { interface ItemInteractionListener {
@ -101,7 +100,7 @@ class TabAdapter(
listener.onTabRemoved(holder.bindingAdapterPosition) listener.onTabRemoved(holder.bindingAdapterPosition)
} }
binding.removeButton.isEnabled = removeButtonEnabled binding.removeButton.isEnabled = removeButtonEnabled
ThemeUtils.setDrawableTint( setDrawableTint(
holder.itemView.context, holder.itemView.context,
binding.removeButton.drawable, binding.removeButton.drawable,
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) (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? val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply { ?: Chip(context).apply {
setCloseIconResource(R.drawable.ic_cancel_24dp)
isCheckable = false
binding.chipGroup.addView(this, binding.chipGroup.size - 1) binding.chipGroup.addView(this, binding.chipGroup.size - 1)
chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary))
} }
chip.text = arg chip.text = arg
if (tab.arguments.size <= 1) { if (tab.arguments.size <= 1) {
chip.chipIcon = null chip.isCloseIconVisible = false
chip.setOnClickListener(null) chip.setOnClickListener(null)
} else { } else {
chip.setChipIconResource(R.drawable.ic_cancel_24dp) chip.isCloseIconVisible = true
chip.setOnClickListener { chip.setOnClickListener {
listener.onChipClicked(tab, holder.bindingAdapterPosition, i) listener.onChipClicked(tab, holder.bindingAdapterPosition, i)
} }

View file

@ -16,6 +16,8 @@
package com.keylesspalace.tusky.components.account package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
@ -41,6 +43,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout 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.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
@ -53,10 +56,12 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding import com.keylesspalace.tusky.databinding.ActivityAccountBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
@ -69,12 +74,12 @@ import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -95,6 +100,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any> lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: AccountViewModel by viewModels { viewModelFactory } private val viewModel: AccountViewModel by viewModels { viewModelFactory }
@ -102,6 +109,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private lateinit var accountFieldAdapter: AccountFieldAdapter private lateinit var accountFieldAdapter: AccountFieldAdapter
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private var followState: FollowState = FollowState.NOT_FOLLOWING private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false private var blocking: Boolean = false
private var muting: Boolean = false private var muting: Boolean = false
@ -169,9 +178,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
* Load colors and dimensions from resources * Load colors and dimensions from resources
*/ */
private fun loadResources() { 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) 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) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
} }
@ -230,6 +239,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
// Setup the tabs and timeline pager. // Setup the tabs and timeline pager.
adapter = AccountPagerAdapter(this, viewModel.accountId) adapter = AccountPagerAdapter(this, viewModel.accountId)
binding.accountFragmentViewPager.reduceSwipeSensitivity()
binding.accountFragmentViewPager.adapter = adapter binding.accountFragmentViewPager.adapter = adapter
binding.accountFragmentViewPager.offscreenPageLimit = 2 binding.accountFragmentViewPager.offscreenPageLimit = 2
@ -242,6 +252,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
binding.accountFragmentViewPager.setPageTransformer(MarginPageTransformer(pageMargin)) binding.accountFragmentViewPager.setPageTransformer(MarginPageTransformer(pageMargin))
val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
binding.accountFragmentViewPager.isUserInputEnabled = enableSwipeForTabs
binding.accountTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { binding.accountTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabReselected(tab: TabLayout.Tab?) { override fun onTabReselected(tab: TabLayout.Tab?) {
tab?.position?.let { position -> tab?.position?.let { position ->
@ -312,7 +325,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
supportActionBar?.setDisplayShowTitleEnabled(false) supportActionBar?.setDisplayShowTitleEnabled(false)
} }
if (hideFab && !viewModel.isSelf && !blocking) { if (hideFab && !blocking) {
if (verticalOffset > oldOffset) { if (verticalOffset > oldOffset) {
binding.accountFloatingActionButton.show() binding.accountFloatingActionButton.show()
} }
@ -376,6 +389,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
// "Post failed" dialog should display in this activity
draftsAlert.observeInContext(this, true)
} }
/** /**
@ -401,6 +417,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) 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) val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
@ -553,6 +583,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
} }
updateFollowButton() updateFollowButton()
updateSubscribeButton()
} }
} }
} }
@ -626,7 +657,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFollowButton.setText(R.string.action_unfollow) binding.accountFollowButton.setText(R.string.action_unfollow)
} }
} }
updateSubscribeButton()
} }
private fun updateMuteButton() { private fun updateMuteButton() {
@ -658,17 +688,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFollowButton.show() binding.accountFollowButton.show()
updateFollowButton() updateFollowButton()
updateSubscribeButton()
if (blocking || viewModel.isSelf) { if (blocking) {
binding.accountFloatingActionButton.hide() binding.accountFloatingActionButton.hide()
binding.accountMuteButton.hide() binding.accountMuteButton.hide()
binding.accountSubscribeButton.hide()
} else { } else {
binding.accountFloatingActionButton.show() binding.accountFloatingActionButton.show()
if (muting) binding.accountMuteButton.visible(muting)
binding.accountMuteButton.show()
else
binding.accountMuteButton.hide()
updateMuteButton() updateMuteButton()
} }
} else { } else {
@ -706,9 +733,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
getString(R.string.action_mute) getString(R.string.action_mute)
} }
if (loadedAccount != null) { loadedAccount?.let { loadedAccount ->
val muteDomain = menu.findItem(R.id.action_mute_domain) val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = getDomain(loadedAccount?.url) domain = getDomain(loadedAccount.url)
if (domain.isEmpty()) { if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway... // If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain) menu.removeItem(R.id.action_mute_domain)
@ -740,6 +767,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
menu.removeItem(R.id.action_report) menu.removeItem(R.id.action_report)
} }
if (!viewModel.isSelf && followState != FollowState.FOLLOWING) {
menu.removeItem(R.id.action_add_or_remove_from_list)
}
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@ -800,10 +831,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun mention() { private fun mention() {
loadedAccount?.let { loadedAccount?.let {
val intent = ComposeActivity.startIntent( val options = if (viewModel.isSelf) {
this, ComposeActivity.ComposeOptions(kind = ComposeActivity.ComposeKind.NEW)
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)) } else {
) ComposeActivity.ComposeOptions(
mentionedUsernames = setOf(it.username),
kind = ComposeActivity.ComposeKind.NEW
)
}
val intent = ComposeActivity.startIntent(this, options)
startActivity(intent) startActivity(intent)
} }
} }
@ -827,23 +863,47 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
when (item.itemId) { when (item.itemId) {
R.id.action_open_in_web -> { R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input. // If the account isn't loaded yet, eat the input.
if (loadedAccount?.url != null) { loadedAccount?.let { loadedAccount ->
openLink(loadedAccount!!.url) openLink(loadedAccount.url)
} }
return true return true
} }
R.id.action_open_as -> { R.id.action_open_as -> {
if (loadedAccount != null) { loadedAccount?.let { loadedAccount ->
showAccountChooserDialog( showAccountChooserDialog(
item.title, false, item.title, false,
object : AccountSelectionListener { object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) { 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 -> { R.id.action_block -> {
toggleBlock() toggleBlock()
return true return true
@ -852,6 +912,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
toggleMute() toggleMute()
return true return true
} }
R.id.action_add_or_remove_from_list -> {
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
return true
}
R.id.action_mute_domain -> { R.id.action_mute_domain -> {
toggleBlockDomain(domain) toggleBlockDomain(domain)
return true return true
@ -861,8 +925,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return true return true
} }
R.id.action_report -> { R.id.action_report -> {
if (loadedAccount != null) { loadedAccount?.let { loadedAccount ->
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username)) startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
} }
return true return true
} }
@ -871,11 +935,22 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
override fun getActionButton(): FloatingActionButton? { override fun getActionButton(): FloatingActionButton? {
return if (!viewModel.isSelf && !blocking) { return if (!blocking) {
binding.accountFloatingActionButton binding.accountFloatingActionButton
} else null } 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 override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {

View file

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.components.account
import android.util.Log import android.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
@ -19,6 +20,7 @@ import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -181,7 +183,11 @@ class AccountViewModel @Inject constructor(
/** /**
* @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE * @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 relation = relationshipData.value?.data
val account = accountData.value?.data val account = accountData.value?.data
val isMastodon = relationshipData.value?.data?.notifying != null val isMastodon = relationshipData.value?.data?.notifying != null
@ -216,40 +222,45 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading(newRelation)) relationshipData.postValue(Loading(newRelation))
} }
when (relationshipAction) { try {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs = parameter ?: true) val relationship = when (relationshipAction) {
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) RelationShipAction.FOLLOW -> mastodonApi.followAccount(
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) accountId,
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) showReblogs = parameter ?: true
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration) )
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.SUBSCRIBE -> { RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
if (isMastodon) RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
mastodonApi.followAccount(accountId, notify = true) RelationShipAction.MUTE -> mastodonApi.muteAccount(
else mastodonApi.subscribeAccount(accountId) accountId,
} parameter ?: true,
RelationShipAction.UNSUBSCRIBE -> { duration
if (isMastodon) )
mastodonApi.followAccount(accountId, notify = false) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
else mastodonApi.unsubscribeAccount(accountId) RelationShipAction.SUBSCRIBE -> {
} if (isMastodon)
}.subscribe( mastodonApi.followAccount(accountId, notify = true)
{ relationship -> else mastodonApi.subscribeAccount(accountId)
relationshipData.postValue(Success(relationship)) }
RelationShipAction.UNSUBSCRIBE -> {
when (relationshipAction) { if (isMastodon)
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) mastodonApi.followAccount(accountId, notify = false)
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) else mastodonApi.unsubscribeAccount(accountId)
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
else -> {
}
} }
},
{
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) { fun noteChanged(newNote: String) {

View file

@ -0,0 +1,210 @@
/* Copyright 2022 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.account.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding
import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class ListsForAccountFragment : DialogFragment(), Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentListsForAccountBinding::bind)
private val adapter = Adapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle)
viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!)
}
override fun onStart() {
super.onStart()
dialog?.apply {
window?.setLayout(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT,
)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_lists_for_account, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.listsView.layoutManager = LinearLayoutManager(view.context)
binding.listsView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewModel.states.collectLatest { states ->
binding.progressBar.hide()
if (states.isEmpty()) {
binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) {
load()
}
} else {
binding.listsView.show()
adapter.submitList(states)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.loadError.collectLatest { error ->
binding.progressBar.hide()
binding.listsView.hide()
binding.messageView.apply {
show()
if (error is IOException) {
setup(R.drawable.elephant_offline, R.string.error_network) {
load()
}
} else {
setup(R.drawable.elephant_error, R.string.error_generic) {
load()
}
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.actionError.collectLatest { error ->
when (error.type) {
ActionError.Type.ADD -> {
Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.addAccountToList(error.listId)
}
.show()
}
ActionError.Type.REMOVE -> {
Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.removeAccountFromList(error.listId)
}
.show()
}
}
}
}
binding.doneButton.setOnClickListener {
dismiss()
}
load()
}
private fun load() {
binding.progressBar.show()
binding.listsView.hide()
binding.messageView.hide()
viewModel.load()
}
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
override fun areItemsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem.list.id == newItem.list.id
}
override fun areContentsTheSame(
oldItem: AccountListState,
newItem: AccountListState
): Boolean {
return oldItem == newItem
}
}
inner class Adapter :
ListAdapter<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): BindingHolder<ItemAddOrRemoveFromListBinding> {
val binding =
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, position: Int) {
val item = getItem(position)
holder.binding.listNameView.text = item.list.title
holder.binding.addButton.apply {
visible(!item.includesAccount)
setOnClickListener {
viewModel.addAccountToList(item.list.id)
}
}
holder.binding.removeButton.apply {
visible(item.includesAccount)
setOnClickListener {
viewModel.removeAccountFromList(item.list.id)
}
}
}
}
companion object {
private const val ARG_ACCOUNT_ID = "accountId"
fun newInstance(accountId: String): ListsForAccountFragment {
val args = Bundle().apply {
putString(ARG_ACCOUNT_ID, accountId)
}
return ListsForAccountFragment().apply { arguments = args }
}
}
}

View file

@ -0,0 +1,137 @@
/* Copyright 2022 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.account.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.getOrThrow
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import at.connyduck.calladapter.networkresult.runCatching
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AccountListState(
val list: MastoList,
val includesAccount: Boolean,
)
data class ActionError(
val error: Throwable,
val type: Type,
val listId: String,
) : Throwable(error) {
enum class Type {
ADD,
REMOVE,
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class ListsForAccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
) : ViewModel() {
private lateinit var accountId: String
private val _states = MutableSharedFlow<List<AccountListState>>(1)
val states: SharedFlow<List<AccountListState>> = _states
private val _loadError = MutableSharedFlow<Throwable>(1)
val loadError: SharedFlow<Throwable> = _loadError
private val _actionError = MutableSharedFlow<ActionError>(1)
val actionError: SharedFlow<ActionError> = _actionError
fun setup(accountId: String) {
this.accountId = accountId
}
fun load() {
_loadError.resetReplayCache()
viewModelScope.launch {
runCatching {
val (all, includes) = listOf(
async { mastodonApi.getLists() },
async { mastodonApi.getListsIncludesAccount(accountId) },
).awaitAll()
_states.emit(
all.getOrThrow().map { list ->
AccountListState(
list = list,
includesAccount = includes.getOrThrow().any { it.id == list.id },
)
}
)
}
.onFailure {
_loadError.emit(it)
}
}
}
fun addAccountToList(listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.addAccountToList(listId, listOf(accountId))
.onSuccess {
_states.emit(
_states.first().map { state ->
if (state.list.id == listId) {
state.copy(includesAccount = true)
} else {
state
}
}
)
}
.onFailure {
_actionError.emit(ActionError(it, ActionError.Type.ADD, listId))
}
}
}
fun removeAccountFromList(listId: String) {
_actionError.resetReplayCache()
viewModelScope.launch {
mastodonApi.deleteAccountFromList(listId, listOf(accountId))
.onSuccess {
_states.emit(
_states.first().map { state ->
if (state.list.id == listId) {
state.copy(includesAccount = false)
} else {
state
}
}
)
}
.onFailure {
_actionError.emit(ActionError(it, ActionError.Type.REMOVE, listId))
}
}
}
}

View file

@ -11,11 +11,11 @@ import androidx.core.view.setPadding
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.color.MaterialColors
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.getFormattedDescription import com.keylesspalace.tusky.util.getFormattedDescription
import com.keylesspalace.tusky.util.hide 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 videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) 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) { viewModel.emojis.observe(this) {
picker.adapter = EmojiAdapter(it, this) picker.adapter = EmojiAdapter(it, this, animateEmojis)
} }
viewModel.load() viewModel.load()

View file

@ -30,6 +30,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
@ -47,11 +48,9 @@ import androidx.annotation.ColorInt
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.os.LocaleListCompat
import androidx.core.view.ContentInfoCompat import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
@ -64,6 +63,7 @@ import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options import com.canhub.cropper.options
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
@ -87,15 +87,18 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys 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.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.getInitialLanguage
import com.keylesspalace.tusky.util.getLocaleList
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.modernLanguageCode
import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.setDrawableTint
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
@ -134,10 +137,10 @@ class ComposeActivity :
private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var emojiBehavior: BottomSheetBehavior<*>
private lateinit var scheduleBehavior: 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 var photoUploadUri: Uri? = null
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
@ -185,7 +188,7 @@ class ComposeActivity :
Log.w("ComposeActivity", "Edit image cancelled by user") Log.w("ComposeActivity", "Edit image cancelled by user")
} else { } else {
Log.w("ComposeActivity", "Edit image failed: " + result.error) 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 viewModel.cropImageItemOld = null
} }
@ -205,8 +208,7 @@ class ComposeActivity :
accountManager.setActiveAccount(accountId) accountManager.setActiveAccount(accountId)
} }
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if (theme == "black") { if (theme == "black") {
setTheme(R.style.TuskyDialogActivityBlackTheme) setTheme(R.style.TuskyDialogActivityBlackTheme)
} }
@ -216,7 +218,7 @@ class ComposeActivity :
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway // do not do anything when not logged in, activity will be finished in super.onCreate() anyway
val activeAccount = accountManager.activeAccount ?: return val activeAccount = accountManager.activeAccount ?: return
setupAvatar(preferences, activeAccount) setupAvatar(activeAccount)
val mediaAdapter = MediaPreviewAdapter( val mediaAdapter = MediaPreviewAdapter(
this, this,
onAddCaption = { item -> onAddCaption = { item ->
@ -236,15 +238,14 @@ class ComposeActivity :
binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null binding.composeMediaPreviewBar.itemAnimator = null
setupButtons()
subscribeToUpdates(mediaAdapter)
/* If the composer is started up as a reply to another post, override the "starting" state /* 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. */ * based on what the intent from the reply request passes. */
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions) viewModel.setup(composeOptions)
setupButtons()
subscribeToUpdates(mediaAdapter)
if (accountManager.shouldDisplaySelfUsername(this)) { if (accountManager.shouldDisplaySelfUsername(this)) {
binding.composeUsernameView.text = getString( binding.composeUsernameView.text = getString(
R.string.compose_active_account_description, R.string.compose_active_account_description,
@ -265,7 +266,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
} }
setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
setupComposeField(preferences, viewModel.startingText) setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning) setupContentWarningField(composeOptions?.contentWarning)
setupPollView() setupPollView()
@ -342,7 +343,7 @@ class ComposeActivity :
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } 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.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
binding.composeReplyView.setOnClickListener { binding.composeReplyView.setOnClickListener {
@ -355,7 +356,7 @@ class ComposeActivity :
binding.composeReplyContentView.show() binding.composeReplyContentView.show()
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 } 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) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
} }
} }
@ -468,9 +469,9 @@ class ComposeActivity :
lifecycleScope.launch { lifecycleScope.launch {
viewModel.uploadError.collect { throwable -> viewModel.uploadError.collect { throwable ->
if (throwable is UploadServerError) { if (throwable is UploadServerError) {
displayTransientError(throwable.errorMessage) displayTransientMessage(throwable.errorMessage)
} else { } else {
displayTransientError(R.string.error_media_upload_sending) displayTransientMessage(R.string.error_media_upload_sending)
} }
} }
} }
@ -498,8 +499,11 @@ class ComposeActivity :
binding.composeScheduleView.setListener(this) binding.composeScheduleView.setListener(this)
binding.atButton.setOnClickListener { atButtonClicked() } binding.atButton.setOnClickListener { atButtonClicked() }
binding.hashButton.setOnClickListener { hashButtonClicked() } 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 } val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
@ -510,6 +514,8 @@ class ComposeActivity :
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 } val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 }
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null)
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() } binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.actionPhotoPick.setOnClickListener { onMediaPick() }
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
@ -536,54 +542,7 @@ class ComposeActivity :
) )
} }
private fun mergeLocaleListCompat(list: MutableList<Locale>, localeListCompat: LocaleListCompat) {
for (index in 0 until localeListCompat.size()) {
val locale = localeListCompat[index]
if (locale != null && list.none { locale.language == it.language }) {
list.add(locale)
}
}
}
// Ensure that the locale whose code matches the given language is first in the list
private fun ensureLanguageIsFirst(locales: MutableList<Locale>, language: String) {
var currentLocaleIndex = locales.indexOfFirst { it.language == language }
if (currentLocaleIndex < 0) {
// Recheck against modern language codes
// This should only happen when replying or when the per-account post language is set
// to a modern code
currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language }
if (currentLocaleIndex < 0) {
// This can happen when:
// - Your per-account posting language is set to one android doesn't know (e.g. toki pona)
// - Replying to a post in a language android doesn't know
locales.add(0, Locale(language))
Log.w(TAG, "Attempting to use unknown language tag '$language'")
return
}
}
if (currentLocaleIndex > 0) {
// Move preselected locale to the top
locales.add(0, locales.removeAt(currentLocaleIndex))
}
}
private fun setupLanguageSpinner(initialLanguage: String) { private fun setupLanguageSpinner(initialLanguage: String) {
val locales = mutableListOf<Locale>()
mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first
mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages
locales.addAll( // finally, other languages
// Only "base" languages, "en" but not "en_DK"
Locale.getAvailableLocales().filter {
it.country.isNullOrEmpty() &&
it.script.isNullOrEmpty() &&
it.variant.isNullOrEmpty()
}
)
ensureLanguageIsFirst(locales, initialLanguage)
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
@ -594,26 +553,11 @@ class ComposeActivity :
} }
} }
binding.composePostLanguageButton.apply { binding.composePostLanguageButton.apply {
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales) adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage))
setSelection(0) setSelection(0)
} }
} }
private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) {
// Account-specific language set on the server
if (accountManager.activeAccount?.defaultPostLanguage?.isNotEmpty() == true) {
accountManager.activeAccount?.defaultPostLanguage!!
} else {
// Setting the application ui preference sets the default locale
AppCompatDelegate.getApplicationLocales()[0]?.language
?: Locale.getDefault().language
}
} else {
language
}
}
private fun setupActionBar() { private fun setupActionBar() {
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
supportActionBar?.run { supportActionBar?.run {
@ -624,7 +568,7 @@ class ComposeActivity :
} }
} }
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { private fun setupAvatar(activeAccount: AccountEntity) {
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
val a = obtainStyledAttributes(null, actionBarSizeAttr) val a = obtainStyledAttributes(null, actionBarSizeAttr)
val avatarSize = a.getDimensionPixelSize(0, 1) val avatarSize = a.getDimensionPixelSize(0, 1)
@ -714,15 +658,15 @@ class ComposeActivity :
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
private fun displayTransientError(errorMessage: String) { private fun displayTransientMessage(message: String) {
val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG) val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG)
// necessary so snackbar is shown over everything // necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.setAnchorView(R.id.composeBottomBar) bar.setAnchorView(R.id.composeBottomBar)
bar.show() bar.show()
} }
private fun displayTransientError(@StringRes stringId: Int) { private fun displayTransientMessage(@StringRes stringId: Int) {
displayTransientError(getString(stringId)) displayTransientMessage(getString(stringId))
} }
private fun toggleHideMedia() { private fun toggleHideMedia() {
@ -732,6 +676,7 @@ class ComposeActivity :
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
if (viewModel.media.value.isEmpty()) { if (viewModel.media.value.isEmpty()) {
binding.composeHideMediaButton.hide() binding.composeHideMediaButton.hide()
binding.descriptionMissingWarningButton.hide()
} else { } else {
binding.composeHideMediaButton.show() binding.composeHideMediaButton.show()
@ColorInt val color = if (contentWarningShown) { @ColorInt val color = if (contentWarningShown) {
@ -745,28 +690,42 @@ class ComposeActivity :
getColor(R.color.chinwag_green) getColor(R.color.chinwag_green)
} else { } else {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) 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) 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() { private fun updateScheduleButton() {
@ColorInt val color = if (binding.composeScheduleView.time == null) { if (viewModel.editing) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary) // Can't reschedule a published status
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
} else { } else {
getColor(R.color.chinwag_green) @ColorInt val color = if (binding.composeScheduleView.time == null) {
MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary)
} else {
getColor(R.color.chinwag_green)
}
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.composeAddMediaButton.isClickable = enable
binding.composeToggleVisibilityButton.isClickable = enable binding.composeToggleVisibilityButton.isClickable = enable && !editing
binding.composeEmojiButton.isClickable = enable binding.composeEmojiButton.isClickable = enable
binding.composeHideMediaButton.isClickable = enable binding.composeHideMediaButton.isClickable = enable
binding.composeScheduleButton.isClickable = enable binding.composeScheduleButton.isClickable = enable && !editing
binding.composeTootButton.isEnabled = enable binding.composeTootButton.isEnabled = enable
} }
@ -782,6 +741,10 @@ class ComposeActivity :
else -> R.drawable.ic_lock_open_24dp else -> R.drawable.ic_lock_open_24dp
} }
binding.composeToggleVisibilityButton.setImageResource(iconRes) binding.composeToggleVisibilityButton.setImageResource(iconRes)
if (viewModel.editing) {
// Can't update visibility on published status
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
}
} }
private fun showComposeOptions() { private fun showComposeOptions() {
@ -818,7 +781,7 @@ class ComposeActivity :
binding.emojiView.adapter?.let { binding.emojiView.adapter?.let {
if (it.itemCount == 0) { if (it.itemCount == 0) {
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() displayTransientMessage(errorMessage)
} else { } else {
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
@ -945,7 +908,7 @@ class ComposeActivity :
val textColor = if (remainingLength < 0) { val textColor = if (remainingLength < 0) {
getColor(R.color.tusky_red) getColor(R.color.tusky_red)
} else { } else {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary) MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary)
} }
binding.composeCharactersLeftView.setTextColor(textColor) binding.composeCharactersLeftView.setTextColor(textColor)
} }
@ -983,7 +946,7 @@ class ComposeActivity :
} }
private fun sendStatus() { private fun sendStatus() {
enableButtons(false) enableButtons(false, viewModel.editing)
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
var spoilerText = "" var spoilerText = ""
if (viewModel.showContentWarning.value) { if (viewModel.showContentWarning.value) {
@ -992,23 +955,16 @@ class ComposeActivity :
val characterCount = calculateTextLength() val characterCount = calculateTextLength()
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
binding.composeEditField.error = getString(R.string.error_empty) binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true) enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) { } else if (characterCount <= maximumTootCharacters) {
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 { lifecycleScope.launch {
viewModel.sendStatus(contentText, spoilerText) viewModel.sendStatus(contentText, spoilerText)
finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
} }
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true, viewModel.editing)
} }
} }
@ -1038,7 +994,7 @@ class ComposeActivity :
val photoFile: File = try { val photoFile: File = try {
createNewImageFile(this) createNewImageFile(this)
} catch (ex: IOException) { } catch (ex: IOException) {
displayTransientError(R.string.error_media_upload_opening) displayTransientMessage(R.string.error_media_upload_opening)
return return
} }
@ -1053,7 +1009,7 @@ class ComposeActivity :
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable button.isEnabled = clickable
ThemeUtils.setDrawableTint( setDrawableTint(
this, button.drawable, this, button.drawable,
if (colorActive) android.R.attr.textColorTertiary if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled else R.attr.textColorDisabled
@ -1062,8 +1018,8 @@ class ComposeActivity :
private fun enablePollButton(enable: Boolean) { private fun enablePollButton(enable: Boolean) {
binding.addPollTextActionTextView.isEnabled = enable binding.addPollTextActionTextView.isEnabled = enable
val textColor = ThemeUtils.getColor( val textColor = MaterialColors.getColor(
this, binding.addPollTextActionTextView,
if (enable) android.R.attr.textColorTertiary if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled else R.attr.textColorDisabled
) )
@ -1108,7 +1064,7 @@ class ComposeActivity :
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video) is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
else -> getString(R.string.error_media_upload_opening) else -> getString(R.string.error_media_upload_opening)
} }
displayTransientError(errorString) displayTransientMessage(errorString)
} }
} }
} }
@ -1123,7 +1079,7 @@ class ComposeActivity :
} else { } else {
binding.composeContentWarningBar.hide() binding.composeContentWarningBar.hide()
binding.composeEditField.requestFocus() 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) binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
} }
@ -1138,7 +1094,6 @@ class ComposeActivity :
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, event.toString())
if (event.action == KeyEvent.ACTION_DOWN) { if (event.action == KeyEvent.ACTION_DOWN) {
if (event.isCtrlPressed) { if (event.isCtrlPressed) {
if (keyCode == KeyEvent.KEYCODE_ENTER) { if (keyCode == KeyEvent.KEYCODE_ENTER) {
@ -1160,25 +1115,79 @@ class ComposeActivity :
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) { if (viewModel.didChange(contentText, contentWarning)) {
when (viewModel.composeKind) {
val warning = if (!viewModel.media.value.isEmpty()) { ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
R.string.compose_save_draft_loses_media ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
} else { ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
R.string.compose_save_draft ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
} }.show()
AlertDialog.Builder(this)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
saveDraftAndFinish(contentText, contentWarning)
}
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
.show()
} else { } else {
viewModel.stopUploads()
finishWithoutSlideOutAnimation() 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() { private fun deleteDraftAndFinish() {
viewModel.deleteDraft() viewModel.deleteDraft()
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
@ -1210,7 +1219,8 @@ class ComposeActivity :
private fun setEmojiList(emojiList: List<Emoji>?) { private fun setEmojiList(emojiList: List<Emoji>?) {
if (emojiList != null) { 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()) enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty())
} }
} }
@ -1223,14 +1233,18 @@ class ComposeActivity :
val uploadPercent: Int = 0, val uploadPercent: Int = 0,
val id: String? = null, val id: String? = null,
val description: String? = null, val description: String? = null,
val focus: Attachment.Focus? = null val focus: Attachment.Focus? = null,
val state: State
) { ) {
enum class Type { enum class Type {
IMAGE, VIDEO, AUDIO; IMAGE, VIDEO, AUDIO;
} }
enum class State {
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
}
} }
override fun onTimeSet(time: String) { override fun onTimeSet(time: String?) {
viewModel.updateScheduledAt(time) viewModel.updateScheduledAt(time)
if (verifyScheduledTime()) { if (verifyScheduledTime()) {
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -1252,6 +1266,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 @Parcelize
data class ComposeOptions( data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
@ -1274,6 +1306,8 @@ class ComposeActivity :
var poll: NewPoll? = null, var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null, var modifiedInitialState: Boolean? = null,
var language: String? = null, var language: String? = null,
var statusId: String? = null,
var kind: ComposeKind? = null
) : Parcelable ) : Parcelable
companion object { companion object {

View file

@ -33,20 +33,18 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.MediaToSend
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -73,6 +71,7 @@ class ComposeViewModel @Inject constructor(
private var scheduledTootId: String? = null private var scheduledTootId: String? = null
private var startingContentWarning: String = "" private var startingContentWarning: String = ""
private var inReplyToId: String? = null private var inReplyToId: String? = null
private var originalStatusId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
@ -96,7 +95,7 @@ class ComposeViewModel @Inject constructor(
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList()) val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val mediaToJob = mutableMapOf<Int, Job>() lateinit var composeKind: ComposeActivity.ComposeKind
// Used in ComposeActivity to pass state to result function when cropImage contract inflight // Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null var cropImageItemOld: QueuedMedia? = null
@ -133,17 +132,18 @@ class ComposeViewModel @Inject constructor(
media.updateAndGet { mediaValue -> media.updateAndGet { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
type = type, type = type,
mediaSize = mediaSize, mediaSize = mediaSize,
description = description, description = description,
focus = focus focus = focus,
state = QueuedMedia.State.UPLOADING
) )
stashMediaItem = mediaItem stashMediaItem = mediaItem
if (replaceItem != null) { if (replaceItem != null) {
mediaToJob[replaceItem.localId]?.cancel() mediaUploader.cancelUploadScope(replaceItem.localId)
mediaValue.map { mediaValue.map {
if (it.localId == replaceItem.localId) mediaItem else it if (it.localId == replaceItem.localId) mediaItem else it
} }
@ -153,13 +153,9 @@ class ComposeViewModel @Inject constructor(
} }
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that 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 mediaUploader
.uploadMedia(mediaItem, instanceInfo.first()) .uploadMedia(mediaItem, instanceInfo.first())
.catch { error ->
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
uploadError.emit(error)
}
.collect { event -> .collect { event ->
val item = media.value.find { it.localId == mediaItem.localId } val item = media.value.find { it.localId == mediaItem.localId }
?: return@collect ?: return@collect
@ -167,7 +163,16 @@ class ComposeViewModel @Inject constructor(
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent -> 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 -> media.update { mediaValue ->
mediaValue.map { mediaItem -> mediaValue.map { mediaItem ->
@ -186,21 +191,22 @@ class ComposeViewModel @Inject constructor(
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaValue -> media.update { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
type = type, type = type,
mediaSize = 0, mediaSize = 0,
uploadPercent = -1, uploadPercent = -1,
id = id, id = id,
description = description, description = description,
focus = focus focus = focus,
state = QueuedMedia.State.PUBLISHED
) )
mediaValue + mediaItem mediaValue + mediaItem
} }
} }
fun removeMediaFromQueue(item: QueuedMedia) { fun removeMediaFromQueue(item: QueuedMedia) {
mediaToJob[item.localId]?.cancel() mediaUploader.cancelUploadScope(item.localId)
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } } media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
} }
@ -209,15 +215,8 @@ class ComposeViewModel @Inject constructor(
} }
fun didChange(content: String?, contentWarning: String?): Boolean { fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = content.orEmpty() != startingText.orEmpty()
val textChanged = !( val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
content.isNullOrEmpty() ||
startingText?.startsWith(content.toString()) ?: false
)
val contentWarningChanged = showContentWarning.value &&
!contentWarning.isNullOrEmpty() &&
!startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = media.value.isNotEmpty() val mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null val pollChanged = poll.value != null
val didScheduledTimeChange = hasScheduledTimeChanged val didScheduledTimeChange = hasScheduledTimeChanged
@ -238,6 +237,10 @@ class ComposeViewModel @Inject constructor(
} }
} }
fun stopUploads() {
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
}
fun shouldShowSaveDraftDialog(): Boolean { fun shouldShowSaveDraftDialog(): Boolean {
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog // 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 -> return media.value.any { mediaValue ->
@ -268,8 +271,10 @@ class ComposeViewModel @Inject constructor(
mediaFocus = mediaFocus, mediaFocus = mediaFocus,
poll = poll.value, poll = poll.value,
failedToSend = false, failedToSend = false,
failedToSendAlert = false,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
language = postLanguage, language = postLanguage,
statusId = originalStatusId,
) )
} }
@ -286,46 +291,36 @@ class ComposeViewModel @Inject constructor(
api.deleteScheduledStatus(scheduledTootId!!) api.deleteScheduledStatus(scheduledTootId!!)
} }
media val attachedMedia = media.value.map { item ->
.filter { items -> items.all { it.uploadPercent == -1 } } MediaToSend(
.first { localId = item.localId,
val mediaIds: MutableList<String> = mutableListOf() id = item.id,
val mediaUris: MutableList<Uri> = mutableListOf() uri = item.uri.toString(),
val mediaDescriptions: MutableList<String> = mutableListOf() description = item.description,
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf() focus = item.focus,
val mediaProcessed: MutableList<Boolean> = mutableListOf() processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED
media.value.forEach { item -> )
mediaIds.add(item.id!!) }
mediaUris.add(item.uri) val tootToSend = StatusToSend(
mediaDescriptions.add(item.description ?: "") text = content,
mediaFocus.add(item.focus) warningText = spoilerText,
mediaProcessed.add(false) visibility = statusVisibility.value.serverString(),
} sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
val tootToSend = StatusToSend( media = attachedMedia,
text = content, scheduledAt = scheduledAt.value,
warningText = spoilerText, inReplyToId = inReplyToId,
visibility = statusVisibility.value.serverString(), poll = poll.value,
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), replyingStatusContent = null,
mediaIds = mediaIds, replyingStatusAuthorUsername = null,
mediaUris = mediaUris.map { it.toString() }, accountId = accountManager.activeAccount!!.id,
mediaDescriptions = mediaDescriptions, draftId = draftId,
mediaFocus = mediaFocus, idempotencyKey = randomAlphanumericString(16),
scheduledAt = scheduledAt.value, retries = 0,
inReplyToId = inReplyToId, language = postLanguage,
poll = poll.value, statusId = originalStatusId
replyingStatusContent = null, )
replyingStatusAuthorUsername = null,
accountId = accountManager.activeAccount!!.id,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
mediaProcessed = mediaProcessed,
language = postLanguage,
)
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
true
}
} }
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server // Updates a QueuedMedia item arbitrarily, then sends description and focus to server
@ -356,15 +351,15 @@ class ComposeViewModel @Inject constructor(
} }
suspend fun updateDescription(localId: Int, description: String): Boolean { suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId, { mediaItem -> return updateMediaItem(localId) { mediaItem ->
mediaItem.copy(description = description) mediaItem.copy(description = description)
}) }
} }
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId, { mediaItem -> return updateMediaItem(localId) { mediaItem ->
mediaItem.copy(focus = focus) mediaItem.copy(focus = focus)
}) }
} }
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
@ -412,6 +407,8 @@ class ComposeViewModel @Inject constructor(
return return
} }
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
@ -452,6 +449,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content startingText = composeOptions?.content
postLanguage = composeOptions?.language postLanguage = composeOptions?.language
@ -497,6 +495,9 @@ class ComposeViewModel @Inject constructor(
scheduledAt.value = newScheduledAt scheduledAt.value = newScheduledAt
} }
val editing: Boolean
get() = !originalStatusId.isNullOrEmpty()
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }

View file

@ -48,10 +48,13 @@ class MediaPreviewAdapter(
val addFocusId = 2 val addFocusId = 2
val editImageId = 3 val editImageId = 3
val removeId = 4 val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { // Already-published items can't have their metadata edited
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image) 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.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->

View file

@ -17,13 +17,14 @@ package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia 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.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow 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.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.HttpException
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
sealed interface FinalUploadEvent
sealed class UploadEvent { sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : 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 { fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
// Create an image file name // Create an image file name
val randomId = randomAlphanumericString(12) val randomId = randomAlphanumericString(12)
@ -76,14 +93,38 @@ class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception() class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception() class UploadServerError(val errorMessage: String) : Exception()
@Singleton
class MediaUploader @Inject constructor( class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
private val mediaUploadApi: MediaUploadApi 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) @OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> { fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
return flow { val uploadScope = CoroutineScope(Dispatchers.IO)
val uploadFlow = flow {
if (shouldResizeMedia(media, instanceInfo)) { if (shouldResizeMedia(media, instanceInfo)) {
emit(downsize(media, instanceInfo)) emit(downsize(media, instanceInfo))
} else { } else {
@ -91,7 +132,23 @@ class MediaUploader @Inject constructor(
} }
} }
.flatMapLatest { upload(it) } .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 { fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
@ -193,6 +250,19 @@ class MediaUploader @Inject constructor(
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> { private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow { return callbackFlow {
var mimeType = contentResolver.getType(media.uri) 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 map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%s_%s.%s".format( val filename = "%s_%s_%s.%s".format(
@ -231,16 +301,20 @@ class MediaUploader @Inject constructor(
null null
} }
mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus)
send(UploadEvent.FinishedEvent(result.id)) val responseBody = uploadResponse.body()
}, { throwable -> if (uploadResponse.isSuccessful && responseBody != null) {
val errorMessage = throwable.getServerErrorMessage() send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200))
} else {
val error = HttpException(uploadResponse)
val errorMessage = error.getServerErrorMessage()
if (errorMessage == null) { if (errorMessage == null) {
throw throwable throw error
} else { } else {
throw UploadServerError(errorMessage) throw UploadServerError(errorMessage)
} }
}) }
awaitClose() 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 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.chinwag_green));
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.chinwag_green : 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

@ -80,6 +80,7 @@ data class ConversationStatusEntity(
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: String, val content: String,
val createdAt: Date, val createdAt: Date,
val editedAt: Date?,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
val repliesCount: Int, val repliesCount: Int,
@ -109,6 +110,7 @@ data class ConversationStatusEntity(
content = content, content = content,
reblog = null, reblog = null,
createdAt = createdAt, createdAt = createdAt,
editedAt = editedAt,
emojis = emojis, emojis = emojis,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
@ -159,6 +161,7 @@ fun Status.toEntity(
account = account.toEntity(), account = account.toEntity(),
content = content, content = content,
createdAt = createdAt, createdAt = createdAt,
editedAt = editedAt,
emojis = emojis, emojis = emojis,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
repliesCount = repliesCount, repliesCount = repliesCount,

View file

@ -71,6 +71,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
account = status.account.toEntity(), account = status.account.toEntity(),
content = status.content, content = status.content,
createdAt = status.createdAt, createdAt = status.createdAt,
editedAt = status.editedAt,
emojis = status.emojis, emojis = status.emojis,
favouritesCount = status.favouritesCount, favouritesCount = status.favouritesCount,
repliesCount = status.repliesCount, repliesCount = status.repliesCount,

View file

@ -68,11 +68,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
this.listener = listener; this.listener = listener;
} }
@Override
protected int getMediaPreviewHeight(Context context) {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
void setupWithConversation( void setupWithConversation(
@NonNull ConversationViewData conversation, @NonNull ConversationViewData conversation,
@Nullable Object payloads @Nullable Object payloads
@ -88,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername()); setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setMetaData(statusViewData, statusDisplayOptions, listener);
setIsReply(status.getInReplyToId() != null); setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited()); setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked()); setBookmarked(status.getBookmarked());
@ -108,10 +103,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
} else { } else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views. // Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE); mediaPreview.setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
mediaPreviews[2].setVisibility(View.GONE);
mediaPreviews[3].setVisibility(View.GONE);
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
@ -129,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
if (payloads instanceof List) { if (payloads instanceof List) {
for (Object item : (List<?>) payloads) { for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) { if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setMetaData(statusViewData, statusDisplayOptions, listener);
} }
} }
} }

View file

@ -63,8 +63,10 @@ class DraftHelper @Inject constructor(
mediaFocus: List<Attachment.Focus?>, mediaFocus: List<Attachment.Focus?>,
poll: NewPoll?, poll: NewPoll?,
failedToSend: Boolean, failedToSend: Boolean,
failedToSendAlert: Boolean,
scheduledAt: String?, scheduledAt: String?,
language: String?, language: String?,
statusId: String?,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky") val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -122,8 +124,10 @@ class DraftHelper @Inject constructor(
attachments = attachments, attachments = attachments,
poll = poll, poll = poll,
failedToSend = failedToSend, failedToSend = failedToSend,
failedToSendNew = failedToSendAlert,
scheduledAt = scheduledAt, scheduledAt = scheduledAt,
language = language, language = language,
statusId = statusId,
) )
draftDao.insertOrReplace(draft) draftDao.insertOrReplace(draft)

View file

@ -25,8 +25,7 @@ import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import at.connyduck.calladapter.networkresult.fold
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
@ -34,10 +33,10 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
@ -48,6 +47,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var draftsAlert: DraftsAlert
private val viewModel: DraftsViewModel by viewModels { viewModelFactory } private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
private lateinit var binding: ActivityDraftsBinding private lateinit var binding: ActivityDraftsBinding
@ -85,16 +87,23 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
adapter.addLoadStateListener { adapter.addLoadStateListener {
binding.draftsErrorMessageView.visible(adapter.itemCount == 0) binding.draftsErrorMessageView.visible(adapter.itemCount == 0)
} }
// If a failed post is saved to drafts while this activity is up, do nothing; the user is already in the drafts view.
draftsAlert.observeInContext(this, false)
} }
override fun onOpenDraft(draft: DraftEntity) { 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 bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getStatus(draft.inReplyToId) viewModel.getStatus(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread()) .fold(
.autoDispose(from(this))
.subscribe(
{ status -> { status ->
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
@ -109,14 +118,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility, visibility = draft.visibility,
scheduledAt = draft.scheduledAt, scheduledAt = draft.scheduledAt,
language = draft.language, language = draft.language,
statusId = draft.statusId,
kind = ComposeActivity.ComposeKind.EDIT_DRAFT
) )
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(context, composeOptions))
}, },
{ throwable -> { throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
Log.w(TAG, "failed loading reply information", throwable) Log.w(TAG, "failed loading reply information", throwable)
@ -124,7 +134,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted // the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information // 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) openDraftWithoutReply(draft)
} else { } else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
@ -132,8 +142,6 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
} }
} }
) )
} else {
openDraftWithoutReply(draft)
} }
} }
@ -148,6 +156,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
visibility = draft.visibility, visibility = draft.visibility,
scheduledAt = draft.scheduledAt, scheduledAt = draft.scheduledAt,
language = draft.language, language = draft.language,
statusId = draft.statusId,
kind = ComposeActivity.ComposeKind.EDIT_DRAFT
) )
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(this, composeOptions))

View file

@ -20,12 +20,12 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject 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) return api.status(statusId)
} }

View file

@ -0,0 +1,148 @@
package com.keylesspalace.tusky.components.followedtags
import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
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.databinding.ActivityFollowedTagsBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.HashtagActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityFollowedTagsBinding::inflate)
private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
setTitle(R.string.title_followed_hashtags)
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setupAdapter().let { adapter ->
setupRecyclerView(adapter)
lifecycleScope.launch {
viewModel.pager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
}
private fun setupRecyclerView(adapter: FollowedTagsAdapter) {
binding.followedTagsView.adapter = adapter
binding.followedTagsView.setHasFixedSize(true)
binding.followedTagsView.layoutManager = LinearLayoutManager(this)
binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
(binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
private fun setupAdapter(): FollowedTagsAdapter {
return FollowedTagsAdapter(this, viewModel).apply {
addLoadStateListener { loadState ->
binding.followedTagsProgressBar.visible(loadState.refresh == LoadState.Loading && itemCount == 0)
if (loadState.refresh is LoadState.Error) {
binding.followedTagsView.hide()
binding.followedTagsMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
} else {
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
}
Log.w(TAG, "error loading followed hashtags", errorState.error)
} else {
binding.followedTagsView.show()
binding.followedTagsMessageView.hide()
}
}
}
}
private fun follow(tagName: String, position: Int) {
lifecycleScope.launch {
api.followTag(tagName).fold(
{
viewModel.tags.add(position, it)
viewModel.currentSource?.invalidate()
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.error_following_hashtag_format, tagName),
Snackbar.LENGTH_SHORT
)
.show()
}
)
}
}
override fun unfollow(tagName: String, position: Int) {
lifecycleScope.launch {
api.unfollowTag(tagName).fold(
{
viewModel.tags.removeAt(position)
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.confirmation_hashtag_unfollowed, tagName),
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_undo) {
follow(tagName, position)
}
.show()
viewModel.currentSource?.invalidate()
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(
R.string.error_unfollowing_hashtag_format,
tagName
),
Snackbar.LENGTH_SHORT
)
.show()
}
)
}
}
companion object {
const val TAG = "FollowedTagsActivity"
}
}

View file

@ -0,0 +1,38 @@
package com.keylesspalace.tusky.components.followedtags
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowedHashtagBinding
import com.keylesspalace.tusky.interfaces.HashtagActionListener
import com.keylesspalace.tusky.util.BindingHolder
class FollowedTagsAdapter(
private val actionListener: HashtagActionListener,
private val viewModel: FollowedTagsViewModel,
) : PagingDataAdapter<String, BindingHolder<ItemFollowedHashtagBinding>>(STRING_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowedHashtagBinding> =
BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: BindingHolder<ItemFollowedHashtagBinding>, position: Int) {
viewModel.tags[position].let { tag ->
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
holder.itemView.findViewById<ImageButton>(R.id.followed_tag_unfollow).setOnClickListener {
actionListener.unfollow(tag.name, position)
}
}
}
override fun getItemCount(): Int = viewModel.tags.size
companion object {
val STRING_COMPARATOR = object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
}
}
}

View file

@ -0,0 +1,16 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.paging.PagingSource
import androidx.paging.PagingState
class FollowedTagsPagingSource(private val viewModel: FollowedTagsViewModel) : PagingSource<String, String>() {
override fun getRefreshKey(state: PagingState<String, String>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(viewModel.tags.map { it.name }, null, viewModel.nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View file

@ -0,0 +1,57 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalPagingApi::class)
class FollowedTagsRemoteMediator(
private val api: MastodonApi,
private val viewModel: FollowedTagsViewModel,
) : RemoteMediator<String, String>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, String>
): MediatorResult {
return try {
val response = request(loadType)
?: return MediatorResult.Success(endOfPaginationReached = true)
return applyResponse(response)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
private suspend fun request(loadType: LoadType): Response<List<HashTag>>? {
return when (loadType) {
LoadType.PREPEND -> null
LoadType.APPEND -> api.followedTags(maxId = viewModel.nextKey)
LoadType.REFRESH -> {
viewModel.nextKey = null
viewModel.tags.clear()
api.followedTags()
}
}
}
private fun applyResponse(response: Response<List<HashTag>>): MediatorResult {
val tags = response.body()
if (!response.isSuccessful || tags == null) {
return MediatorResult.Error(HttpException(response))
}
val links = HttpHeaderLink.parse(response.headers()["Link"])
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
viewModel.tags.addAll(tags)
viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
}
}

View file

@ -0,0 +1,33 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject
class FollowedTagsViewModel @Inject constructor (
api: MastodonApi
) : ViewModel(), Injectable {
val tags: MutableList<HashTag> = mutableListOf()
var nextKey: String? = null
var currentSource: FollowedTagsPagingSource? = null
@OptIn(ExperimentalPagingApi::class)
val pager = Pager(
config = PagingConfig(pageSize = 100),
remoteMediator = FollowedTagsRemoteMediator(api, this),
pagingSourceFactory = {
FollowedTagsPagingSource(
viewModel = this
).also { source ->
currentSource = source
}
},
).flow.cachedIn(viewModelScope)
}

View file

@ -22,6 +22,7 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.util.Log import android.util.Log
import android.view.Menu
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.openLinkInCustomTab
import com.keylesspalace.tusky.util.rickRoll import com.keylesspalace.tusky.util.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll import com.keylesspalace.tusky.util.shouldRickRoll
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -67,24 +69,8 @@ class LoginActivity : BaseActivity(), Injectable {
is LoginResult.Ok -> lifecycleScope.launch { is LoginResult.Ok -> lifecycleScope.launch {
fetchOauthToken(result.code) fetchOauthToken(result.code)
} }
is LoginResult.Err -> { is LoginResult.Err -> displayError(result.errorMessage)
// Authorization failed. Put the error response where the user can read it and they is LoginResult.Cancel -> setLoading(false)
// can try again.
setLoading(false)
// Use error returned by the server or fall back to the generic message
binding.domainTextInputLayout.error =
result.errorMessage.ifBlank { getString(R.string.error_authorization_denied) }
Log.e(
TAG,
"%s %s".format(
getString(R.string.error_authorization_denied),
result.errorMessage
)
)
}
is LoginResult.Cancel -> {
setLoading(false)
}
} }
} }
@ -117,7 +103,7 @@ class LoginActivity : BaseActivity(), Injectable {
getString(R.string.preferences_file_key), Context.MODE_PRIVATE getString(R.string.preferences_file_key), Context.MODE_PRIVATE
) )
binding.loginButton.setOnClickListener { onButtonClick() } binding.loginButton.setOnClickListener { onLoginClick(true) }
binding.registerButton.setOnClickListener { onRegisterClick() } binding.registerButton.setOnClickListener { onRegisterClick() }
binding.whatsAnInstanceTextView.setOnClickListener { binding.whatsAnInstanceTextView.setOnClickListener {
@ -129,13 +115,9 @@ class LoginActivity : BaseActivity(), Injectable {
textView?.movementMethod = LinkMovementMethod.getInstance() textView?.movementMethod = LinkMovementMethod.getInstance()
} }
if (isAdditionalLogin() || isAccountMigration()) { setSupportActionBar(binding.toolbar)
setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration())
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowTitleEnabled(false)
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
binding.toolbar.visibility = View.GONE
}
} }
override fun requiresLogin(): Boolean { override fun requiresLogin(): Boolean {
@ -149,6 +131,17 @@ class LoginActivity : BaseActivity(), Injectable {
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menu?.add(R.string.action_browser_login)?.apply {
setOnMenuItemClickListener {
onLoginClick(false)
true
}
}
return super.onCreateOptionsMenu(menu)
}
/** /**
* Handle registation of new account in the most basic way possible; open a URL * Handle registation of new account in the most basic way possible; open a URL
* in the system default browser. * in the system default browser.
@ -166,7 +159,7 @@ class LoginActivity : BaseActivity(), Injectable {
* app is run on a given server instance. So, after the first authentication, they are * app is run on a given server instance. So, after the first authentication, they are
* saved in SharedPreferences and every subsequent run they are simply fetched from there. * saved in SharedPreferences and every subsequent run they are simply fetched from there.
*/ */
private fun onButtonClick() { private fun onLoginClick(openInWebView: Boolean) {
binding.loginButton.isEnabled = false binding.loginButton.isEnabled = false
binding.domainTextInputLayout.error = null binding.domainTextInputLayout.error = null
@ -204,7 +197,7 @@ class LoginActivity : BaseActivity(), Injectable {
.putString(CLIENT_SECRET, credentials.clientSecret) .putString(CLIENT_SECRET, credentials.clientSecret)
.apply() .apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId) redirectUserToAuthorizeAndLogin(domain, credentials.clientId, openInWebView)
}, },
{ e -> { e ->
binding.loginButton.isEnabled = true binding.loginButton.isEnabled = true
@ -218,10 +211,10 @@ class LoginActivity : BaseActivity(), Injectable {
} }
} }
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String, openInWebView: Boolean) {
// To authorize this app and log in it's necessary to redirect to the domain given, // To authorize this app and log in it's necessary to redirect to the domain given,
// login there, and the server will redirect back to the app with its response. // login there, and the server will redirect back to the app with its response.
val url = HttpUrl.Builder() val uri = HttpUrl.Builder()
.scheme("https") .scheme("https")
.host(domain) .host(domain)
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE) .addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
@ -230,13 +223,59 @@ class LoginActivity : BaseActivity(), Injectable {
.addQueryParameter("response_type", "code") .addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES) .addQueryParameter("scope", OAUTH_SCOPES)
.build() .build()
doWebViewAuth.launch(LoginData(domain, url.toString().toUri(), oauthRedirectUri.toUri())) .toString()
.toUri()
if (openInWebView) {
doWebViewAuth.launch(LoginData(domain, uri, oauthRedirectUri.toUri()))
} else {
openLinkInCustomTab(uri, this)
}
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// first show or user cancelled login
/* Check if we are resuming during authorization by seeing if the intent contains the
* redirect that was given to the server. If so, its response is here! */
val uri = intent.data
if (uri?.toString()?.startsWith(oauthRedirectUri) == true) {
// This should either have returned an authorization code or an error.
val code = uri.getQueryParameter("code")
val error = uri.getQueryParameter("error")
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
lifecycleScope.launch {
fetchOauthToken(code)
}
} else {
displayError(error)
}
} else {
// first show or user cancelled login
setLoading(false)
}
}
private fun displayError(error: String?) {
// Authorization failed. Put the error response where the user can read it and they
// can try again.
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = if (error == null) {
// This case means a junk response was received somehow.
getString(R.string.error_authorization_unknown)
} else {
// Use error returned by the server or fall back to the generic message
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
error.ifBlank { getString(R.string.error_authorization_denied) }
}
} }
private suspend fun fetchOauthToken(code: String) { private suspend fun fetchOauthToken(code: String) {

View file

@ -120,6 +120,7 @@ public class NotificationHelper {
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
/** /**
* WorkManager Tag * WorkManager Tag
@ -173,11 +174,11 @@ public class NotificationHelper {
notificationId++; notificationId++;
builder.setContentTitle(titleForType(context, body, account)) 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) { if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
builder.setStyle(new NotificationCompat.BigTextStyle() builder.setStyle(new NotificationCompat.BigTextStyle()
.bigText(bodyForType(body, context))); .bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
} }
//load the avatar synchronously //load the avatar synchronously
@ -370,6 +371,7 @@ public class NotificationHelper {
composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true); composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(actionableStatus.getLanguage()); composeOptions.setLanguage(actionableStatus.getLanguage());
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
Intent composeIntent = ComposeActivity.startIntent( Intent composeIntent = ComposeActivity.startIntent(
context, context,
@ -401,6 +403,7 @@ public class NotificationHelper {
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(), CHANNEL_SIGN_UP + account.getIdentifier(),
CHANNEL_UPDATES + account.getIdentifier(), CHANNEL_UPDATES + account.getIdentifier(),
CHANNEL_REPORT + account.getIdentifier(),
}; };
int[] channelNames = { int[] channelNames = {
R.string.notification_mention_name, R.string.notification_mention_name,
@ -412,6 +415,7 @@ public class NotificationHelper {
R.string.notification_subscription_name, R.string.notification_subscription_name,
R.string.notification_sign_up_name, R.string.notification_sign_up_name,
R.string.notification_update_name, R.string.notification_update_name,
R.string.notification_report_name,
}; };
int[] channelDescriptions = { int[] channelDescriptions = {
R.string.notification_mention_descriptions, R.string.notification_mention_descriptions,
@ -423,6 +427,7 @@ public class NotificationHelper {
R.string.notification_subscription_description, R.string.notification_subscription_description,
R.string.notification_sign_up_description, R.string.notification_sign_up_description,
R.string.notification_update_description, R.string.notification_update_description,
R.string.notification_report_description,
}; };
List<NotificationChannel> channels = new ArrayList<>(6); List<NotificationChannel> channels = new ArrayList<>(6);
@ -469,7 +474,7 @@ public class NotificationHelper {
if (notificationManager.areNotificationsEnabled()) { if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) { for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
Log.d(TAG, "NotificationsEnabled"); Log.d(TAG, "NotificationsEnabled");
return true; return true;
} }
@ -542,7 +547,7 @@ public class NotificationHelper {
return false; return false;
} }
NotificationChannel channel = notificationManager.getNotificationChannel(channelId); NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
} }
switch (type) { switch (type) {
@ -564,6 +569,8 @@ public class NotificationHelper {
return account.getNotificationsSignUps(); return account.getNotificationsSignUps();
case UPDATE: case UPDATE:
return account.getNotificationsUpdates(); return account.getNotificationsUpdates();
case REPORT:
return account.getNotificationsReports();
default: default:
return false; return false;
} }
@ -593,6 +600,10 @@ public class NotificationHelper {
return CHANNEL_POLL + account.getIdentifier(); return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP: case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier(); return CHANNEL_SIGN_UP + account.getIdentifier();
case UPDATE:
return CHANNEL_UPDATES + account.getIdentifier();
case REPORT:
return CHANNEL_REPORT + account.getIdentifier();
default: default:
return null; return null;
} }
@ -678,11 +689,13 @@ public class NotificationHelper {
return String.format(context.getString(R.string.notification_sign_up_format), accountName); return String.format(context.getString(R.string.notification_sign_up_format), accountName);
case UPDATE: case UPDATE:
return String.format(context.getString(R.string.notification_update_format), accountName); return String.format(context.getString(R.string.notification_update_format), accountName);
case REPORT:
return context.getString(R.string.notification_report_format, account.getDomain());
} }
return null; return null;
} }
private static String bodyForType(Notification notification, Context context) { private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
switch (notification.getType()) { switch (notification.getType()) {
case FOLLOW: case FOLLOW:
case FOLLOW_REQUEST: case FOLLOW_REQUEST:
@ -692,13 +705,13 @@ public class NotificationHelper {
case FAVOURITE: case FAVOURITE:
case REBLOG: case REBLOG:
case STATUS: case STATUS:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
return parseAsMastodonHtml(notification.getStatus().getContent()).toString(); return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
} }
case POLL: case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent())); StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
@ -715,6 +728,12 @@ public class NotificationHelper {
} }
return builder.toString(); return builder.toString();
} }
case REPORT:
return context.getString(
R.string.notification_header_report_format,
StringUtils.unicodeWrap(notification.getAccount().getName()),
StringUtils.unicodeWrap(notification.getReport().getTargetAccount().getName())
);
} }
return null; return null;
} }

View file

@ -36,7 +36,6 @@ import com.keylesspalace.tusky.util.CryptoUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.UnifiedPush
import retrofit2.HttpException
private const val TAG = "PushNotificationHelper" private const val TAG = "PushNotificationHelper"
@ -210,10 +209,8 @@ suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, ac
suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
.onFailure { .onFailure { throwable ->
Log.d(TAG, "Error unregistering push endpoint for account " + account.id) Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
Log.d(TAG, Log.getStackTraceString(it))
Log.d(TAG, (it as HttpException).response().toString())
} }
.onSuccess { .onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)

View file

@ -16,11 +16,13 @@
package com.keylesspalace.tusky.components.preference package com.keylesspalace.tusky.components.preference
import android.content.Intent import android.content.Intent
import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
@ -30,6 +32,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
@ -46,7 +49,10 @@ import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference 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
import com.keylesspalace.tusky.util.makeIcon
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -66,6 +72,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
val context = requireContext() val context = requireContext()
makePreferenceScreen { makePreferenceScreen {
@ -73,7 +81,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_title_edit_notification_settings) setTitle(R.string.pref_title_edit_notification_settings)
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).apply { icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).apply {
sizeRes = R.dimen.preference_icon_size sizeRes = R.dimen.preference_icon_size
colorInt = ThemeUtils.getColor(context, R.attr.iconColor) colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
} }
setOnPreferenceClickListener { setOnPreferenceClickListener {
openNotificationPrefs() openNotificationPrefs()
@ -95,6 +103,20 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
preference {
setTitle(R.string.title_followed_hashtags)
setIcon(R.drawable.ic_hashtag)
setOnPreferenceClickListener {
val intent = Intent(context, FollowedTagsActivity::class.java)
activity?.startActivity(intent)
activity?.overridePendingTransition(
R.anim.slide_from_right,
R.anim.slide_to_left
)
true
}
}
preference { preference {
setTitle(R.string.action_view_mutes) setTitle(R.string.action_view_mutes)
setIcon(R.drawable.ic_mute_24dp) setIcon(R.drawable.ic_mute_24dp)
@ -114,7 +136,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.action_view_blocks) setTitle(R.string.action_view_blocks)
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply { icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply {
sizeRes = R.dimen.preference_icon_size sizeRes = R.dimen.preference_icon_size
colorInt = ThemeUtils.getColor(context, R.attr.iconColor) colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
} }
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
@ -154,7 +176,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
// TODO language
preferenceCategory(R.string.pref_publishing) { preferenceCategory(R.string.pref_publishing) {
listPreference { listPreference {
setTitle(R.string.pref_default_post_privacy) setTitle(R.string.pref_default_post_privacy)
@ -174,6 +195,29 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
listPreference {
val locales = getLocaleList(getInitialLanguage(null, accountManager.activeAccount))
setTitle(R.string.pref_default_post_language)
// Explicitly add "System default" to the start of the list
entries = (
listOf(context.getString(R.string.system_default)) + locales.map {
it.getTuskyDisplayName(context)
}
).toTypedArray()
entryValues = (listOf("") + locales.map { it.language }).toTypedArray()
key = PrefKeys.DEFAULT_POST_LANGUAGE
icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize)
value = accountManager.activeAccount?.defaultPostLanguage ?: ""
isPersistent = false // This will be entirely server-driven
setSummaryProvider { entry }
setOnPreferenceChangeListener { _, newValue ->
syncWithServer(language = (newValue as String))
eventHub.dispatch(PreferenceChangedEvent(key))
true
}
}
switchPreference { switchPreference {
setTitle(R.string.pref_default_media_sensitivity) setTitle(R.string.pref_default_media_sensitivity)
setIcon(R.drawable.ic_eye_24dp) setIcon(R.drawable.ic_eye_24dp)
@ -280,6 +324,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.action_view_account_preferences)
}
private fun openNotificationPrefs() { private fun openNotificationPrefs() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent() val intent = Intent()
@ -302,8 +351,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) {
mastodonApi.accountUpdateSource(visibility, sensitive) mastodonApi.accountUpdateSource(visibility, sensitive, language)
.enqueue(object : Callback<Account> { .enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) { override fun onResponse(call: Call<Account>, response: Response<Account>) {
val account = response.body() val account = response.body()
@ -313,6 +362,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
it.defaultPostPrivacy = account.source?.privacy it.defaultPostPrivacy = account.source?.privacy
?: Status.Visibility.PUBLIC ?: Status.Visibility.PUBLIC
it.defaultMediaSensitivity = account.source?.sensitive ?: false it.defaultMediaSensitivity = account.source?.sensitive ?: false
it.defaultPostLanguage = language ?: ""
accountManager.saveAccount(it) accountManager.saveAccount(it)
} }
} else { } else {

View file

@ -144,6 +144,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true true
} }
} }
switchPreference {
setTitle(R.string.pref_title_notification_filter_reports)
key = PrefKeys.NOTIFICATION_FILTER_REPORTS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReports
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsReports = newValue as Boolean }
true
}
}
} }
preferenceCategory(R.string.pref_title_notification_alerts) { category -> preferenceCategory(R.string.pref_title_notification_alerts) { category ->
@ -193,6 +204,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
} }
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.pref_title_edit_notification_settings)
}
companion object { companion object {
fun newInstance(): NotificationPreferencesFragment { fun newInstance(): NotificationPreferencesFragment {
return NotificationPreferencesFragment() return NotificationPreferencesFragment()

View file

@ -23,6 +23,8 @@ import android.util.Log
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity 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.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
import com.keylesspalace.tusky.settings.PrefKeys 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.getNonNullString
import com.keylesspalace.tusky.util.setAppNightMode
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import javax.inject.Inject import javax.inject.Inject
@ -40,6 +43,7 @@ import javax.inject.Inject
class PreferencesActivity : class PreferencesActivity :
BaseActivity(), BaseActivity(),
SharedPreferences.OnSharedPreferenceChangeListener, SharedPreferences.OnSharedPreferenceChangeListener,
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
HasAndroidInjector { HasAndroidInjector {
@Inject @Inject
@ -81,8 +85,6 @@ class PreferencesActivity :
GENERAL_PREFERENCES -> PreferencesFragment.newInstance() GENERAL_PREFERENCES -> PreferencesFragment.newInstance()
ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance() ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance()
NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance() NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance()
TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance()
PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance()
else -> throw IllegalArgumentException("preferenceType not known") else -> throw IllegalArgumentException("preferenceType not known")
} }
@ -90,18 +92,34 @@ class PreferencesActivity :
replace(R.id.fragment_container, fragment, fragmentTag) 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) onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false 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() { override fun onResume() {
super.onResume() super.onResume()
PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this)
@ -124,9 +142,9 @@ class PreferencesActivity :
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) { when (key) {
"appTheme" -> { "appTheme" -> {
val theme = sharedPreferences.getNonNullString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
Log.d("activeTheme", theme) Log.d("activeTheme", theme)
ThemeUtils.setAppNightMode(theme) setAppNightMode(theme)
restartActivitiesOnBackPressedCallback.isEnabled = true restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity() this.restartCurrentActivity()
@ -158,8 +176,6 @@ class PreferencesActivity :
const val GENERAL_PREFERENCES = 0 const val GENERAL_PREFERENCES = 0
const val ACCOUNT_PREFERENCES = 1 const val ACCOUNT_PREFERENCES = 1
const val NOTIFICATION_PREFERENCES = 2 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_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE"
private const val EXTRA_RESTART_ON_BACK = "restart" private const val EXTRA_RESTART_ON_BACK = "restart"

View file

@ -31,14 +31,11 @@ import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deserialize 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.keylesspalace.tusky.util.serialize
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject import javax.inject.Inject
@ -51,7 +48,26 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
lateinit var localeManager: LocaleManager lateinit var localeManager: LocaleManager
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } 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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
makePreferenceScreen { makePreferenceScreen {
@ -92,6 +108,16 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) 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 { listPreference {
setDefaultValue("top") setDefaultValue("top")
setEntries(R.array.pref_main_nav_position_options) setEntries(R.array.pref_main_nav_position_options)
@ -208,14 +234,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timeline_filters) { preferenceCategory(R.string.pref_title_timeline_filters) {
preference { preference {
setTitle(R.string.pref_title_post_tabs) setTitle(R.string.pref_title_post_tabs)
setOnPreferenceClickListener { fragment = TabFilterPreferencesFragment::class.qualifiedName
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
}
} }
} }
@ -259,53 +278,22 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
preferenceCategory(R.string.pref_title_proxy_settings) { preferenceCategory(R.string.pref_title_proxy_settings) {
httpProxyPref = preference { preference {
setTitle(R.string.pref_title_http_proxy_settings) setTitle(R.string.pref_title_http_proxy_settings)
setOnPreferenceClickListener { fragment = ProxyPreferencesFragment::class.qualifiedName
activity?.let { activity -> summaryProvider = ProxyPreferencesFragment.SummaryProvider
val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.PROXY_PREFERENCES)
activity.startActivity(intent)
activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
}
true
}
} }
} }
} }
} }
private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable { private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
val context = requireContext() return makeIcon(requireContext(), icon, iconSize)
return IconicsDrawable(context, icon).apply {
sizePx = iconSize
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
updateHttpProxySummary() requireActivity().setTitle(R.string.action_view_preferences)
}
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 = ""
}
} }
override fun onDisplayPreferenceDialog(preference: Preference) { override fun onDisplayPreferenceDialog(preference: Preference) {

View file

@ -16,12 +16,18 @@
package com.keylesspalace.tusky.components.preference package com.keylesspalace.tusky.components.preference
import android.os.Bundle import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.settings.PrefKeys 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.makePreferenceScreen
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.settings.validatedEditTextPreference
import com.keylesspalace.tusky.util.getNonNullString
import kotlin.system.exitProcess import kotlin.system.exitProcess
class ProxyPreferencesFragment : PreferenceFragmentCompat() { class ProxyPreferencesFragment : PreferenceFragmentCompat() {
@ -36,22 +42,38 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() {
setDefaultValue(false) setDefaultValue(false)
} }
editTextPreference { preferenceCategory { category ->
setTitle(R.string.pref_title_http_proxy_server) category.dependency = PrefKeys.HTTP_PROXY_ENABLED
key = PrefKeys.HTTP_PROXY_SERVER category.isIconSpaceReserved = false
isIconSpaceReserved = false
setSummaryProvider { text }
}
editTextPreference { validatedEditTextPreference(null, ProxyConfiguration::isValidHostname) {
setTitle(R.string.pref_title_http_proxy_port) setTitle(R.string.pref_title_http_proxy_server)
key = PrefKeys.HTTP_PROXY_PORT key = PrefKeys.HTTP_PROXY_SERVER
isIconSpaceReserved = false isIconSpaceReserved = false
setSummaryProvider { text } 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() { override fun onPause() {
super.onPause() super.onPause()
if (pendingRestart) { 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 { companion object {
fun newInstance(): ProxyPreferencesFragment { fun newInstance(): ProxyPreferencesFragment {
return 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 { companion object {
fun newInstance(): TabFilterPreferencesFragment { fun newInstance(): TabFilterPreferencesFragment {
return TabFilterPreferencesFragment() return TabFilterPreferencesFragment()

View file

@ -154,52 +154,46 @@ class ReportViewModel @Inject constructor(
fun toggleMute() { fun toggleMute() {
val alreadyMuted = muteStateMutable.value?.data == true val alreadyMuted = muteStateMutable.value?.data == true
if (alreadyMuted) { viewModelScope.launch {
mastodonApi.unmuteAccount(accountId) try {
} else { val relationship = if (alreadyMuted) {
mastodonApi.muteAccount(accountId) mastodonApi.unmuteAccount(accountId)
} } else {
.subscribeOn(Schedulers.io()) mastodonApi.muteAccount(accountId)
.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)
} }
).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() muteStateMutable.value = Loading()
} }
fun toggleBlock() { fun toggleBlock() {
val alreadyBlocked = blockStateMutable.value?.data == true val alreadyBlocked = blockStateMutable.value?.data == true
if (alreadyBlocked) { viewModelScope.launch {
mastodonApi.unblockAccount(accountId) try {
} else { val relationship = if (alreadyBlocked) {
mastodonApi.blockAccount(accountId) mastodonApi.unblockAccount(accountId)
} } else {
.subscribeOn(Schedulers.io()) mastodonApi.blockAccount(accountId)
.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)
} }
)
.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() 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
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER 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.StatusViewHelper.Companion.NO_INPUT_FILTER
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.setClickableMentions import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
@ -161,7 +161,7 @@ class StatusViewHolder(
binding.timestampInfo.text = if (createdAt != null) { binding.timestampInfo.text = if (createdAt != null) {
val then = createdAt.time val then = createdAt.time
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
TimestampUtils.getRelativeTimeSpanString(binding.timestampInfo.context, then, now) getRelativeTimeSpanString(binding.timestampInfo.context, then, now)
} else { } else {
// unknown minutes~ // unknown minutes~
"?m" "?m"

View file

@ -128,7 +128,8 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I
inReplyToId = item.params.inReplyToId, inReplyToId = item.params.inReplyToId,
visibility = item.params.visibility, visibility = item.params.visibility,
scheduledAt = item.scheduledAt, scheduledAt = item.scheduledAt,
sensitive = item.params.sensitive sensitive = item.params.sensitive,
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED
) )
) )
startActivity(intent) startActivity(intent)

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.scheduled package com.keylesspalace.tusky.components.scheduled
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
@ -53,8 +52,7 @@ class ScheduledStatusAdapter(
holder.binding.edit.isEnabled = true holder.binding.edit.isEnabled = true
holder.binding.delete.isEnabled = true holder.binding.delete.isEnabled = true
holder.binding.text.text = item.params.text holder.binding.text.text = item.params.text
holder.binding.edit.setOnClickListener { v: View -> holder.binding.edit.setOnClickListener {
v.isEnabled = false
listener.edit(item) listener.edit(item)
} }
holder.binding.delete.setOnClickListener { holder.binding.delete.setOnClickListener {

View file

@ -22,12 +22,15 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.preference.PreferenceManager
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter
import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.databinding.ActivitySearchBinding
import com.keylesspalace.tusky.di.ViewModelFactory 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 com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
@ -44,6 +47,8 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
private val binding by viewBinding(ActivitySearchBinding::inflate) private val binding by viewBinding(ActivitySearchBinding::inflate)
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
@ -58,8 +63,12 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
} }
private fun setupPages() { private fun setupPages() {
binding.pages.reduceSwipeSensitivity()
binding.pages.adapter = SearchPagerAdapter(this) binding.pages.adapter = SearchPagerAdapter(this)
val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
binding.pages.isUserInputEnabled = enableSwipeForTabs
TabLayoutMediator(binding.tabs, binding.pages) { TabLayoutMediator(binding.tabs, binding.pages) {
tab, position -> tab, position ->
tab.text = getPageTitle(position) tab.text = getPageTitle(position)

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
@ -31,7 +32,9 @@ import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers 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 import javax.inject.Inject
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
@ -98,17 +101,13 @@ class SearchViewModel @Inject constructor(
} }
fun removeItem(statusViewData: StatusViewData.Concrete) { fun removeItem(statusViewData: StatusViewData.Concrete) {
timelineCases.delete(statusViewData.id) viewModelScope.launch {
.subscribe( if (timelineCases.delete(statusViewData.id).isSuccess) {
{ if (loadedStatuses.remove(statusViewData)) {
if (loadedStatuses.remove(statusViewData)) statusesPagingSourceFactory.invalidate()
statusesPagingSourceFactory.invalidate()
},
{ err ->
Log.d(TAG, "Failed to delete status", err)
} }
) }
.autoDispose() }
} }
fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
@ -169,7 +168,9 @@ class SearchViewModel @Inject constructor(
} }
fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { 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) { fun pinAccount(status: Status, isPin: Boolean) {
@ -177,11 +178,15 @@ class SearchViewModel @Inject constructor(
} }
fun blockAccount(accountId: String) { fun blockAccount(accountId: String) {
timelineCases.block(accountId) viewModelScope.launch {
timelineCases.block(accountId)
}
} }
fun deleteStatus(id: String): Single<DeletedStatus> { fun deleteStatusAsync(id: String): Deferred<NetworkResult<DeletedStatus>> {
return timelineCases.delete(id) return viewModelScope.async {
timelineCases.delete(id)
}
} }
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {

View file

@ -19,24 +19,27 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.databinding.ItemAccountBinding
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener 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) { PagingDataAdapter<TimelineAccount, AccountViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val view = LayoutInflater.from(parent.context) val binding = ItemAccountBinding.inflate(
.inflate(R.layout.item_account, parent, false) LayoutInflater.from(parent.context),
return AccountViewHolder(view) parent,
false
)
return AccountViewHolder(binding)
} }
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
holder.apply { holder.apply {
setupWithAccount(item, animateAvatars, animateEmojis) setupWithAccount(item, animateAvatars, animateEmojis, showBotOverlay)
setupLinkListener(linkListener) setupLinkListener(linkListener)
} }
} }

View file

@ -30,7 +30,8 @@ class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
return SearchAccountsAdapter( return SearchAccountsAdapter(
this, this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), 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.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -38,6 +39,9 @@ abstract class SearchFragment<T : Any> :
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var mastodonApi: MastodonApi
protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory } protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory }
protected val binding by viewBinding(FragmentSearchBinding::bind) protected val binding by viewBinding(FragmentSearchBinding::bind)

View file

@ -32,14 +32,14 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import at.connyduck.calladapter.networkresult.fold
import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity 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.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener { class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
@ -219,6 +219,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
replyingStatusAuthor = actionableStatus.account.localUsername, replyingStatusAuthor = actionableStatus.account.localUsername,
replyingStatusContent = status.content.toString(), replyingStatusContent = status.content.toString(),
language = actionableStatus.language, language = actionableStatus.language,
kind = ComposeActivity.ComposeKind.NEW
) )
) )
bottomSheetActivity?.startActivityWithSlideInAnimation(intent) bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
@ -351,6 +352,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
showConfirmEditDialog(id, position, status) showConfirmEditDialog(id, position, status)
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_edit -> {
editStatus(id, position, status)
return@setOnMenuItemClickListener true
}
R.id.pin -> { R.id.pin -> {
viewModel.pinAccount(status, !status.isPinned()) viewModel.pinAccount(status, !status.isPinned())
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
@ -436,7 +441,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_delete_post_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatusAsync(id)
removeItem(position) removeItem(position)
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -449,10 +454,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_redraft_post_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) lifecycleScope.launch {
.observeOn(AndroidSchedulers.mainThread()) viewModel.deleteStatusAsync(id).await().fold(
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ deletedStatus -> { deletedStatus ->
removeItem(position) removeItem(position)
@ -473,6 +476,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
sensitive = redraftStatus.sensitive, sensitive = redraftStatus.sensitive,
poll = redraftStatus.poll?.toNewPoll(status.createdAt), poll = redraftStatus.poll?.toNewPoll(status.createdAt),
language = redraftStatus.language, language = redraftStatus.language,
kind = ComposeActivity.ComposeKind.NEW
) )
) )
startActivity(intent) startActivity(intent)
@ -482,9 +486,39 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
} }
) )
}
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .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

@ -42,11 +42,11 @@ import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -63,6 +63,7 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -85,9 +86,6 @@ class TimelineFragment :
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject
lateinit var accountManager: AccountManager
private val viewModel: TimelineViewModel by lazy { private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) { if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
@ -105,6 +103,38 @@ class TimelineFragment :
private var isSwipeToRefreshEnabled = true private var isSwipeToRefreshEnabled = true
private var hideFab = false 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -134,6 +164,8 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -211,6 +243,9 @@ class TimelineFragment :
} }
} }
} }
if (readingOrder == ReadingOrder.OLDEST_FIRST) {
updateReadingPositionForOldestFirst()
}
} }
}) })
@ -257,6 +292,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() { private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.swipeRefreshLayout.setOnRefreshListener(this) binding.swipeRefreshLayout.setOnRefreshListener(this)
@ -348,6 +410,8 @@ class TimelineFragment :
override fun onLoadMore(position: Int) { override fun onLoadMore(position: Int) {
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
loadMorePosition = position
statusIdBelowLoadMore = adapter.peek(position + 1)?.id
viewModel.loadMore(placeholder.id) viewModel.loadMore(placeholder.id)
} }
@ -408,6 +472,11 @@ class TimelineFragment :
adapter.notifyItemRangeChanged(0, adapter.itemCount) 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 package com.keylesspalace.tusky.components.timeline
import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineAccountEntity
@ -30,6 +31,8 @@ import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
private const val TAG = "TimelineTypeMappers"
data class Placeholder( data class Placeholder(
val id: String, val id: String,
val loading: Boolean val loading: Boolean
@ -77,6 +80,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
inReplyToAccountId = null, inReplyToAccountId = null,
content = null, content = null,
createdAt = 0L, createdAt = 0L,
editedAt = 0L,
emojis = null, emojis = null,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = 0, favouritesCount = 0,
@ -120,6 +124,7 @@ fun Status.toEntity(
inReplyToAccountId = actionableStatus.inReplyToAccountId, inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content, content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time, createdAt = actionableStatus.createdAt.time,
editedAt = actionableStatus.editedAt?.time,
emojis = actionableStatus.emojis.let(gson::toJson), emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount, reblogsCount = actionableStatus.reblogsCount,
favouritesCount = actionableStatus.favouritesCount, favouritesCount = actionableStatus.favouritesCount,
@ -147,8 +152,9 @@ fun Status.toEntity(
) )
} }
fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData {
if (this.status.authorServerId == null) { if (this.status.isPlaceholder) {
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
} }
@ -170,6 +176,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
reblog = null, reblog = null,
content = status.content.orEmpty(), content = status.content.orEmpty(),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount, favouritesCount = status.favouritesCount,
@ -201,6 +208,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
reblog = reblog, reblog = reblog,
content = "", content = "",
createdAt = Date(status.createdAt), // lie but whatever? createdAt = Date(status.createdAt), // lie but whatever?
editedAt = null,
emojis = listOf(), emojis = listOf(),
reblogsCount = 0, reblogsCount = 0,
favouritesCount = 0, favouritesCount = 0,
@ -231,6 +239,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
reblog = null, reblog = null,
content = status.content.orEmpty(), content = status.content.orEmpty(),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount, favouritesCount = status.favouritesCount,
@ -256,6 +265,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
status = status, status = status,
isExpanded = this.status.expanded, isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing, 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 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 contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val contentCollapsed = oldStatus?.contentCollapsed ?: true 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.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent 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.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.toViewData
@ -169,13 +171,23 @@ class CachedTimelineViewModel @Inject constructor(
val response = db.withTransaction { val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val nextPlaceholderId = val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId)
timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) when (readingOrder) {
api.homeTimeline( // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
maxId = idAbovePlaceholder, // after minId and no larger than maxId
sinceId = nextPlaceholderId, OLDEST_FIRST -> api.homeTimeline(
limit = LOAD_AT_ONCE 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() val statuses = response.body()
@ -218,12 +230,16 @@ class CachedTimelineViewModel @Inject constructor(
/* In case we loaded a whole page and there was no overlap with existing statuses, /* 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 */ we insert a placeholder because there might be even more unknown statuses */
if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { 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 to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */ servers handle client generated ids as expected */
val idToConvert = when (readingOrder) {
OLDEST_FIRST -> statuses.first().id
NEWEST_FIRST -> statuses.last().id
}
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder( Placeholder(
statuses.last().id, idToConvert,
loading = false loading = false
).toEntity(activeAccount.id) ).toEntity(activeAccount.id)
) )

View file

@ -259,7 +259,7 @@ class NetworkTimelineViewModel @Inject constructor(
limit: Int limit: Int
): Response<List<Status>> { ): Response<List<Status>> {
return when (kind) { 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_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit)
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit)
Kind.TAG -> { Kind.TAG -> {

View file

@ -20,6 +20,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent
@ -33,6 +34,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent 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.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
@ -53,7 +55,7 @@ abstract class TimelineViewModel(
private val api: MastodonApi, private val api: MastodonApi,
private val eventHub: EventHub, private val eventHub: EventHub,
protected val accountManager: AccountManager, protected val accountManager: AccountManager,
private val sharedPreferences: SharedPreferences, protected val sharedPreferences: SharedPreferences,
private val filterModel: FilterModel private val filterModel: FilterModel
) : ViewModel() { ) : ViewModel() {
@ -70,6 +72,7 @@ abstract class TimelineViewModel(
protected var alwaysOpenSpoilers = false protected var alwaysOpenSpoilers = false
private var filterRemoveReplies = false private var filterRemoveReplies = false
private var filterRemoveReblogs = false private var filterRemoveReblogs = false
protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST
fun init( fun init(
kind: Kind, kind: Kind,
@ -87,6 +90,8 @@ abstract class TimelineViewModel(
filterRemoveReblogs = filterRemoveReblogs =
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
} }
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
@ -124,7 +129,7 @@ abstract class TimelineViewModel(
timelineCases.bookmark(status.actionableId, bookmark).await() timelineCases.bookmark(status.actionableId, bookmark).await()
} catch (t: Exception) { } catch (t: Exception) {
ifExpected(t) { ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t) Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
} }
} }
} }
@ -211,6 +216,9 @@ abstract class TimelineViewModel(
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia =
accountManager.activeAccount!!.alwaysShowSensitiveMedia accountManager.activeAccount!!.alwaysShowSensitiveMedia
} }
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
}
} }
} }
@ -280,10 +288,8 @@ abstract class TimelineViewModel(
private fun reloadFilters() { private fun reloadFilters() {
viewModelScope.launch { viewModelScope.launch {
val filters = try { val filters = api.getFilters().getOrElse {
api.getFilters().await() Log.e(TAG, "Failed to fetch filters", it)
} catch (t: Exception) {
Log.e(TAG, "Failed to fetch filters", t)
return@launch return@launch
} }
filterModel.initWithFilters( filterModel.initWithFilters(

View file

@ -62,6 +62,7 @@ class ThreadAdapter(
} }
companion object { companion object {
private const val TAG = "ThreadAdapter"
private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_DETAILED = 1

View file

@ -21,7 +21,10 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.CheckResult
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
@ -33,6 +36,7 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
@ -48,6 +52,9 @@ import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@ -104,6 +111,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
binding.toolbar.setNavigationOnClickListener { binding.toolbar.setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed() activity?.onBackPressedDispatcher?.onBackPressed()
} }
binding.toolbar.inflateMenu(R.menu.view_thread_toolbar)
binding.toolbar.setOnMenuItemClickListener { menuItem -> binding.toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.action_reveal -> { R.id.action_reveal -> {
@ -139,24 +147,50 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { uiState -> viewModel.uiState.collect { uiState ->
when (uiState) { when (uiState) {
is ThreadUiState.Loading -> { is ThreadUiState.Loading -> {
updateRevealButton(RevealButtonState.NO_BUTTON) updateRevealButton(RevealButtonState.NO_BUTTON)
binding.recyclerView.hide() binding.recyclerView.hide()
binding.statusView.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 -> { is ThreadUiState.Error -> {
Log.w(TAG, "failed to load status", uiState.throwable) Log.w(TAG, "failed to load status", uiState.throwable)
initialProgressBar.cancel()
threadProgressBar.cancel()
updateRevealButton(RevealButtonState.NO_BUTTON) updateRevealButton(RevealButtonState.NO_BUTTON)
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.hide() binding.recyclerView.hide()
binding.statusView.show() binding.statusView.show()
binding.progressBar.hide()
if (uiState.throwable is IOException) { if (uiState.throwable is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
@ -169,22 +203,31 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
} }
} }
is ThreadUiState.Success -> { is ThreadUiState.Success -> {
adapter.submitList(uiState.statuses) { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
if (viewModel.isInitialLoad) { // no detailed statuses available, e.g. because author is blocked
activity?.finish()
return@collect
}
threadProgressBar.cancel()
adapter.submitList(uiState.statusViewData) {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && viewModel.isInitialLoad) {
viewModel.isInitialLoad = false viewModel.isInitialLoad = false
val detailedPosition = adapter.currentList.indexOfFirst { viewData ->
viewData.isDetailed // Ensure the top of the status is visible
} (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0)
binding.recyclerView.scrollToPosition(detailedPosition)
} }
} }
updateRevealButton(uiState.revealButton) updateRevealButton(uiState.revealButton)
binding.swipeRefreshLayout.isRefreshing = uiState.refreshing binding.swipeRefreshLayout.isRefreshing = false
binding.recyclerView.show() binding.recyclerView.show()
binding.statusView.hide() binding.statusView.hide()
binding.progressBar.hide() }
is ThreadUiState.Refreshing -> {
threadProgressBar.cancel()
} }
} }
} }
@ -204,6 +247,28 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
viewModel.loadThread(thisThreadsStatusId) 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) { private fun updateRevealButton(state: RevealButtonState) {
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal) val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal)
@ -319,6 +384,17 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
viewModel.voteInPoll(choices, status) 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 { companion object {
private const val TAG = "ViewThreadFragment" private const val TAG = "ViewThreadFragment"

View file

@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub 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.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent 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.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
@ -54,7 +57,9 @@ class ViewThreadViewModel @Inject constructor(
private val filterModel: FilterModel, private val filterModel: FilterModel,
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager accountManager: AccountManager,
private val db: AppDatabase,
private val gson: Gson
) : ViewModel() { ) : ViewModel() {
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading) private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
@ -95,36 +100,70 @@ class ViewThreadViewModel @Inject constructor(
} }
fun loadThread(id: String) { fun loadThread(id: String) {
_uiState.value = ThreadUiState.Loading
viewModelScope.launch { viewModelScope.launch {
Log.d(TAG, "Finding status with: $id")
val contextCall = async { api.statusContext(id) } val contextCall = async { api.statusContext(id) }
val statusCall = async { api.statusAsync(id) } val timelineStatus = db.timelineDao().getStatus(id)
val contextResult = contextCall.await() var detailedStatus = if (timelineStatus != null) {
val statusResult = statusCall.await() Log.d(TAG, "Loaded status from local timeline")
val viewData = timelineStatus.toViewData(
gson,
isDetailed = true
) as StatusViewData.Concrete
val status = statusResult.getOrElse { exception -> // Return the correct status, depending on which one matched. If you do not do
_uiState.value = ThreadUiState.Error(exception) // this the status IDs will be different between the status that's displayed with
return@launch // 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 ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
val detailedStatus = status.toViewData(true)
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
val statuses = ancestors + detailedStatus + descendants val statuses = ancestors + detailedStatus + descendants
_uiState.value = ThreadUiState.Success( _uiState.value = ThreadUiState.Success(
statuses = statuses, statusViewData = statuses,
revealButton = statuses.getRevealButtonState(), detailedStatusPosition = ancestors.size,
refreshing = false revealButton = statuses.getRevealButtonState()
) )
}, { throwable -> }, { throwable ->
_errors.emit(throwable) _errors.emit(throwable)
_uiState.value = ThreadUiState.Success( _uiState.value = ThreadUiState.Success(
statuses = listOf(status.toViewData(true)), statusViewData = listOf(detailedStatus),
detailedStatusPosition = 0,
revealButton = RevealButtonState.NO_BUTTON, revealButton = RevealButtonState.NO_BUTTON,
refreshing = false
) )
}) })
} }
@ -136,15 +175,17 @@ class ViewThreadViewModel @Inject constructor(
} }
fun refresh(id: String) { fun refresh(id: String) {
updateSuccess { uiState -> _uiState.value = ThreadUiState.Refreshing
uiState.copy(refreshing = true)
}
loadThread(id) loadThread(id)
} }
fun detailedStatus(): StatusViewData.Concrete? { fun detailedStatus(): StatusViewData.Concrete? {
return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status -> return when (val uiState = _uiState.value) {
status.isDetailed 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() timelineCases.bookmark(status.actionableId, bookmark).await()
} catch (t: Exception) { } catch (t: Exception) {
ifExpected(t) { 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) { fun removeStatus(statusToRemove: StatusViewData.Concrete) {
updateSuccess { uiState -> updateSuccess { uiState ->
uiState.copy( uiState.copy(
statuses = uiState.statuses.filterNot { status -> status == statusToRemove } statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove }
) )
} }
} }
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
updateSuccess { uiState -> updateSuccess { uiState ->
val statuses = uiState.statuses.map { viewData -> val statuses = uiState.statusViewData.map { viewData ->
if (viewData.id == status.id) { if (viewData.id == status.id) {
viewData.copy(isExpanded = expanded) viewData.copy(isExpanded = expanded)
} else { } else {
@ -216,7 +257,7 @@ class ViewThreadViewModel @Inject constructor(
} }
} }
uiState.copy( uiState.copy(
statuses = statuses, statusViewData = statuses,
revealButton = statuses.getRevealButtonState() revealButton = statuses.getRevealButtonState()
) )
} }
@ -261,8 +302,8 @@ class ViewThreadViewModel @Inject constructor(
private fun removeAllByAccountId(accountId: String) { private fun removeAllByAccountId(accountId: String) {
updateSuccess { uiState -> updateSuccess { uiState ->
uiState.copy( uiState.copy(
statuses = uiState.statuses.filter { viewData -> statusViewData = uiState.statusViewData.filter { viewData ->
viewData.status.account.id == accountId viewData.status.account.id != accountId
} }
) )
} }
@ -271,7 +312,7 @@ class ViewThreadViewModel @Inject constructor(
private fun handleStatusComposedEvent(event: StatusComposedEvent) { private fun handleStatusComposedEvent(event: StatusComposedEvent) {
val eventStatus = event.status val eventStatus = event.status
updateSuccess { uiState -> updateSuccess { uiState ->
val statuses = uiState.statuses val statuses = uiState.statusViewData
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
if (detailedIndex != -1 && repliedIndex >= detailedIndex) { if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
@ -279,7 +320,7 @@ class ViewThreadViewModel @Inject constructor(
val newStatuses = statuses.subList(0, repliedIndex + 1) + val newStatuses = statuses.subList(0, repliedIndex + 1) +
eventStatus.toViewData() + eventStatus.toViewData() +
statuses.subList(repliedIndex + 1, statuses.size) statuses.subList(repliedIndex + 1, statuses.size)
uiState.copy(statuses = newStatuses) uiState.copy(statusViewData = newStatuses)
} else { } else {
uiState uiState
} }
@ -289,7 +330,7 @@ class ViewThreadViewModel @Inject constructor(
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
updateSuccess { uiState -> updateSuccess { uiState ->
uiState.copy( uiState.copy(
statuses = uiState.statuses.filter { status -> statusViewData = uiState.statusViewData.filter { status ->
status.id != event.statusId status.id != event.statusId
} }
) )
@ -300,13 +341,13 @@ class ViewThreadViewModel @Inject constructor(
updateSuccess { uiState -> updateSuccess { uiState ->
when (uiState.revealButton) { when (uiState.revealButton) {
RevealButtonState.HIDE -> uiState.copy( RevealButtonState.HIDE -> uiState.copy(
statuses = uiState.statuses.map { viewData -> statusViewData = uiState.statusViewData.map { viewData ->
viewData.copy(isExpanded = false) viewData.copy(isExpanded = false)
}, },
revealButton = RevealButtonState.REVEAL revealButton = RevealButtonState.REVEAL
) )
RevealButtonState.REVEAL -> uiState.copy( RevealButtonState.REVEAL -> uiState.copy(
statuses = uiState.statuses.map { viewData -> statusViewData = uiState.statusViewData.map { viewData ->
viewData.copy(isExpanded = true) viewData.copy(isExpanded = true)
}, },
revealButton = RevealButtonState.HIDE revealButton = RevealButtonState.HIDE
@ -316,16 +357,11 @@ class ViewThreadViewModel @Inject constructor(
} }
} }
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState { private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState {
val hasWarnings = any { viewData -> val hasWarnings = status.spoilerText.isNotEmpty()
viewData.status.spoilerText.isNotEmpty()
}
return if (hasWarnings) { return if (hasWarnings) {
val allExpanded = none { viewData -> if (isExpanded) {
!viewData.isExpanded
}
if (allExpanded) {
RevealButtonState.HIDE RevealButtonState.HIDE
} else { } else {
RevealButtonState.REVEAL 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() { private fun loadFilters() {
viewModelScope.launch { viewModelScope.launch {
val filters = try { val filters = api.getFilters().getOrElse {
api.getFilters().await() Log.w(TAG, "Failed to fetch filters", it)
} catch (t: Exception) {
Log.w(TAG, "Failed to fetch filters", t)
return@launch return@launch
} }
filterModel.initWithFilters( filterModel.initWithFilters(
filters.filter { filter -> filters.filter { filter ->
filter.context.contains(Filter.THREAD) filter.context.contains(Filter.THREAD)
@ -350,9 +410,9 @@ class ViewThreadViewModel @Inject constructor(
) )
updateSuccess { uiState -> updateSuccess { uiState ->
val statuses = uiState.statuses.filter() val statuses = uiState.statusViewData.filter()
uiState.copy( uiState.copy(
statuses = statuses, statusViewData = statuses,
revealButton = statuses.getRevealButtonState() revealButton = statuses.getRevealButtonState()
) )
} }
@ -365,13 +425,15 @@ class ViewThreadViewModel @Inject constructor(
} }
} }
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { private fun Status.toViewData(
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id } isDetailed: Boolean = false
): StatusViewData.Concrete {
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id }
return toViewData( return toViewData(
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
isCollapsed = oldStatus?.isCollapsed ?: !detailed, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
isDetailed = oldStatus?.isDetailed ?: detailed isDetailed = oldStatus?.isDetailed ?: isDetailed
) )
} }
@ -388,7 +450,7 @@ class ViewThreadViewModel @Inject constructor(
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) {
updateSuccess { uiState -> updateSuccess { uiState ->
uiState.copy( uiState.copy(
statuses = uiState.statuses.map { viewData -> statusViewData = uiState.statusViewData.map { viewData ->
if (viewData.id == statusId) { if (viewData.id == statusId) {
updater(viewData) updater(viewData)
} else { } else {
@ -413,13 +475,27 @@ class ViewThreadViewModel @Inject constructor(
} }
sealed interface ThreadUiState { sealed interface ThreadUiState {
/** The initial load of the detailed status for this thread */
object Loading : ThreadUiState object Loading : ThreadUiState
class Error(val throwable: Throwable) : ThreadUiState
data class Success( /** Loading the detailed status has completed, now loading ancestors/descendants */
val statuses: List<StatusViewData.Concrete>, data class LoadingThread(
val revealButton: RevealButtonState, val statusViewDatum: StatusViewData.Concrete?,
val refreshing: Boolean val revealButton: RevealButtonState
) : ThreadUiState ) : 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 { 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) {
bottomSheetActivity?.viewUrl(url)
}
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

@ -54,6 +54,7 @@ data class AccountEntity(
var notificationsSubscriptions: Boolean = true, var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true, var notificationsSignUps: Boolean = true,
var notificationsUpdates: Boolean = true, var notificationsUpdates: Boolean = true,
var notificationsReports: Boolean = true,
var notificationSound: Boolean = true, var notificationSound: Boolean = true,
var notificationVibration: Boolean = true, var notificationVibration: Boolean = true,
var notificationLight: Boolean = true, var notificationLight: Boolean = true,
@ -61,6 +62,7 @@ data class AccountEntity(
var defaultMediaSensitivity: Boolean = false, var defaultMediaSensitivity: Boolean = false,
var defaultPostLanguage: String = "", var defaultPostLanguage: String = "",
var alwaysShowSensitiveMedia: Boolean = false, var alwaysShowSensitiveMedia: Boolean = false,
/** True if content behind a content warning is shown by default */
var alwaysOpenSpoiler: Boolean = false, var alwaysOpenSpoiler: Boolean = false,
var mediaPreviewEnabled: Boolean = true, var mediaPreviewEnabled: Boolean = true,
var lastNotificationId: String = "0", var lastNotificationId: String = "0",

View file

@ -45,9 +45,8 @@ class AccountManager @Inject constructor(db: AppDatabase) {
init { init {
accounts = accountDao.loadAll().toMutableList() accounts = accountDao.loadAll().toMutableList()
activeAccount = accounts.find { acc -> activeAccount = accounts.find { acc -> acc.isActive }
acc.isActive ?: accounts.firstOrNull()?.also { acc -> acc.isActive = true }
}
} }
/** /**
@ -169,15 +168,17 @@ class AccountManager @Inject constructor(db: AppDatabase) {
*/ */
fun setActiveAccount(accountId: Long) { fun setActiveAccount(accountId: Long) {
val newActiveAccount = accounts.find { (id) ->
id == accountId
} ?: return // invalid accountId passed, do nothing
activeAccount?.let { activeAccount?.let {
Log.d(TAG, "setActiveAccount: saving account with id " + it.id) Log.d(TAG, "setActiveAccount: saving account with id " + it.id)
it.isActive = false it.isActive = false
saveAccount(it) saveAccount(it)
} }
activeAccount = accounts.find { (id) -> activeAccount = newActiveAccount
id == accountId
}
activeAccount?.let { activeAccount?.let {
it.isActive = true it.isActive = true

View file

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 43) }, version = 47)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -617,4 +617,33 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''");
} }
}; };
public static final Migration MIGRATION_43_44 = new Migration(43, 44) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1");
}
};
public static final Migration MIGRATION_44_45 = new Migration(44, 45) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `editedAt` INTEGER");
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");
}
};
public static final Migration MIGRATION_46_47 = new Migration(46, 47) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0");
}
};
} }

View file

@ -126,13 +126,13 @@ class Converters @Inject constructor (
} }
@TypeConverter @TypeConverter
fun dateToLong(date: Date): Long { fun dateToLong(date: Date?): Long? {
return date.time return date?.time
} }
@TypeConverter @TypeConverter
fun longToDate(date: Long): Date { fun longToDate(date: Long?): Date? {
return Date(date) return date?.let { Date(it) }
} }
@TypeConverter @TypeConverter

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.db package com.keylesspalace.tusky.db
import androidx.lifecycle.LiveData
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
@ -30,6 +31,12 @@ interface DraftDao {
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC")
fun draftsPagingSource(accountId: Long): PagingSource<Int, DraftEntity> fun draftsPagingSource(accountId: Long): PagingSource<Int, DraftEntity>
@Query("SELECT COUNT(*) FROM DraftEntity WHERE accountId = :accountId AND failedToSendNew = 1")
fun draftsNeedUserAlert(accountId: Long): LiveData<Int>
@Query("UPDATE DraftEntity SET failedToSendNew = 0 WHERE accountId = :accountId AND failedToSendNew = 1")
suspend fun draftsClearNeedUserAlert(accountId: Long)
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId") @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId")
suspend fun loadDrafts(accountId: Long): List<DraftEntity> suspend fun loadDrafts(accountId: Long): List<DraftEntity>

View file

@ -40,8 +40,10 @@ data class DraftEntity(
val attachments: List<DraftAttachment>, val attachments: List<DraftAttachment>,
val poll: NewPoll?, val poll: NewPoll?,
val failedToSend: Boolean, val failedToSend: Boolean,
val failedToSendNew: Boolean,
val scheduledAt: String?, val scheduledAt: String?,
val language: String?, val language: String?,
val statusId: String?,
) )
/** /**

View file

@ -0,0 +1,99 @@
/* Copyright 2023 Andi McClure
*
* 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.db
import android.content.Context
import android.content.DialogInterface
import android.util.Log
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* This class manages an alert popup when a post has failed and been saved to drafts.
* It must be separately registered in each lifetime in which it is to appear,
* and it only appears if the post failure belongs to the current user.
*/
private const val TAG = "DraftsAlert"
@Singleton
class DraftsAlert @Inject constructor(db: AppDatabase) {
// For tracking when a media upload fails in the service
private val draftDao: DraftDao = db.draftDao()
@Inject
lateinit var accountManager: AccountManager
public fun <T> observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner {
accountManager.activeAccount?.let { activeAccount ->
val coroutineScope = context.lifecycleScope
// Assume a single MainActivity, AccountActivity or DraftsActivity never sees more then one user id in its lifetime.
val activeAccountId = activeAccount.id
// This LiveData will be automatically disposed when the activity is destroyed.
val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId)
// observe ensures that this gets called at the most appropriate moment wrt the context lifecycle—
// at init, at next onResume, or immediately if the context is resumed already.
if (showAlert) {
draftsNeedUserAlert.observe(context) { count ->
Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count")
if (count > 0) {
AlertDialog.Builder(context)
.setTitle(R.string.action_post_failed)
.setMessage(
context.getResources().getQuantityString(R.plurals.action_post_failed_detail, count)
)
.setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts
val intent = DraftsActivity.newIntent(context)
context.startActivity(intent)
}
.setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int ->
clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care
}
.show()
}
}
} else {
draftsNeedUserAlert.observe(context) { _ ->
Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts")
clearDraftsAlert(coroutineScope, activeAccountId)
}
}
} ?: run {
Log.w(TAG, "Attempted to observe drafts, but there is no active account")
}
}
/**
* Clear drafts alert for specified user
*/
fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) {
coroutineScope.launch {
draftDao.draftsClearNeedUserAlert(id)
}
}
}

View file

@ -33,7 +33,7 @@ abstract class TimelineDao {
@Query( @Query(
""" """
SELECT s.serverId, s.url, s.timelineUserId, SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language,
@ -53,6 +53,29 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
) )
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount> 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,
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)
AND s.authorServerId IS NOT NULL"""
)
abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount?
@Query( @Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
(LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId) (LENGTH(serverId) < LENGTH(:maxId) OR LENGTH(serverId) == LENGTH(:maxId) AND serverId <= :maxId)
@ -192,6 +215,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") @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? 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] * Returns the id of the next placeholder after [serverId]
*/ */
@ -200,4 +230,12 @@ AND timelineUserId = :accountId
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
abstract suspend fun getStatusCount(accountId: Long): Int 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

@ -58,6 +58,7 @@ data class TimelineStatusEntity(
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val content: String?, val content: String?,
val createdAt: Long, val createdAt: Long,
val editedAt: Long?,
val emojis: String?, val emojis: String?,
val reblogsCount: Int, val reblogsCount: Int,
val favouritesCount: Int, val favouritesCount: Int,
@ -76,13 +77,17 @@ data class TimelineStatusEntity(
val reblogAccountId: String?, val reblogAccountId: String?,
val poll: String?, val poll: String?,
val muted: Boolean?, 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 contentCollapsed: Boolean,
val contentShowing: Boolean, val contentShowing: Boolean,
val pinned: Boolean, val pinned: Boolean,
val card: String?, val card: String?,
val language: String?, val language: String?,
) ) {
val isPlaceholder: Boolean
get() = this.authorServerId == null
}
@Entity( @Entity(
primaryKeys = ["serverId", "timelineUserId"] primaryKeys = ["serverId", "timelineUserId"]

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