Merge tag 'v21.0' into develop
This commit is contained in:
commit
f9a29a6b76
306 changed files with 14908 additions and 3982 deletions
47
Release.md
Normal file
47
Release.md
Normal 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
|
|
@ -22,10 +22,11 @@ android {
|
|||
compileSdkVersion 33
|
||||
defaultConfig {
|
||||
applicationId APP_ID
|
||||
namespace "com.keylesspalace.tusky"
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 33
|
||||
versionCode 97
|
||||
versionName "20.0"
|
||||
versionCode 100
|
||||
versionName "21.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
|
@ -72,6 +73,9 @@ android {
|
|||
returnDefaultValues = true
|
||||
includeAndroidResources = true
|
||||
}
|
||||
unitTests.all {
|
||||
systemProperty 'robolectric.logging.enabled', 'true'
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
|
@ -88,8 +92,9 @@ android {
|
|||
enableSplit = false
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
|
||||
dependenciesInfo {
|
||||
includeInApk false
|
||||
includeInBundle false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
|
@ -111,3 +111,7 @@
|
|||
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
|
||||
static void throwUninitializedPropertyAccessException(java.lang.String);
|
||||
}
|
||||
|
||||
# Preference fragments can be referenced by name, ensure they remain
|
||||
# https://github.com/tuskyapp/Tusky/issues/3161
|
||||
-keep class * extends androidx.preference.PreferenceFragmentCompat
|
||||
|
|
965
app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json
Normal file
965
app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
977
app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json
Normal file
977
app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
983
app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json
Normal file
983
app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
989
app/schemas/com.keylesspalace.tusky.db.AppDatabase/47.json
Normal file
989
app/schemas/com.keylesspalace.tusky.db.AppDatabase/47.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.keylesspalace.tusky">
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
@ -39,7 +38,18 @@
|
|||
</activity>
|
||||
<activity
|
||||
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 android:name=".components.login.LoginWebViewActivity" />
|
||||
<activity
|
||||
|
@ -133,6 +143,7 @@
|
|||
<activity android:name=".ListsActivity" />
|
||||
<activity android:name=".LicenseActivity" />
|
||||
<activity android:name=".FiltersActivity" />
|
||||
<activity android:name=".components.followedtags.FollowedTagsActivity" />
|
||||
<activity
|
||||
android:name=".components.report.ReportActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
|
|
|
@ -21,6 +21,7 @@ import android.content.SharedPreferences;
|
|||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.MenuItem;
|
||||
|
@ -35,6 +36,7 @@ import androidx.core.app.ActivityCompat;
|
|||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.material.color.MaterialColors;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
|
||||
import com.keylesspalace.tusky.components.login.LoginActivity;
|
||||
|
@ -77,12 +79,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
/* set the taskdescription programmatically, the theme would turn it blue */
|
||||
String appName = getString(R.string.app_name);
|
||||
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
|
||||
int recentsBackgroundColor = ThemeUtils.getColor(this, R.attr.colorSurface);
|
||||
int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK);
|
||||
|
||||
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
|
||||
|
||||
int style = textStyle(preferences.getString("statusTextSize", "medium"));
|
||||
getTheme().applyStyle(style, false);
|
||||
getTheme().applyStyle(style, true);
|
||||
|
||||
if(requiresLogin()) {
|
||||
redirectIfNotLoggedIn();
|
||||
|
|
|
@ -29,10 +29,9 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.looksLikeMastodonUrl
|
||||
import com.keylesspalace.tusky.util.openLink
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import javax.inject.Inject
|
||||
|
||||
/** 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 {
|
||||
OPEN_IN_BROWSER,
|
||||
DISPLAY_ERROR,
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.widget.ArrayAdapter
|
|||
import android.widget.Toast
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
|
||||
|
@ -19,7 +20,6 @@ import com.keylesspalace.tusky.view.getSecondsForDurationIndex
|
|||
import com.keylesspalace.tusky.view.setupEditDialogForFilter
|
||||
import com.keylesspalace.tusky.view.showAddFilterDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -150,12 +150,10 @@ class FiltersActivity : BaseActivity() {
|
|||
binding.filterProgressBar.show()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val newFilters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
val newFilters = api.getFilters().getOrElse {
|
||||
binding.filterProgressBar.hide()
|
||||
binding.filterMessageView.show()
|
||||
if (t is IOException) {
|
||||
if (it is IOException) {
|
||||
binding.filterMessageView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
|
|
|
@ -38,12 +38,12 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.databinding.ActivityListsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.show
|
||||
|
@ -244,8 +244,8 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
|
|||
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
|
||||
.let(this::ListViewHolder)
|
||||
.apply {
|
||||
val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary)
|
||||
val context = nameTextView.context
|
||||
val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary)
|
||||
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
|
||||
|
||||
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
|
||||
|
|
|
@ -20,7 +20,6 @@ import android.content.Context
|
|||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
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.FixedSizeDrawable
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
|
@ -76,6 +76,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
|
|||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityMainBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
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.pager.MainPagerAdapter
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
|
||||
import com.keylesspalace.tusky.usecase.LogoutUsecase
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.updateShortcut
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
@ -140,6 +143,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
@Inject
|
||||
lateinit var logoutUsecase: LogoutUsecase
|
||||
|
||||
@Inject
|
||||
lateinit var draftsAlert: DraftsAlert
|
||||
|
||||
@Inject
|
||||
lateinit var developerToolsUseCase: DeveloperToolsUseCase
|
||||
|
||||
private val binding by viewBinding(ActivityMainBinding::inflate)
|
||||
|
||||
private lateinit var header: AccountHeaderView
|
||||
|
@ -241,7 +250,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
|
||||
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
|
||||
sizeDp = 20
|
||||
colorInt = ThemeUtils.getColor(this@MainActivity, android.R.attr.textColorPrimary)
|
||||
colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary)
|
||||
}
|
||||
setOnMenuItemClickListener {
|
||||
startActivity(SearchActivity.getIntent(this@MainActivity))
|
||||
|
@ -249,6 +258,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
binding.viewPager.reduceSwipeSensitivity()
|
||||
|
||||
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
|
||||
|
||||
/* Fetch user info while we're doing other things. This has to be done after setting up the
|
||||
|
@ -306,6 +317,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
1
|
||||
)
|
||||
}
|
||||
|
||||
// "Post failed" dialog should display in this activity
|
||||
draftsAlert.observeInContext(this, true)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -384,9 +398,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener {
|
||||
binding.mainDrawerLayout.open()
|
||||
}
|
||||
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
|
||||
|
||||
binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener)
|
||||
binding.topNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener)
|
||||
|
||||
header = AccountHeaderView(this).apply {
|
||||
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
|
||||
|
@ -407,7 +423,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
|
||||
header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent))
|
||||
header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent))
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
|
||||
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
|
||||
|
@ -503,8 +519,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
|
||||
}
|
||||
badgeStyle = BadgeStyle().apply {
|
||||
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
|
||||
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
|
||||
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary))
|
||||
color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary))
|
||||
}
|
||||
},
|
||||
DividerDrawerItem(),
|
||||
|
@ -556,27 +572,56 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Add a "Developer tools" entry. Code that makes it easier to
|
||||
// set the app state at runtime belongs here, it will never
|
||||
// be exposed to users.
|
||||
binding.mainDrawer.addItems(
|
||||
DividerDrawerItem(),
|
||||
secondaryDrawerItem {
|
||||
nameText = "debug"
|
||||
isEnabled = false
|
||||
textColor = ColorStateList.valueOf(Color.GREEN)
|
||||
nameText = "Developer tools"
|
||||
isEnabled = true
|
||||
iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
|
||||
onClick = {
|
||||
buildDeveloperToolsDialog().show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDeveloperToolsDialog(): AlertDialog {
|
||||
return AlertDialog.Builder(this)
|
||||
.setTitle("Developer Tools")
|
||||
.setItems(
|
||||
arrayOf("Create \"Load more\" gap")
|
||||
) { _, which ->
|
||||
Log.d(TAG, "Developer tools: $which")
|
||||
when (which) {
|
||||
0 -> {
|
||||
Log.d(TAG, "Creating \"Load more\" gap")
|
||||
lifecycleScope.launch {
|
||||
accountManager.activeAccount?.let {
|
||||
developerToolsUseCase.createLoadMoreGap(
|
||||
it.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.create()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState))
|
||||
}
|
||||
|
||||
private fun setupTabs(selectNotificationTab: Boolean) {
|
||||
|
||||
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
|
||||
val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize)
|
||||
val actionBarSize = getDimension(this, R.attr.actionBarSize)
|
||||
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
|
||||
(binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
|
||||
binding.tabLayout.hide()
|
||||
binding.topNav.hide()
|
||||
binding.bottomTabLayout
|
||||
} else {
|
||||
binding.bottomNav.hide()
|
||||
|
@ -612,7 +657,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
|
||||
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
|
||||
|
||||
onTabSelectedListener?.let {
|
||||
|
@ -749,71 +794,117 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
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) {
|
||||
if (hideTopToolbar) {
|
||||
val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom"
|
||||
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
val avatarView = if (navOnBottom) {
|
||||
binding.bottomNavAvatar.show()
|
||||
binding.bottomNavAvatar
|
||||
} else {
|
||||
binding.topNavAvatar.show()
|
||||
binding.topNavAvatar
|
||||
}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (animateAvatars) {
|
||||
Glide.with(this)
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
} else {
|
||||
Glide.with(this)
|
||||
.asBitmap()
|
||||
.load(avatarUrl)
|
||||
.placeholder(R.drawable.avatar_default)
|
||||
.into(avatarView)
|
||||
}
|
||||
} 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)
|
||||
binding.bottomNavAvatar.hide()
|
||||
binding.topNavAvatar.hide()
|
||||
|
||||
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>?) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(BitmapDrawable(resources, resource), navIconSize, navIconSize)
|
||||
}
|
||||
override fun onLoadStarted(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon =
|
||||
FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
if (placeholder != null) {
|
||||
binding.mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
|
||||
override fun onResourceReady(
|
||||
resource: Drawable,
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,9 +25,12 @@ import androidx.fragment.app.commit
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.TimelineFragment
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind
|
||||
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
|
@ -39,13 +42,22 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate)
|
||||
private lateinit var kind: Kind
|
||||
private var hashtag: String? = null
|
||||
private var followTagItem: 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?) {
|
||||
Log.d("StatusListActivity", "onCreate")
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
|
@ -89,10 +101,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
menuInflater.inflate(R.menu.view_hashtag_toolbar, menu)
|
||||
followTagItem = menu.findItem(R.id.action_follow_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
|
||||
unfollowTagItem?.isVisible = tagEntity.following == true
|
||||
followTagItem?.setOnMenuItemClickListener { followTag() }
|
||||
unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() }
|
||||
muteTagItem?.setOnMenuItemClickListener { muteTag() }
|
||||
unmuteTagItem?.setOnMenuItemClickListener { unmuteTag() }
|
||||
updateMuteTagMenuItems()
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to query tag #$tag", it)
|
||||
|
@ -144,6 +161,90 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
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
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -22,8 +22,9 @@ import androidx.work.WorkManager
|
|||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
|
@ -72,8 +73,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
ThemeUtils.setAppNightMode(theme)
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
setAppNightMode(theme)
|
||||
|
||||
localeManager.setLocale()
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
|
|||
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.fragment.ViewImageFragment
|
||||
import com.keylesspalace.tusky.fragment.ViewVideoFragment
|
||||
import com.keylesspalace.tusky.pager.ImagePagerAdapter
|
||||
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
|
||||
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
|
||||
|
@ -68,7 +69,7 @@ import java.util.Locale
|
|||
|
||||
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
|
||||
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener {
|
||||
class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener {
|
||||
|
||||
private val binding by viewBinding(ActivityViewMediaBinding::inflate)
|
||||
|
||||
|
|
|
@ -26,7 +26,8 @@ import com.keylesspalace.tusky.util.removeDuplicates
|
|||
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
|
||||
var accountActionListener: AccountActionListener,
|
||||
protected val animateAvatar: Boolean,
|
||||
protected val animateEmojis: Boolean
|
||||
protected val animateEmojis: Boolean,
|
||||
protected val showBotOverlay: Boolean
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
|
||||
var accountList = mutableListOf<TimelineAccount>()
|
||||
private var bottomLoading: Boolean = false
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,11 +31,13 @@ import com.keylesspalace.tusky.util.loadAvatar
|
|||
class BlocksAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean,
|
||||
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
) {
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
|
|
|
@ -17,6 +17,7 @@ package com.keylesspalace.tusky.adapter
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding
|
||||
|
@ -26,7 +27,8 @@ import java.util.Locale
|
|||
|
||||
class EmojiAdapter(
|
||||
emojiList: List<Emoji>,
|
||||
private val onEmojiSelectedListener: OnEmojiSelectedListener
|
||||
private val onEmojiSelectedListener: OnEmojiSelectedListener,
|
||||
private val animate: Boolean
|
||||
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
|
||||
|
||||
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
|
||||
|
@ -43,15 +45,23 @@ class EmojiAdapter(
|
|||
val emoji = emojiList[position]
|
||||
val emojiImageView = holder.binding.root
|
||||
|
||||
Glide.with(emojiImageView)
|
||||
.load(emoji.url)
|
||||
.into(emojiImageView)
|
||||
if (animate) {
|
||||
Glide.with(emojiImageView)
|
||||
.load(emoji.url)
|
||||
.into(emojiImageView)
|
||||
} else {
|
||||
Glide.with(emojiImageView)
|
||||
.asBitmap()
|
||||
.load(emoji.url)
|
||||
.into(emojiImageView)
|
||||
}
|
||||
|
||||
emojiImageView.setOnClickListener {
|
||||
onEmojiSelectedListener.onEmojiSelected(emoji.shortcode)
|
||||
}
|
||||
|
||||
emojiImageView.contentDescription = emoji.shortcode
|
||||
TooltipCompat.setTooltipText(emojiImageView, emoji.shortcode)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,23 +16,37 @@ package com.keylesspalace.tusky.adapter
|
|||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemAccountBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
|
||||
/** Displays either a follows or following list. */
|
||||
class FollowAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) : AccountAdapter<AccountViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<AccountViewHolder>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
) {
|
||||
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_account, parent, false)
|
||||
return AccountViewHolder(view)
|
||||
val binding = ItemAccountBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return AccountViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) {
|
||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
|
||||
viewHolder.setupWithAccount(
|
||||
accountList[position],
|
||||
animateAvatar,
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
)
|
||||
viewHolder.setupActionListener(accountActionListener)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,12 @@ class FollowRequestViewHolder(
|
|||
private val showHeader: Boolean
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) {
|
||||
val wrappedName = account.name.unicodeWrap()
|
||||
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
|
||||
binding.displayNameTextView.text = emojifiedName
|
||||
|
|
|
@ -23,8 +23,9 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
|
|||
class FollowRequestsAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) {
|
||||
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
|
||||
val binding = ItemFollowRequestBinding.inflate(
|
||||
LayoutInflater.from(parent.context), parent, false
|
||||
|
@ -33,7 +34,7 @@ class FollowRequestsAdapter(
|
|||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
|
||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
|
||||
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay)
|
||||
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,14 +21,15 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.util.getTuskyDisplayName
|
||||
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||
import java.util.Locale
|
||||
|
||||
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return (super.getView(position, convertView, parent) as TextView).apply {
|
||||
setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary))
|
||||
setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary))
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
text = super.getItem(position)?.modernLanguageCode?.uppercase()
|
||||
}
|
||||
|
@ -36,9 +37,8 @@ class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : Ar
|
|||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return (super.getDropDownView(position, convertView, parent) as TextView).apply {
|
||||
setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary))
|
||||
val locale = super.getItem(position)
|
||||
text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})"
|
||||
setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary))
|
||||
text = super.getItem(position)?.getTuskyDisplayName(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,14 @@
|
|||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.databinding.ItemMutedUserBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import java.util.HashMap
|
||||
|
||||
/**
|
||||
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
|
||||
|
@ -22,29 +17,68 @@ import java.util.HashMap
|
|||
class MutesAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) : AccountAdapter<MutesAdapter.MutedUserViewHolder>(
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
) : AccountAdapter<BindingHolder<ItemMutedUserBinding>>(
|
||||
accountActionListener,
|
||||
animateAvatar,
|
||||
animateEmojis
|
||||
animateEmojis,
|
||||
showBotOverlay
|
||||
) {
|
||||
private val mutingNotificationsMap = HashMap<String, Boolean>()
|
||||
|
||||
override fun createAccountViewHolder(parent: ViewGroup): MutedUserViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_muted_user, parent, false)
|
||||
return MutedUserViewHolder(view)
|
||||
override fun createAccountViewHolder(parent: ViewGroup): BindingHolder<ItemMutedUserBinding> {
|
||||
val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindAccountViewHolder(viewHolder: MutedUserViewHolder, position: Int) {
|
||||
override fun onBindAccountViewHolder(viewHolder: BindingHolder<ItemMutedUserBinding>, position: Int) {
|
||||
val account = accountList[position]
|
||||
viewHolder.setupWithAccount(
|
||||
account,
|
||||
mutingNotificationsMap[account.id],
|
||||
animateAvatar,
|
||||
animateEmojis
|
||||
)
|
||||
viewHolder.setupActionListener(accountActionListener)
|
||||
val binding = viewHolder.binding
|
||||
val context = binding.root.context
|
||||
|
||||
val mutingNotifications = mutingNotificationsMap[account.id]
|
||||
|
||||
val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis)
|
||||
binding.mutedUserDisplayName.text = emojifiedName
|
||||
|
||||
val formattedUsername = context.getString(R.string.post_username_format, account.username)
|
||||
binding.mutedUserUsername.text = formattedUsername
|
||||
|
||||
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar)
|
||||
|
||||
val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername)
|
||||
binding.mutedUserUnmute.contentDescription = unmuteString
|
||||
ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString)
|
||||
|
||||
binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null)
|
||||
|
||||
binding.mutedUserMuteNotifications.isChecked = if (mutingNotifications == null) {
|
||||
binding.mutedUserMuteNotifications.isEnabled = false
|
||||
true
|
||||
} else {
|
||||
binding.mutedUserMuteNotifications.isEnabled = true
|
||||
mutingNotifications
|
||||
}
|
||||
|
||||
binding.mutedUserUnmute.setOnClickListener {
|
||||
accountActionListener.onMute(
|
||||
false,
|
||||
account.id,
|
||||
viewHolder.bindingAdapterPosition,
|
||||
false
|
||||
)
|
||||
}
|
||||
binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked ->
|
||||
accountActionListener.onMute(
|
||||
true,
|
||||
account.id,
|
||||
viewHolder.bindingAdapterPosition,
|
||||
isChecked
|
||||
)
|
||||
}
|
||||
binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) }
|
||||
}
|
||||
|
||||
fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) {
|
||||
|
@ -52,81 +86,8 @@ class MutesAdapter(
|
|||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>?) {
|
||||
mutingNotificationsMap.putAll(newMutingNotificationsMap!!)
|
||||
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>) {
|
||||
mutingNotificationsMap.putAll(newMutingNotificationsMap)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
class MutedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val avatar: ImageView = itemView.findViewById(R.id.muted_user_avatar)
|
||||
private val username: TextView = itemView.findViewById(R.id.muted_user_username)
|
||||
private val displayName: TextView = itemView.findViewById(R.id.muted_user_display_name)
|
||||
private val unmute: ImageButton = itemView.findViewById(R.id.muted_user_unmute)
|
||||
private val muteNotifications: ImageButton =
|
||||
itemView.findViewById(R.id.muted_user_mute_notifications)
|
||||
|
||||
private var id: String? = null
|
||||
private var notifications = false
|
||||
|
||||
fun setupWithAccount(
|
||||
account: TimelineAccount,
|
||||
mutingNotifications: Boolean?,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean
|
||||
) {
|
||||
id = account.id
|
||||
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
|
||||
displayName.text = emojifiedName
|
||||
val format = username.context.getString(R.string.post_username_format)
|
||||
val formattedUsername = String.format(format, account.username)
|
||||
username.text = formattedUsername
|
||||
val avatarRadius = avatar.context.resources
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
|
||||
val unmuteString =
|
||||
unmute.context.getString(R.string.action_unmute_desc, formattedUsername)
|
||||
unmute.contentDescription = unmuteString
|
||||
ViewCompat.setTooltipText(unmute, unmuteString)
|
||||
if (mutingNotifications == null) {
|
||||
muteNotifications.isEnabled = false
|
||||
notifications = true
|
||||
} else {
|
||||
muteNotifications.isEnabled = true
|
||||
notifications = mutingNotifications
|
||||
}
|
||||
if (notifications) {
|
||||
muteNotifications.setImageResource(R.drawable.ic_notifications_24dp)
|
||||
val unmuteNotificationsString = muteNotifications.context
|
||||
.getString(R.string.action_unmute_notifications_desc, formattedUsername)
|
||||
muteNotifications.contentDescription = unmuteNotificationsString
|
||||
ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString)
|
||||
} else {
|
||||
muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp)
|
||||
val muteNotificationsString = muteNotifications.context
|
||||
.getString(R.string.action_mute_notifications_desc, formattedUsername)
|
||||
muteNotifications.contentDescription = muteNotificationsString
|
||||
ViewCompat.setTooltipText(muteNotifications, muteNotificationsString)
|
||||
}
|
||||
}
|
||||
|
||||
fun setupActionListener(listener: AccountActionListener) {
|
||||
unmute.setOnClickListener {
|
||||
listener.onMute(
|
||||
false,
|
||||
id,
|
||||
bindingAdapterPosition,
|
||||
false
|
||||
)
|
||||
}
|
||||
muteNotifications.setOnClickListener {
|
||||
listener.onMute(
|
||||
true,
|
||||
id,
|
||||
bindingAdapterPosition,
|
||||
!notifications
|
||||
)
|
||||
}
|
||||
itemView.setOnClickListener { listener.onViewAccount(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
import com.bumptech.glide.Glide;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
|
||||
import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
import com.keylesspalace.tusky.entity.Notification;
|
||||
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_REQUEST = 3;
|
||||
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[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
@ -137,6 +139,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
.inflate(R.layout.item_status_placeholder, parent, false);
|
||||
return new PlaceholderViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_REPORT: {
|
||||
ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false);
|
||||
return new ReportNotificationViewHolder(binding);
|
||||
}
|
||||
default:
|
||||
case VIEW_TYPE_UNKNOWN: {
|
||||
View view = new View(parent.getContext());
|
||||
|
@ -247,11 +253,18 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
case VIEW_TYPE_FOLLOW_REQUEST: {
|
||||
if (payloadForHolder == null) {
|
||||
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
|
||||
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
|
||||
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay());
|
||||
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
|
||||
}
|
||||
break;
|
||||
}
|
||||
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:
|
||||
}
|
||||
}
|
||||
|
@ -304,6 +317,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
case FOLLOW_REQUEST: {
|
||||
return VIEW_TYPE_FOLLOW_REQUEST;
|
||||
}
|
||||
case REPORT: {
|
||||
return VIEW_TYPE_REPORT;
|
||||
}
|
||||
default: {
|
||||
return VIEW_TYPE_UNKNOWN;
|
||||
}
|
||||
|
@ -322,6 +338,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
|
||||
void onViewStatusForNotificationId(String notificationId);
|
||||
|
||||
void onViewReport(String reportId);
|
||||
|
||||
void onExpandedChange(boolean expanded, int position);
|
||||
|
||||
/**
|
||||
|
@ -418,7 +436,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
timestampInfo = itemView.findViewById(R.id.status_timestamp_info);
|
||||
timestampInfo = itemView.findViewById(R.id.status_meta_info);
|
||||
statusContent = itemView.findViewById(R.id.notification_content);
|
||||
statusAvatar = itemView.findViewById(R.id.notification_status_avatar);
|
||||
notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar);
|
||||
|
|
|
@ -15,26 +15,52 @@
|
|||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
|
||||
import com.google.android.material.progressindicator.IndeterminateDrawable
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
|
||||
/**
|
||||
* Placeholder for different timelines.
|
||||
* Either displays "load more" button or a progress indicator.
|
||||
**/
|
||||
*
|
||||
* Displays a "Load more" button for a particular status ID, or a
|
||||
* circular progress wheel if the status' page is being loaded.
|
||||
*
|
||||
* The user can only have one "Load more" operation in progress at
|
||||
* a time (determined by the adapter), so the contents of the view
|
||||
* and the enabled state is driven by that.
|
||||
*/
|
||||
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val loadMoreButton: Button = itemView.findViewById(R.id.button_load_more)
|
||||
private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar)
|
||||
private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more)
|
||||
private val drawable = IndeterminateDrawable.createCircularDrawable(
|
||||
itemView.context,
|
||||
CircularProgressIndicatorSpec(itemView.context, null)
|
||||
)
|
||||
|
||||
fun setup(listener: StatusActionListener, progress: Boolean) {
|
||||
loadMoreButton.visibility = if (progress) View.GONE else View.VISIBLE
|
||||
progressBar.visibility = if (progress) View.VISIBLE else View.GONE
|
||||
loadMoreButton.isEnabled = true
|
||||
loadMoreButton.setOnClickListener { v: View? ->
|
||||
fun setup(listener: StatusActionListener, loading: Boolean) {
|
||||
itemView.isEnabled = !loading
|
||||
loadMoreButton.isEnabled = !loading
|
||||
|
||||
if (loading) {
|
||||
loadMoreButton.text = ""
|
||||
loadMoreButton.icon = drawable
|
||||
return
|
||||
}
|
||||
|
||||
loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text)
|
||||
loadMoreButton.icon = null
|
||||
|
||||
// To allow the user to click anywhere in the layout to load more content set the click
|
||||
// listener on the parent layout instead of loadMoreButton.
|
||||
//
|
||||
// See the comments in item_status_placeholder.xml for more details.
|
||||
itemView.setOnClickListener {
|
||||
itemView.isEnabled = false
|
||||
loadMoreButton.isEnabled = false
|
||||
loadMoreButton.icon = drawable
|
||||
loadMoreButton.text = ""
|
||||
listener.onLoadMore(bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
private var emojis: List<Emoji> = emptyList()
|
||||
private var resultClickListener: View.OnClickListener? = null
|
||||
private var animateEmojis = false
|
||||
private var enabled = true
|
||||
|
||||
@JvmOverloads
|
||||
fun setup(
|
||||
options: List<PollOptionViewData>,
|
||||
voteCount: Int,
|
||||
|
@ -46,7 +48,8 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
emojis: List<Emoji>,
|
||||
mode: Int,
|
||||
resultClickListener: View.OnClickListener?,
|
||||
animateEmojis: Boolean
|
||||
animateEmojis: Boolean,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
this.pollOptions = options
|
||||
this.voteCount = voteCount
|
||||
|
@ -55,6 +58,7 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
this.mode = mode
|
||||
this.resultClickListener = resultClickListener
|
||||
this.animateEmojis = animateEmojis
|
||||
this.enabled = enabled
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
@ -82,6 +86,9 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
|||
radioButton.visible(mode == SINGLE)
|
||||
checkBox.visible(mode == MULTIPLE)
|
||||
|
||||
radioButton.isEnabled = enabled
|
||||
checkBox.isEnabled = enabled
|
||||
|
||||
when (mode) {
|
||||
RESULT -> {
|
||||
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
|
@ -23,6 +25,7 @@ import androidx.appcompat.app.AlertDialog;
|
|||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
@ -30,6 +33,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.color.MaterialColors;
|
||||
import com.google.android.material.imageview.ShapeableImageView;
|
||||
import com.google.android.material.shape.CornerFamily;
|
||||
import com.google.android.material.shape.ShapeAppearanceModel;
|
||||
|
@ -50,9 +54,10 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.util.TouchDelegateHelper;
|
||||
import com.keylesspalace.tusky.view.MediaPreviewImageView;
|
||||
import com.keylesspalace.tusky.view.MediaPreviewLayout;
|
||||
import com.keylesspalace.tusky.viewdata.PollOptionViewData;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewData;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
|
@ -66,12 +71,11 @@ import at.connyduck.sparkbutton.SparkButton;
|
|||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
import kotlin.collections.CollectionsKt;
|
||||
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
||||
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||
public static class Key {
|
||||
public static final String KEY_CREATED = "created";
|
||||
}
|
||||
|
||||
private TextView displayName;
|
||||
private TextView username;
|
||||
private ImageButton replyButton;
|
||||
|
@ -81,8 +85,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private SparkButton bookmarkButton;
|
||||
private ImageButton moreButton;
|
||||
private ConstraintLayout mediaContainer;
|
||||
protected MediaPreviewImageView[] mediaPreviews;
|
||||
private ImageView[] mediaOverlays;
|
||||
protected MediaPreviewLayout mediaPreview;
|
||||
private TextView sensitiveMediaWarning;
|
||||
private View sensitiveMediaShow;
|
||||
protected TextView[] mediaLabels;
|
||||
|
@ -91,7 +94,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private ImageView avatarInset;
|
||||
|
||||
public ImageView avatar;
|
||||
public TextView timestampInfo;
|
||||
public TextView metaInfo;
|
||||
public TextView content;
|
||||
public TextView contentWarningDescription;
|
||||
|
||||
|
@ -120,7 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
timestampInfo = itemView.findViewById(R.id.status_timestamp_info);
|
||||
metaInfo = itemView.findViewById(R.id.status_meta_info);
|
||||
content = itemView.findViewById(R.id.status_content);
|
||||
avatar = itemView.findViewById(R.id.status_avatar);
|
||||
replyButton = itemView.findViewById(R.id.status_reply);
|
||||
|
@ -132,19 +135,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
mediaContainer = itemView.findViewById(R.id.status_media_preview_container);
|
||||
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);
|
||||
sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button);
|
||||
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.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) {
|
||||
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()) {
|
||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||
timestampText = absoluteTimeFormatter.format(createdAt, true);
|
||||
} else {
|
||||
if (createdAt == null) {
|
||||
timestampInfo.setText("?m");
|
||||
timestampText = "?m";
|
||||
} else {
|
||||
long then = createdAt.getTime();
|
||||
long now = System.currentTimeMillis();
|
||||
String readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now);
|
||||
timestampInfo.setText(readout);
|
||||
String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now);
|
||||
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,
|
||||
|
@ -427,14 +430,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
|
||||
} else {
|
||||
Focus focus = meta != null ? meta.getFocus() : null;
|
||||
|
||||
if (focus != null) { // If there is a focal point for this attachment:
|
||||
imageView.setFocalPoint(focus);
|
||||
|
||||
Glide.with(imageView)
|
||||
Glide.with(imageView.getContext())
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
|
@ -452,38 +454,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
protected void setMediaPreviews(final List<Attachment> attachments, boolean sensitive,
|
||||
final StatusActionListener listener, boolean showingContent,
|
||||
boolean useBlurhash) {
|
||||
Context context = itemView.getContext();
|
||||
final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS);
|
||||
protected void setMediaPreviews(
|
||||
final List<Attachment> attachments,
|
||||
boolean sensitive,
|
||||
final StatusActionListener listener,
|
||||
boolean showingContent,
|
||||
boolean useBlurhash
|
||||
) {
|
||||
|
||||
mediaPreview.setVisibility(View.VISIBLE);
|
||||
mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments));
|
||||
|
||||
final int mediaPreviewHeight = getMediaPreviewHeight(context);
|
||||
|
||||
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++) {
|
||||
mediaPreview.forEachIndexed((i, imageView, descriptionIndicator) -> {
|
||||
Attachment attachment = attachments.get(i);
|
||||
String previewUrl = attachment.getPreviewUrl();
|
||||
String description = attachment.getDescription();
|
||||
MediaPreviewImageView imageView = mediaPreviews[i];
|
||||
boolean hasDescription = !TextUtils.isEmpty(description);
|
||||
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
imageView.setContentDescription(imageView.getContext()
|
||||
.getString(R.string.action_view_media));
|
||||
} else {
|
||||
if (hasDescription) {
|
||||
imageView.setContentDescription(description);
|
||||
} else {
|
||||
imageView.setContentDescription(imageView.getContext().getString(R.string.action_view_media));
|
||||
}
|
||||
|
||||
loadImage(
|
||||
|
@ -495,42 +486,43 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
final Attachment.Type type = attachment.getType();
|
||||
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 {
|
||||
mediaOverlays[i].setVisibility(View.GONE);
|
||||
imageView.setForeground(null);
|
||||
}
|
||||
|
||||
setAttachmentClickListener(imageView, listener, i, attachment, true);
|
||||
}
|
||||
|
||||
if (sensitive) {
|
||||
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
|
||||
} else {
|
||||
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());
|
||||
if (sensitive) {
|
||||
sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
|
||||
} else {
|
||||
sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
|
||||
}
|
||||
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.
|
||||
for (int i = n; i < Status.MAX_MEDIA_ATTACHMENTS; i++) {
|
||||
mediaPreviews[i].setVisibility(View.GONE);
|
||||
}
|
||||
descriptionIndicator.setVisibility(hasDescription && showingContent ? View.VISIBLE : View.GONE);
|
||||
|
||||
sensitiveMediaShow.setOnClickListener(v -> {
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
listener.onContentHiddenChange(false, getBindingAdapterPosition());
|
||||
}
|
||||
v.setVisibility(View.GONE);
|
||||
sensitiveMediaWarning.setVisibility(View.VISIBLE);
|
||||
descriptionIndicator.setVisibility(View.GONE);
|
||||
});
|
||||
sensitiveMediaWarning.setOnClickListener(v -> {
|
||||
if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
listener.onContentHiddenChange(true, getBindingAdapterPosition());
|
||||
}
|
||||
v.setVisibility(View.GONE);
|
||||
sensitiveMediaShow.setVisibility(View.VISIBLE);
|
||||
descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE);
|
||||
});
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
|
@ -728,7 +720,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
Status actionable = status.getActionable();
|
||||
setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
|
||||
setUsername(status.getUsername());
|
||||
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
setIsReply(actionable.getInReplyToId() != null);
|
||||
setReplyCount(actionable.getRepliesCount());
|
||||
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
|
||||
|
@ -751,10 +743,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, status.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
mediaPreview.setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
|
||||
|
@ -783,7 +772,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (payloads instanceof List)
|
||||
for (Object item : (List<?>) payloads) {
|
||||
if (Key.KEY_CREATED.equals(item)) {
|
||||
setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions);
|
||||
setMetaData(status, statusDisplayOptions, listener);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -809,6 +798,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
getContentWarningDescription(context, status),
|
||||
(TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
|
||||
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
|
||||
actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "",
|
||||
getReblogDescription(context, status),
|
||||
status.getUsername(),
|
||||
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) {
|
||||
return "";
|
||||
|
@ -1153,7 +1143,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
avatarInset.setVisibility(visibility);
|
||||
displayName.setVisibility(visibility);
|
||||
username.setVisibility(visibility);
|
||||
timestampInfo.setVisibility(visibility);
|
||||
metaInfo.setVisibility(visibility);
|
||||
contentWarningDescription.setVisibility(visibility);
|
||||
contentWarningButton.setVisibility(visibility);
|
||||
content.setVisibility(visibility);
|
||||
|
|
|
@ -2,12 +2,18 @@ package com.keylesspalace.tusky.adapter;
|
|||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.DynamicDrawableSpan;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
|
@ -15,6 +21,7 @@ import com.keylesspalace.tusky.entity.Status;
|
|||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.NoUnderlineURLSpan;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
|
@ -26,6 +33,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
private final TextView favourites;
|
||||
private final View infoDivider;
|
||||
|
||||
private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
|
||||
|
||||
public StatusDetailedViewHolder(View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
|
@ -34,18 +43,74 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected int getMediaPreviewHeight(Context context) {
|
||||
return context.getResources().getDimensionPixelSize(R.dimen.status_detail_media_preview_height);
|
||||
}
|
||||
protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
|
||||
@Override
|
||||
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) {
|
||||
if (createdAt == null) {
|
||||
timestampInfo.setText("");
|
||||
} else {
|
||||
DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT);
|
||||
timestampInfo.setText(dateFormat.format(createdAt));
|
||||
Status status = statusViewData.getActionable();
|
||||
|
||||
Status.Visibility visibility = status.getVisibility();
|
||||
Context context = metaInfo.getContext();
|
||||
|
||||
Drawable visibilityIcon = getVisibilityIcon(visibility);
|
||||
CharSequence visibilityString = getVisibilityDescription(context, visibility);
|
||||
|
||||
SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString);
|
||||
|
||||
if (visibilityIcon != null) {
|
||||
ImageSpan visibilityIconSpan = new ImageSpan(
|
||||
visibilityIcon,
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE
|
||||
);
|
||||
sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
String metadataJoiner = context.getString(R.string.metadata_joiner);
|
||||
|
||||
Date createdAt = status.getCreatedAt();
|
||||
if (createdAt != null) {
|
||||
|
||||
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) {
|
||||
|
@ -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
|
||||
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
|
||||
@NonNull final StatusActionListener listener,
|
||||
|
@ -105,8 +155,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
@Nullable Object payloads) {
|
||||
// We never collapse statuses in the detail view
|
||||
StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
status.copyWithCollapsed(false) :
|
||||
status;
|
||||
|
||||
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
|
||||
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
|
@ -119,17 +169,13 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
} else {
|
||||
hideQuantitativeStats();
|
||||
}
|
||||
|
||||
setApplication(actionable.getApplication());
|
||||
|
||||
setStatusVisibility(actionable.getVisibility());
|
||||
}
|
||||
}
|
||||
|
||||
private void setStatusVisibility(Status.Visibility visibility) {
|
||||
private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) {
|
||||
|
||||
if (visibility == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
int visibilityIcon;
|
||||
|
@ -147,29 +193,26 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
visibilityIcon = R.drawable.ic_email_24dp;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
final Drawable visibilityDrawable = this.timestampInfo.getContext()
|
||||
.getDrawable(visibilityIcon);
|
||||
final Drawable visibilityDrawable = AppCompatResources.getDrawable(
|
||||
this.metaInfo.getContext(), visibilityIcon
|
||||
);
|
||||
if (visibilityDrawable == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
final int size = (int) this.timestampInfo.getTextSize();
|
||||
final int size = (int) this.metaInfo.getTextSize();
|
||||
visibilityDrawable.setBounds(
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size
|
||||
);
|
||||
visibilityDrawable.setTint(this.timestampInfo.getCurrentTextColor());
|
||||
this.timestampInfo.setCompoundDrawables(
|
||||
visibilityDrawable,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor());
|
||||
|
||||
return visibilityDrawable;
|
||||
}
|
||||
|
||||
private void hideQuantitativeStats() {
|
||||
|
|
|
@ -53,11 +53,6 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
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
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull final StatusActionListener listener,
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewGroup
|
||||
|
@ -30,8 +29,8 @@ import com.keylesspalace.tusky.TabData
|
|||
import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemTabPreferenceSmallBinding
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.setDrawableTint
|
||||
import com.keylesspalace.tusky.util.show
|
||||
|
||||
interface ItemInteractionListener {
|
||||
|
@ -101,7 +100,7 @@ class TabAdapter(
|
|||
listener.onTabRemoved(holder.bindingAdapterPosition)
|
||||
}
|
||||
binding.removeButton.isEnabled = removeButtonEnabled
|
||||
ThemeUtils.setDrawableTint(
|
||||
setDrawableTint(
|
||||
holder.itemView.context,
|
||||
binding.removeButton.drawable,
|
||||
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled)
|
||||
|
@ -119,17 +118,18 @@ class TabAdapter(
|
|||
|
||||
val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
|
||||
?: Chip(context).apply {
|
||||
setCloseIconResource(R.drawable.ic_cancel_24dp)
|
||||
isCheckable = false
|
||||
binding.chipGroup.addView(this, binding.chipGroup.size - 1)
|
||||
chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary))
|
||||
}
|
||||
|
||||
chip.text = arg
|
||||
|
||||
if (tab.arguments.size <= 1) {
|
||||
chip.chipIcon = null
|
||||
chip.isCloseIconVisible = false
|
||||
chip.setOnClickListener(null)
|
||||
} else {
|
||||
chip.setChipIconResource(R.drawable.ic_cancel_24dp)
|
||||
chip.isCloseIconVisible = true
|
||||
chip.setOnClickListener {
|
||||
listener.onChipClicked(tab, holder.bindingAdapterPosition, i)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
package com.keylesspalace.tusky.components.account
|
||||
|
||||
import android.animation.ArgbEvaluator
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
|
@ -41,6 +43,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
|
@ -53,10 +56,12 @@ import com.keylesspalace.tusky.EditProfileActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
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.report.ReportActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
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.Loading
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getDomain
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
@ -95,6 +100,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
@Inject
|
||||
lateinit var draftsAlert: DraftsAlert
|
||||
|
||||
private val viewModel: AccountViewModel by viewModels { viewModelFactory }
|
||||
|
||||
|
@ -102,6 +109,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
private lateinit var accountFieldAdapter: AccountFieldAdapter
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
private var followState: FollowState = FollowState.NOT_FOLLOWING
|
||||
private var blocking: Boolean = false
|
||||
private var muting: Boolean = false
|
||||
|
@ -169,9 +178,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
* Load colors and dimensions from resources
|
||||
*/
|
||||
private fun loadResources() {
|
||||
toolbarColor = ThemeUtils.getColor(this, R.attr.colorSurface)
|
||||
toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK)
|
||||
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
|
||||
statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark)
|
||||
statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK)
|
||||
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
|
||||
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
|
||||
}
|
||||
|
@ -230,6 +239,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
// Setup the tabs and timeline pager.
|
||||
adapter = AccountPagerAdapter(this, viewModel.accountId)
|
||||
|
||||
binding.accountFragmentViewPager.reduceSwipeSensitivity()
|
||||
binding.accountFragmentViewPager.adapter = adapter
|
||||
binding.accountFragmentViewPager.offscreenPageLimit = 2
|
||||
|
||||
|
@ -242,6 +252,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
|
||||
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 {
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) {
|
||||
tab?.position?.let { position ->
|
||||
|
@ -312,7 +325,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
|
||||
if (hideFab && !viewModel.isSelf && !blocking) {
|
||||
if (hideFab && !blocking) {
|
||||
if (verticalOffset > oldOffset) {
|
||||
binding.accountFloatingActionButton.show()
|
||||
}
|
||||
|
@ -376,6 +389,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
viewModel.noteSaved.observe(this) {
|
||||
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.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
|
||||
|
||||
// Long press on username to copy it to clipboard
|
||||
for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) {
|
||||
view.setOnLongClickListener {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
val fullUsername = getFullUsername(loadedAccount)
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername))
|
||||
Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
|
||||
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||
|
||||
|
@ -553,6 +583,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
}
|
||||
updateFollowButton()
|
||||
updateSubscribeButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -626,7 +657,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
binding.accountFollowButton.setText(R.string.action_unfollow)
|
||||
}
|
||||
}
|
||||
updateSubscribeButton()
|
||||
}
|
||||
|
||||
private fun updateMuteButton() {
|
||||
|
@ -658,17 +688,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
binding.accountFollowButton.show()
|
||||
updateFollowButton()
|
||||
updateSubscribeButton()
|
||||
|
||||
if (blocking || viewModel.isSelf) {
|
||||
if (blocking) {
|
||||
binding.accountFloatingActionButton.hide()
|
||||
binding.accountMuteButton.hide()
|
||||
binding.accountSubscribeButton.hide()
|
||||
} else {
|
||||
binding.accountFloatingActionButton.show()
|
||||
if (muting)
|
||||
binding.accountMuteButton.show()
|
||||
else
|
||||
binding.accountMuteButton.hide()
|
||||
binding.accountMuteButton.visible(muting)
|
||||
updateMuteButton()
|
||||
}
|
||||
} else {
|
||||
|
@ -706,9 +733,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
getString(R.string.action_mute)
|
||||
}
|
||||
|
||||
if (loadedAccount != null) {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
val muteDomain = menu.findItem(R.id.action_mute_domain)
|
||||
domain = getDomain(loadedAccount?.url)
|
||||
domain = getDomain(loadedAccount.url)
|
||||
if (domain.isEmpty()) {
|
||||
// If we can't get the domain, there's no way we can mute it anyway...
|
||||
menu.removeItem(R.id.action_mute_domain)
|
||||
|
@ -740,6 +767,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
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)
|
||||
}
|
||||
|
||||
|
@ -800,10 +831,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
private fun mention() {
|
||||
loadedAccount?.let {
|
||||
val intent = ComposeActivity.startIntent(
|
||||
this,
|
||||
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))
|
||||
)
|
||||
val options = if (viewModel.isSelf) {
|
||||
ComposeActivity.ComposeOptions(kind = ComposeActivity.ComposeKind.NEW)
|
||||
} else {
|
||||
ComposeActivity.ComposeOptions(
|
||||
mentionedUsernames = setOf(it.username),
|
||||
kind = ComposeActivity.ComposeKind.NEW
|
||||
)
|
||||
}
|
||||
val intent = ComposeActivity.startIntent(this, options)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
@ -827,23 +863,47 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
when (item.itemId) {
|
||||
R.id.action_open_in_web -> {
|
||||
// If the account isn't loaded yet, eat the input.
|
||||
if (loadedAccount?.url != null) {
|
||||
openLink(loadedAccount!!.url)
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
openLink(loadedAccount.url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.action_open_as -> {
|
||||
if (loadedAccount != null) {
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
showAccountChooserDialog(
|
||||
item.title, false,
|
||||
object : AccountSelectionListener {
|
||||
override fun onAccountSelected(account: AccountEntity) {
|
||||
openAsAccount(loadedAccount!!.url, account)
|
||||
openAsAccount(loadedAccount.url, account)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
R.id.action_share_account_link -> {
|
||||
// If the account isn't loaded yet, eat the input.
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
val url = loadedAccount.url
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, url)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.action_share_account_username -> {
|
||||
// If the account isn't loaded yet, eat the input.
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
val fullUsername = getFullUsername(loadedAccount)
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername)
|
||||
sendIntent.type = "text/plain"
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
R.id.action_block -> {
|
||||
toggleBlock()
|
||||
return true
|
||||
|
@ -852,6 +912,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
toggleMute()
|
||||
return true
|
||||
}
|
||||
R.id.action_add_or_remove_from_list -> {
|
||||
ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null)
|
||||
return true
|
||||
}
|
||||
R.id.action_mute_domain -> {
|
||||
toggleBlockDomain(domain)
|
||||
return true
|
||||
|
@ -861,8 +925,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
return true
|
||||
}
|
||||
R.id.action_report -> {
|
||||
if (loadedAccount != null) {
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username))
|
||||
loadedAccount?.let { loadedAccount ->
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
@ -871,11 +935,22 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
|
||||
override fun getActionButton(): FloatingActionButton? {
|
||||
return if (!viewModel.isSelf && !blocking) {
|
||||
return if (!blocking) {
|
||||
binding.accountFloatingActionButton
|
||||
} else null
|
||||
}
|
||||
|
||||
private fun getFullUsername(account: Account): String {
|
||||
if (account.isRemote()) {
|
||||
return "@" + account.username
|
||||
} else {
|
||||
val localUsername = account.localUsername
|
||||
// Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible.
|
||||
val domain = accountManager.activeAccount!!.domain
|
||||
return "@$localUsername@$domain"
|
||||
}
|
||||
}
|
||||
|
||||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.keylesspalace.tusky.components.account
|
|||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
|
@ -19,6 +20,7 @@ import com.keylesspalace.tusky.util.RxAwareViewModel
|
|||
import com.keylesspalace.tusky.util.Success
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
@ -181,7 +183,11 @@ class AccountViewModel @Inject constructor(
|
|||
/**
|
||||
* @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE
|
||||
*/
|
||||
private fun changeRelationship(relationshipAction: RelationShipAction, parameter: Boolean? = null, duration: Int? = null) {
|
||||
private fun changeRelationship(
|
||||
relationshipAction: RelationShipAction,
|
||||
parameter: Boolean? = null,
|
||||
duration: Int? = null
|
||||
) = viewModelScope.launch {
|
||||
val relation = relationshipData.value?.data
|
||||
val account = accountData.value?.data
|
||||
val isMastodon = relationshipData.value?.data?.notifying != null
|
||||
|
@ -216,40 +222,45 @@ class AccountViewModel @Inject constructor(
|
|||
relationshipData.postValue(Loading(newRelation))
|
||||
}
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, showReblogs = parameter ?: true)
|
||||
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
||||
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
||||
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
||||
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true, duration)
|
||||
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
||||
RelationShipAction.SUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = true)
|
||||
else mastodonApi.subscribeAccount(accountId)
|
||||
}
|
||||
RelationShipAction.UNSUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = false)
|
||||
else mastodonApi.unsubscribeAccount(accountId)
|
||||
}
|
||||
}.subscribe(
|
||||
{ relationship ->
|
||||
relationshipData.postValue(Success(relationship))
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
||||
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
||||
else -> {
|
||||
}
|
||||
try {
|
||||
val relationship = when (relationshipAction) {
|
||||
RelationShipAction.FOLLOW -> mastodonApi.followAccount(
|
||||
accountId,
|
||||
showReblogs = parameter ?: true
|
||||
)
|
||||
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
|
||||
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
|
||||
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
|
||||
RelationShipAction.MUTE -> mastodonApi.muteAccount(
|
||||
accountId,
|
||||
parameter ?: true,
|
||||
duration
|
||||
)
|
||||
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
|
||||
RelationShipAction.SUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = true)
|
||||
else mastodonApi.subscribeAccount(accountId)
|
||||
}
|
||||
RelationShipAction.UNSUBSCRIBE -> {
|
||||
if (isMastodon)
|
||||
mastodonApi.followAccount(accountId, notify = false)
|
||||
else mastodonApi.unsubscribeAccount(accountId)
|
||||
}
|
||||
},
|
||||
{
|
||||
relationshipData.postValue(Error(relation))
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
|
||||
relationshipData.postValue(Success(relationship))
|
||||
|
||||
when (relationshipAction) {
|
||||
RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId))
|
||||
RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId))
|
||||
RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId))
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
relationshipData.postValue(Error(relation))
|
||||
}
|
||||
}
|
||||
|
||||
fun noteChanged(newNote: String) {
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,11 +11,11 @@ import androidx.core.view.setPadding
|
|||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.decodeBlurHash
|
||||
import com.keylesspalace.tusky.util.getFormattedDescription
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
|
@ -40,7 +40,7 @@ class AccountMediaGridAdapter(
|
|||
}
|
||||
) {
|
||||
|
||||
private val baseItemBackgroundColor = ThemeUtils.getColor(context, R.attr.colorSurface)
|
||||
private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)
|
||||
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
|
||||
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
|
||||
|
||||
|
|
|
@ -122,7 +122,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
|
|||
}
|
||||
|
||||
viewModel.emojis.observe(this) {
|
||||
picker.adapter = EmojiAdapter(it, this)
|
||||
picker.adapter = EmojiAdapter(it, this, animateEmojis)
|
||||
}
|
||||
|
||||
viewModel.load()
|
||||
|
|
|
@ -30,6 +30,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
|
@ -47,11 +48,9 @@ import androidx.annotation.ColorInt
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.view.ContentInfoCompat
|
||||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.isGone
|
||||
|
@ -64,6 +63,7 @@ import com.canhub.cropper.CropImage
|
|||
import com.canhub.cropper.CropImageContract
|
||||
import com.canhub.cropper.options
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
|
@ -87,15 +87,18 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.afterTextChanged
|
||||
import com.keylesspalace.tusky.util.getInitialLanguage
|
||||
import com.keylesspalace.tusky.util.getLocaleList
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.highlightSpans
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.modernLanguageCode
|
||||
import com.keylesspalace.tusky.util.onTextChanged
|
||||
import com.keylesspalace.tusky.util.setDrawableTint
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
@ -134,10 +137,10 @@ class ComposeActivity :
|
|||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
// this only exists when a status is trying to be sent, but uploads are still occurring
|
||||
private var finishingUploadDialog: ProgressDialog? = null
|
||||
private var photoUploadUri: Uri? = null
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
||||
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
|
||||
|
@ -185,7 +188,7 @@ class ComposeActivity :
|
|||
Log.w("ComposeActivity", "Edit image cancelled by user")
|
||||
} else {
|
||||
Log.w("ComposeActivity", "Edit image failed: " + result.error)
|
||||
displayTransientError(R.string.error_image_edit_failed)
|
||||
displayTransientMessage(R.string.error_image_edit_failed)
|
||||
}
|
||||
viewModel.cropImageItemOld = null
|
||||
}
|
||||
|
@ -205,8 +208,7 @@ class ComposeActivity :
|
|||
accountManager.setActiveAccount(accountId)
|
||||
}
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
|
@ -216,7 +218,7 @@ class ComposeActivity :
|
|||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(preferences, activeAccount)
|
||||
setupAvatar(activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
|
@ -236,15 +238,14 @@ class ComposeActivity :
|
|||
binding.composeMediaPreviewBar.adapter = mediaAdapter
|
||||
binding.composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
setupButtons()
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
|
||||
|
||||
viewModel.setup(composeOptions)
|
||||
|
||||
setupButtons()
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
|
||||
if (accountManager.shouldDisplaySelfUsername(this)) {
|
||||
binding.composeUsernameView.text = getString(
|
||||
R.string.compose_active_account_description,
|
||||
|
@ -265,7 +266,7 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupLanguageSpinner(getInitialLanguage(composeOptions?.language))
|
||||
setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount))
|
||||
setupComposeField(preferences, viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
|
@ -342,7 +343,7 @@ class ComposeActivity :
|
|||
binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 }
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
|
||||
binding.composeReplyView.setOnClickListener {
|
||||
|
@ -355,7 +356,7 @@ class ComposeActivity :
|
|||
binding.composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 }
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
}
|
||||
}
|
||||
|
@ -468,9 +469,9 @@ class ComposeActivity :
|
|||
lifecycleScope.launch {
|
||||
viewModel.uploadError.collect { throwable ->
|
||||
if (throwable is UploadServerError) {
|
||||
displayTransientError(throwable.errorMessage)
|
||||
displayTransientMessage(throwable.errorMessage)
|
||||
} else {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
displayTransientMessage(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -498,8 +499,11 @@ class ComposeActivity :
|
|||
binding.composeScheduleView.setListener(this)
|
||||
binding.atButton.setOnClickListener { atButtonClicked() }
|
||||
binding.hashButton.setOnClickListener { hashButtonClicked() }
|
||||
binding.descriptionMissingWarningButton.setOnClickListener {
|
||||
displayTransientMessage(R.string.hint_media_description_missing)
|
||||
}
|
||||
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
|
@ -510,6 +514,8 @@ class ComposeActivity :
|
|||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 }
|
||||
binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
|
||||
binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null)
|
||||
|
||||
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
|
@ -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) {
|
||||
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 {
|
||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode
|
||||
|
@ -594,26 +553,11 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
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 a = obtainStyledAttributes(null, actionBarSizeAttr)
|
||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
||||
|
@ -714,15 +658,15 @@ class ComposeActivity :
|
|||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(errorMessage: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG)
|
||||
private fun displayTransientMessage(message: String) {
|
||||
val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG)
|
||||
// necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.setAnchorView(R.id.composeBottomBar)
|
||||
bar.show()
|
||||
}
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
displayTransientError(getString(stringId))
|
||||
private fun displayTransientMessage(@StringRes stringId: Int) {
|
||||
displayTransientMessage(getString(stringId))
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
|
@ -732,6 +676,7 @@ class ComposeActivity :
|
|||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
if (viewModel.media.value.isEmpty()) {
|
||||
binding.composeHideMediaButton.hide()
|
||||
binding.descriptionMissingWarningButton.hide()
|
||||
} else {
|
||||
binding.composeHideMediaButton.show()
|
||||
@ColorInt val color = if (contentWarningShown) {
|
||||
|
@ -745,28 +690,42 @@ class ComposeActivity :
|
|||
getColor(R.color.chinwag_green)
|
||||
} else {
|
||||
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary)
|
||||
}
|
||||
}
|
||||
binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
var oneMediaWithoutDescription = false
|
||||
for (media in viewModel.media.value) {
|
||||
if (media.description == null || media.description.isEmpty()) {
|
||||
oneMediaWithoutDescription = true
|
||||
break
|
||||
}
|
||||
}
|
||||
binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScheduleButton() {
|
||||
@ColorInt val color = if (binding.composeScheduleView.time == null) {
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
if (viewModel.editing) {
|
||||
// Can't reschedule a published status
|
||||
enableButton(binding.composeScheduleButton, clickable = false, colorActive = false)
|
||||
} else {
|
||||
getColor(R.color.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.composeToggleVisibilityButton.isClickable = enable
|
||||
binding.composeToggleVisibilityButton.isClickable = enable && !editing
|
||||
binding.composeEmojiButton.isClickable = enable
|
||||
binding.composeHideMediaButton.isClickable = enable
|
||||
binding.composeScheduleButton.isClickable = enable
|
||||
binding.composeScheduleButton.isClickable = enable && !editing
|
||||
binding.composeTootButton.isEnabled = enable
|
||||
}
|
||||
|
||||
|
@ -782,6 +741,10 @@ class ComposeActivity :
|
|||
else -> R.drawable.ic_lock_open_24dp
|
||||
}
|
||||
binding.composeToggleVisibilityButton.setImageResource(iconRes)
|
||||
if (viewModel.editing) {
|
||||
// Can't update visibility on published status
|
||||
enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showComposeOptions() {
|
||||
|
@ -818,7 +781,7 @@ class ComposeActivity :
|
|||
binding.emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
displayTransientMessage(errorMessage)
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
|
@ -945,7 +908,7 @@ class ComposeActivity :
|
|||
val textColor = if (remainingLength < 0) {
|
||||
getColor(R.color.tusky_red)
|
||||
} else {
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary)
|
||||
}
|
||||
binding.composeCharactersLeftView.setTextColor(textColor)
|
||||
}
|
||||
|
@ -983,7 +946,7 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun sendStatus() {
|
||||
enableButtons(false)
|
||||
enableButtons(false, viewModel.editing)
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
if (viewModel.showContentWarning.value) {
|
||||
|
@ -992,23 +955,16 @@ class ComposeActivity :
|
|||
val characterCount = calculateTextLength()
|
||||
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
|
||||
binding.composeEditField.error = getString(R.string.error_empty)
|
||||
enableButtons(true)
|
||||
enableButtons(true, viewModel.editing)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
if (viewModel.media.value.isNotEmpty()) {
|
||||
finishingUploadDialog = ProgressDialog.show(
|
||||
this, getString(R.string.dialog_title_finishing_media_upload),
|
||||
getString(R.string.dialog_message_uploading_media), true, true
|
||||
)
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
viewModel.sendStatus(contentText, spoilerText)
|
||||
finishingUploadDialog?.dismiss()
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
} else {
|
||||
binding.composeEditField.error = getString(R.string.error_compose_character_limit)
|
||||
enableButtons(true)
|
||||
enableButtons(true, viewModel.editing)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1038,7 +994,7 @@ class ComposeActivity :
|
|||
val photoFile: File = try {
|
||||
createNewImageFile(this)
|
||||
} catch (ex: IOException) {
|
||||
displayTransientError(R.string.error_media_upload_opening)
|
||||
displayTransientMessage(R.string.error_media_upload_opening)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1053,7 +1009,7 @@ class ComposeActivity :
|
|||
|
||||
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
||||
button.isEnabled = clickable
|
||||
ThemeUtils.setDrawableTint(
|
||||
setDrawableTint(
|
||||
this, button.drawable,
|
||||
if (colorActive) android.R.attr.textColorTertiary
|
||||
else R.attr.textColorDisabled
|
||||
|
@ -1062,8 +1018,8 @@ class ComposeActivity :
|
|||
|
||||
private fun enablePollButton(enable: Boolean) {
|
||||
binding.addPollTextActionTextView.isEnabled = enable
|
||||
val textColor = ThemeUtils.getColor(
|
||||
this,
|
||||
val textColor = MaterialColors.getColor(
|
||||
binding.addPollTextActionTextView,
|
||||
if (enable) android.R.attr.textColorTertiary
|
||||
else R.attr.textColorDisabled
|
||||
)
|
||||
|
@ -1108,7 +1064,7 @@ class ComposeActivity :
|
|||
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
|
||||
else -> getString(R.string.error_media_upload_opening)
|
||||
}
|
||||
displayTransientError(errorString)
|
||||
displayTransientMessage(errorString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1123,7 +1079,7 @@ class ComposeActivity :
|
|||
} else {
|
||||
binding.composeContentWarningBar.hide()
|
||||
binding.composeEditField.requestFocus()
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary)
|
||||
}
|
||||
binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
@ -1138,7 +1094,6 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
Log.d(TAG, event.toString())
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (event.isCtrlPressed) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
|
@ -1160,25 +1115,79 @@ class ComposeActivity :
|
|||
val contentText = binding.composeEditField.text.toString()
|
||||
val contentWarning = binding.composeContentWarningField.text.toString()
|
||||
if (viewModel.didChange(contentText, contentWarning)) {
|
||||
|
||||
val warning = if (!viewModel.media.value.isEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
R.string.compose_save_draft
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(warning)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
}
|
||||
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
|
||||
.show()
|
||||
when (viewModel.composeKind) {
|
||||
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
|
||||
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
|
||||
}.show()
|
||||
} else {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User is editing a new post, and can either save the changes as a draft or discard them.
|
||||
*/
|
||||
private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
R.string.compose_save_draft
|
||||
}
|
||||
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(warning)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
}
|
||||
.setNegativeButton(R.string.action_delete) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User is editing an existing draft, and can either update the draft with the new changes or
|
||||
* discard them.
|
||||
*/
|
||||
private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder {
|
||||
val warning = if (viewModel.media.value.isNotEmpty()) {
|
||||
R.string.compose_save_draft_loses_media
|
||||
} else {
|
||||
R.string.compose_save_draft
|
||||
}
|
||||
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(warning)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User is editing a post (scheduled, or posted), and can either go back to editing, or
|
||||
* discard the changes.
|
||||
*/
|
||||
private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder {
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(R.string.compose_unsaved_changes)
|
||||
.setPositiveButton(R.string.action_continue_edit) { _, _ ->
|
||||
// Do nothing, dialog will dismiss, user can continue editing
|
||||
}
|
||||
.setNegativeButton(R.string.action_discard) { _, _ ->
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
|
@ -1210,7 +1219,8 @@ class ComposeActivity :
|
|||
|
||||
private fun setEmojiList(emojiList: List<Emoji>?) {
|
||||
if (emojiList != null) {
|
||||
binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity)
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity, animateEmojis)
|
||||
enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
@ -1223,14 +1233,18 @@ class ComposeActivity :
|
|||
val uploadPercent: Int = 0,
|
||||
val id: String? = null,
|
||||
val description: String? = null,
|
||||
val focus: Attachment.Focus? = null
|
||||
val focus: Attachment.Focus? = null,
|
||||
val state: State
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO, AUDIO;
|
||||
}
|
||||
enum class State {
|
||||
UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(time: String) {
|
||||
override fun onTimeSet(time: String?) {
|
||||
viewModel.updateScheduledAt(time)
|
||||
if (verifyScheduledTime()) {
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
@ -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
|
||||
data class ComposeOptions(
|
||||
// Let's keep fields var until all consumers are Kotlin
|
||||
|
@ -1274,6 +1306,8 @@ class ComposeActivity :
|
|||
var poll: NewPoll? = null,
|
||||
var modifiedInitialState: Boolean? = null,
|
||||
var language: String? = null,
|
||||
var statusId: String? = null,
|
||||
var kind: ComposeKind? = null
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -33,20 +33,18 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.MediaToSend
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.StatusToSend
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
@ -73,6 +71,7 @@ class ComposeViewModel @Inject constructor(
|
|||
private var scheduledTootId: String? = null
|
||||
private var startingContentWarning: String = ""
|
||||
private var inReplyToId: String? = null
|
||||
private var originalStatusId: String? = null
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
|
||||
private var contentWarningStateChanged: Boolean = false
|
||||
|
@ -96,7 +95,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
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
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
@ -133,17 +132,18 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
media.updateAndGet { mediaValue ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
type = type,
|
||||
mediaSize = mediaSize,
|
||||
description = description,
|
||||
focus = focus
|
||||
focus = focus,
|
||||
state = QueuedMedia.State.UPLOADING
|
||||
)
|
||||
stashMediaItem = mediaItem
|
||||
|
||||
if (replaceItem != null) {
|
||||
mediaToJob[replaceItem.localId]?.cancel()
|
||||
mediaUploader.cancelUploadScope(replaceItem.localId)
|
||||
mediaValue.map {
|
||||
if (it.localId == replaceItem.localId) mediaItem else it
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem, instanceInfo.first())
|
||||
.catch { error ->
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.emit(error)
|
||||
}
|
||||
.collect { event ->
|
||||
val item = media.value.find { it.localId == mediaItem.localId }
|
||||
?: return@collect
|
||||
|
@ -167,7 +163,16 @@ class ComposeViewModel @Inject constructor(
|
|||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.mediaId, uploadPercent = -1)
|
||||
item.copy(
|
||||
id = event.mediaId,
|
||||
uploadPercent = -1,
|
||||
state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED }
|
||||
)
|
||||
is UploadEvent.ErrorEvent -> {
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
|
||||
uploadError.emit(event.error)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
media.update { mediaValue ->
|
||||
mediaValue.map { mediaItem ->
|
||||
|
@ -186,21 +191,22 @@ class ComposeViewModel @Inject constructor(
|
|||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
|
||||
media.update { mediaValue ->
|
||||
val mediaItem = QueuedMedia(
|
||||
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
|
||||
localId = mediaUploader.getNewLocalMediaId(),
|
||||
uri = uri,
|
||||
type = type,
|
||||
mediaSize = 0,
|
||||
uploadPercent = -1,
|
||||
id = id,
|
||||
description = description,
|
||||
focus = focus
|
||||
focus = focus,
|
||||
state = QueuedMedia.State.PUBLISHED
|
||||
)
|
||||
mediaValue + mediaItem
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToJob[item.localId]?.cancel()
|
||||
mediaUploader.cancelUploadScope(item.localId)
|
||||
media.update { mediaValue -> mediaValue.filter { it.localId != item.localId } }
|
||||
}
|
||||
|
||||
|
@ -209,15 +215,8 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
|
||||
val textChanged = !(
|
||||
content.isNullOrEmpty() ||
|
||||
startingText?.startsWith(content.toString()) ?: false
|
||||
)
|
||||
|
||||
val contentWarningChanged = showContentWarning.value &&
|
||||
!contentWarning.isNullOrEmpty() &&
|
||||
!startingContentWarning.startsWith(contentWarning.toString())
|
||||
val textChanged = content.orEmpty() != startingText.orEmpty()
|
||||
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
val didScheduledTimeChange = hasScheduledTimeChanged
|
||||
|
@ -238,6 +237,10 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun stopUploads() {
|
||||
mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray())
|
||||
}
|
||||
|
||||
fun shouldShowSaveDraftDialog(): Boolean {
|
||||
// if any of the media files need to be downloaded first it could take a while, so show a loading dialog
|
||||
return media.value.any { mediaValue ->
|
||||
|
@ -268,8 +271,10 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaFocus = mediaFocus,
|
||||
poll = poll.value,
|
||||
failedToSend = false,
|
||||
failedToSendAlert = false,
|
||||
scheduledAt = scheduledAt.value,
|
||||
language = postLanguage,
|
||||
statusId = originalStatusId,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -286,46 +291,36 @@ class ComposeViewModel @Inject constructor(
|
|||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
}
|
||||
|
||||
media
|
||||
.filter { items -> items.all { it.uploadPercent == -1 } }
|
||||
.first {
|
||||
val mediaIds: MutableList<String> = mutableListOf()
|
||||
val mediaUris: MutableList<Uri> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String> = mutableListOf()
|
||||
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
|
||||
val mediaProcessed: MutableList<Boolean> = mutableListOf()
|
||||
media.value.forEach { item ->
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
mediaFocus.add(item.focus)
|
||||
mediaProcessed.add(false)
|
||||
}
|
||||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
mediaIds = mediaIds,
|
||||
mediaUris = mediaUris.map { it.toString() },
|
||||
mediaDescriptions = mediaDescriptions,
|
||||
mediaFocus = mediaFocus,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
draftId = draftId,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0,
|
||||
mediaProcessed = mediaProcessed,
|
||||
language = postLanguage,
|
||||
)
|
||||
val attachedMedia = media.value.map { item ->
|
||||
MediaToSend(
|
||||
localId = item.localId,
|
||||
id = item.id,
|
||||
uri = item.uri.toString(),
|
||||
description = item.description,
|
||||
focus = item.focus,
|
||||
processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED
|
||||
)
|
||||
}
|
||||
val tootToSend = StatusToSend(
|
||||
text = content,
|
||||
warningText = spoilerText,
|
||||
visibility = statusVisibility.value.serverString(),
|
||||
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
|
||||
media = attachedMedia,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
draftId = draftId,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0,
|
||||
language = postLanguage,
|
||||
statusId = originalStatusId
|
||||
)
|
||||
|
||||
serviceClient.sendToot(tootToSend)
|
||||
true
|
||||
}
|
||||
serviceClient.sendToot(tootToSend)
|
||||
}
|
||||
|
||||
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
|
||||
|
@ -356,15 +351,15 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun updateDescription(localId: Int, description: String): Boolean {
|
||||
return updateMediaItem(localId, { mediaItem ->
|
||||
return updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(description = description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
|
||||
return updateMediaItem(localId, { mediaItem ->
|
||||
return updateMediaItem(localId) { mediaItem ->
|
||||
mediaItem.copy(focus = focus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||
|
@ -412,6 +407,8 @@ class ComposeViewModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
|
||||
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
|
@ -452,6 +449,7 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
draftId = composeOptions?.draftId ?: 0
|
||||
scheduledTootId = composeOptions?.scheduledTootId
|
||||
originalStatusId = composeOptions?.statusId
|
||||
startingText = composeOptions?.content
|
||||
postLanguage = composeOptions?.language
|
||||
|
||||
|
@ -497,6 +495,9 @@ class ComposeViewModel @Inject constructor(
|
|||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
val editing: Boolean
|
||||
get() = !originalStatusId.isNullOrEmpty()
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
|
|
@ -48,10 +48,13 @@ class MediaPreviewAdapter(
|
|||
val addFocusId = 2
|
||||
val editImageId = 3
|
||||
val removeId = 4
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) {
|
||||
// Already-published items can't have their metadata edited
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
|
||||
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
|
||||
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
|
||||
}
|
||||
}
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
|
|
|
@ -17,13 +17,14 @@ package com.keylesspalace.tusky.components.compose
|
|||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
|
@ -35,28 +36,44 @@ import com.keylesspalace.tusky.util.getImageSquarePixels
|
|||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.getServerErrorMessage
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
sealed interface FinalUploadEvent
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val mediaId: String) : UploadEvent()
|
||||
data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent
|
||||
data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent
|
||||
}
|
||||
|
||||
data class UploadData(
|
||||
val flow: Flow<UploadEvent>,
|
||||
val scope: CoroutineScope
|
||||
)
|
||||
|
||||
fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
|
@ -76,14 +93,38 @@ class MediaTypeException : Exception()
|
|||
class CouldNotOpenFileException : Exception()
|
||||
class UploadServerError(val errorMessage: String) : Exception()
|
||||
|
||||
@Singleton
|
||||
class MediaUploader @Inject constructor(
|
||||
private val context: Context,
|
||||
private val mediaUploadApi: MediaUploadApi
|
||||
) {
|
||||
|
||||
private val uploads = mutableMapOf<Int, UploadData>()
|
||||
|
||||
private var mostRecentId: Int = 0
|
||||
|
||||
fun getNewLocalMediaId(): Int {
|
||||
return mostRecentId++
|
||||
}
|
||||
|
||||
suspend fun getMediaUploadState(localId: Int): FinalUploadEvent {
|
||||
return uploads[localId]?.flow
|
||||
?.filterIsInstance<FinalUploadEvent>()
|
||||
?.first()
|
||||
?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads media.
|
||||
* @param media the media to upload
|
||||
* @param instanceInfo info about the current media to make sure the media gets resized correctly
|
||||
* @return A Flow emitting upload events.
|
||||
* The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
|
||||
return flow {
|
||||
val uploadScope = CoroutineScope(Dispatchers.IO)
|
||||
val uploadFlow = flow {
|
||||
if (shouldResizeMedia(media, instanceInfo)) {
|
||||
emit(downsize(media, instanceInfo))
|
||||
} else {
|
||||
|
@ -91,7 +132,23 @@ class MediaUploader @Inject constructor(
|
|||
}
|
||||
}
|
||||
.flatMapLatest { upload(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.catch { exception ->
|
||||
emit(UploadEvent.ErrorEvent(exception))
|
||||
}
|
||||
.shareIn(uploadScope, SharingStarted.Lazily, 1)
|
||||
|
||||
uploads[media.localId] = UploadData(uploadFlow, uploadScope)
|
||||
return uploadFlow
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the CoroutineScope of a media upload.
|
||||
* Call this when to abort the upload or to clean up resources after upload info is no longer needed
|
||||
*/
|
||||
fun cancelUploadScope(vararg localMediaIds: Int) {
|
||||
localMediaIds.forEach { localId ->
|
||||
uploads.remove(localId)?.scope?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
|
||||
|
@ -193,6 +250,19 @@ class MediaUploader @Inject constructor(
|
|||
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
|
||||
// Android's MIME type suggestions from file extensions is broken for at least
|
||||
// .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
|
||||
// Sniff the content of the file to determine the actual type.
|
||||
if (mimeType != null && (
|
||||
mimeType.startsWith("audio/", ignoreCase = true) ||
|
||||
mimeType.startsWith("video/", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(context, media.uri)
|
||||
mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE)
|
||||
}
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = "%s_%s_%s.%s".format(
|
||||
|
@ -231,16 +301,20 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
mediaUploadApi.uploadMedia(body, description, focus).fold({ result ->
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
}, { throwable ->
|
||||
val errorMessage = throwable.getServerErrorMessage()
|
||||
val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus)
|
||||
val responseBody = uploadResponse.body()
|
||||
if (uploadResponse.isSuccessful && responseBody != null) {
|
||||
send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200))
|
||||
} else {
|
||||
val error = HttpException(uploadResponse)
|
||||
val errorMessage = error.getServerErrorMessage()
|
||||
if (errorMessage == null) {
|
||||
throw throwable
|
||||
throw error
|
||||
} else {
|
||||
throw UploadServerError(errorMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -63,4 +63,16 @@ class EditTextTyped @JvmOverloads constructor(
|
|||
editorInfo
|
||||
)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Override pasting to ensure that formatted content is always pasted as
|
||||
* plain text.
|
||||
*/
|
||||
override fun onTextContextMenuItem(id: Int): Boolean {
|
||||
if (id == android.R.id.paste) {
|
||||
return super.onTextContextMenuItem(android.R.id.pasteAsPlainText)
|
||||
}
|
||||
|
||||
return super.onTextContextMenuItem(id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -80,6 +80,7 @@ data class ConversationStatusEntity(
|
|||
val account: ConversationAccountEntity,
|
||||
val content: String,
|
||||
val createdAt: Date,
|
||||
val editedAt: Date?,
|
||||
val emojis: List<Emoji>,
|
||||
val favouritesCount: Int,
|
||||
val repliesCount: Int,
|
||||
|
@ -109,6 +110,7 @@ data class ConversationStatusEntity(
|
|||
content = content,
|
||||
reblog = null,
|
||||
createdAt = createdAt,
|
||||
editedAt = editedAt,
|
||||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
|
@ -159,6 +161,7 @@ fun Status.toEntity(
|
|||
account = account.toEntity(),
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
editedAt = editedAt,
|
||||
emojis = emojis,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
|
|
|
@ -71,6 +71,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
|
|||
account = status.account.toEntity(),
|
||||
content = status.content,
|
||||
createdAt = status.createdAt,
|
||||
editedAt = status.editedAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
|
|
|
@ -68,11 +68,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getMediaPreviewHeight(Context context) {
|
||||
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
|
||||
}
|
||||
|
||||
void setupWithConversation(
|
||||
@NonNull ConversationViewData conversation,
|
||||
@Nullable Object payloads
|
||||
|
@ -88,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
|
||||
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
|
||||
setUsername(account.getUsername());
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setMetaData(statusViewData, statusDisplayOptions, listener);
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setFavourited(status.getFavourited());
|
||||
setBookmarked(status.getBookmarked());
|
||||
|
@ -108,10 +103,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
} else {
|
||||
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
|
||||
// Hide all unused views.
|
||||
mediaPreviews[0].setVisibility(View.GONE);
|
||||
mediaPreviews[1].setVisibility(View.GONE);
|
||||
mediaPreviews[2].setVisibility(View.GONE);
|
||||
mediaPreviews[3].setVisibility(View.GONE);
|
||||
mediaPreview.setVisibility(View.GONE);
|
||||
hideSensitiveMediaWarning();
|
||||
}
|
||||
|
||||
|
@ -129,7 +121,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
if (payloads instanceof List) {
|
||||
for (Object item : (List<?>) payloads) {
|
||||
if (Key.KEY_CREATED.equals(item)) {
|
||||
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
|
||||
setMetaData(statusViewData, statusDisplayOptions, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,8 +63,10 @@ class DraftHelper @Inject constructor(
|
|||
mediaFocus: List<Attachment.Focus?>,
|
||||
poll: NewPoll?,
|
||||
failedToSend: Boolean,
|
||||
failedToSendAlert: Boolean,
|
||||
scheduledAt: String?,
|
||||
language: String?,
|
||||
statusId: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val externalFilesDir = context.getExternalFilesDir("Tusky")
|
||||
|
||||
|
@ -122,8 +124,10 @@ class DraftHelper @Inject constructor(
|
|||
attachments = attachments,
|
||||
poll = poll,
|
||||
failedToSend = failedToSend,
|
||||
failedToSendNew = failedToSendAlert,
|
||||
scheduledAt = scheduledAt,
|
||||
language = language,
|
||||
statusId = statusId,
|
||||
)
|
||||
|
||||
draftDao.insertOrReplace(draft)
|
||||
|
|
|
@ -25,8 +25,7 @@ import androidx.activity.viewModels
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
@ -34,10 +33,10 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
||||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.db.DraftsAlert
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
@ -48,6 +47,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var draftsAlert: DraftsAlert
|
||||
|
||||
private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private lateinit var binding: ActivityDraftsBinding
|
||||
|
@ -85,16 +87,23 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
adapter.addLoadStateListener {
|
||||
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) {
|
||||
if (draft.inReplyToId == null) {
|
||||
openDraftWithoutReply(draft)
|
||||
return
|
||||
}
|
||||
|
||||
if (draft.inReplyToId != null) {
|
||||
val context = this as Context
|
||||
|
||||
lifecycleScope.launch {
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.getStatus(draft.inReplyToId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this))
|
||||
.subscribe(
|
||||
.fold(
|
||||
{ status ->
|
||||
val composeOptions = ComposeActivity.ComposeOptions(
|
||||
draftId = draft.id,
|
||||
|
@ -109,14 +118,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
visibility = draft.visibility,
|
||||
scheduledAt = draft.scheduledAt,
|
||||
language = draft.language,
|
||||
statusId = draft.statusId,
|
||||
kind = ComposeActivity.ComposeKind.EDIT_DRAFT
|
||||
)
|
||||
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
startActivity(ComposeActivity.startIntent(this, composeOptions))
|
||||
startActivity(ComposeActivity.startIntent(context, composeOptions))
|
||||
},
|
||||
{ throwable ->
|
||||
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
|
||||
Log.w(TAG, "failed loading reply information", throwable)
|
||||
|
@ -124,7 +134,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
// the original status to which a reply was drafted has been deleted
|
||||
// let's open the ComposeActivity without reply information
|
||||
Toast.makeText(this, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
|
||||
openDraftWithoutReply(draft)
|
||||
} else {
|
||||
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
|
||||
|
@ -132,8 +142,6 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
openDraftWithoutReply(draft)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -148,6 +156,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
visibility = draft.visibility,
|
||||
scheduledAt = draft.scheduledAt,
|
||||
language = draft.language,
|
||||
statusId = draft.statusId,
|
||||
kind = ComposeActivity.ComposeKind.EDIT_DRAFT
|
||||
)
|
||||
|
||||
startActivity(ComposeActivity.startIntent(this, composeOptions))
|
||||
|
|
|
@ -20,12 +20,12 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -60,7 +60,7 @@ class DraftsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun getStatus(statusId: String): Single<Status> {
|
||||
suspend fun getStatus(statusId: String): NetworkResult<Status> {
|
||||
return api.status(statusId)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -22,6 +22,7 @@ import android.net.Uri
|
|||
import android.os.Bundle
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
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.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.openLinkInCustomTab
|
||||
import com.keylesspalace.tusky.util.rickRoll
|
||||
import com.keylesspalace.tusky.util.shouldRickRoll
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
|
@ -67,24 +69,8 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
is LoginResult.Ok -> lifecycleScope.launch {
|
||||
fetchOauthToken(result.code)
|
||||
}
|
||||
is LoginResult.Err -> {
|
||||
// Authorization failed. Put the error response where the user can read it and they
|
||||
// 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)
|
||||
}
|
||||
is LoginResult.Err -> displayError(result.errorMessage)
|
||||
is LoginResult.Cancel -> setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,7 +103,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
binding.loginButton.setOnClickListener { onButtonClick() }
|
||||
binding.loginButton.setOnClickListener { onLoginClick(true) }
|
||||
binding.registerButton.setOnClickListener { onRegisterClick() }
|
||||
|
||||
binding.whatsAnInstanceTextView.setOnClickListener {
|
||||
|
@ -129,13 +115,9 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
textView?.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
if (isAdditionalLogin() || isAccountMigration()) {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
} else {
|
||||
binding.toolbar.visibility = View.GONE
|
||||
}
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration())
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
* 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.domainTextInputLayout.error = null
|
||||
|
||||
|
@ -204,7 +197,7 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
.putString(CLIENT_SECRET, credentials.clientSecret)
|
||||
.apply()
|
||||
|
||||
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
|
||||
redirectUserToAuthorizeAndLogin(domain, credentials.clientId, openInWebView)
|
||||
},
|
||||
{ e ->
|
||||
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,
|
||||
// login there, and the server will redirect back to the app with its response.
|
||||
val url = HttpUrl.Builder()
|
||||
val uri = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host(domain)
|
||||
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
|
||||
|
@ -230,13 +223,59 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
.addQueryParameter("response_type", "code")
|
||||
.addQueryParameter("scope", OAUTH_SCOPES)
|
||||
.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() {
|
||||
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)
|
||||
|
||||
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) {
|
||||
|
|
|
@ -120,6 +120,7 @@ public class NotificationHelper {
|
|||
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
|
||||
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
|
||||
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
|
||||
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
|
||||
|
||||
/**
|
||||
* WorkManager Tag
|
||||
|
@ -173,11 +174,11 @@ public class NotificationHelper {
|
|||
notificationId++;
|
||||
|
||||
builder.setContentTitle(titleForType(context, body, account))
|
||||
.setContentText(bodyForType(body, context));
|
||||
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
|
||||
|
||||
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
|
||||
builder.setStyle(new NotificationCompat.BigTextStyle()
|
||||
.bigText(bodyForType(body, context)));
|
||||
.bigText(bodyForType(body, context, account.getAlwaysOpenSpoiler())));
|
||||
}
|
||||
|
||||
//load the avatar synchronously
|
||||
|
@ -370,6 +371,7 @@ public class NotificationHelper {
|
|||
composeOptions.setMentionedUsernames(mentionedUsernames);
|
||||
composeOptions.setModifiedInitialState(true);
|
||||
composeOptions.setLanguage(actionableStatus.getLanguage());
|
||||
composeOptions.setKind(ComposeActivity.ComposeKind.NEW);
|
||||
|
||||
Intent composeIntent = ComposeActivity.startIntent(
|
||||
context,
|
||||
|
@ -401,6 +403,7 @@ public class NotificationHelper {
|
|||
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
|
||||
CHANNEL_SIGN_UP + account.getIdentifier(),
|
||||
CHANNEL_UPDATES + account.getIdentifier(),
|
||||
CHANNEL_REPORT + account.getIdentifier(),
|
||||
};
|
||||
int[] channelNames = {
|
||||
R.string.notification_mention_name,
|
||||
|
@ -412,6 +415,7 @@ public class NotificationHelper {
|
|||
R.string.notification_subscription_name,
|
||||
R.string.notification_sign_up_name,
|
||||
R.string.notification_update_name,
|
||||
R.string.notification_report_name,
|
||||
};
|
||||
int[] channelDescriptions = {
|
||||
R.string.notification_mention_descriptions,
|
||||
|
@ -423,6 +427,7 @@ public class NotificationHelper {
|
|||
R.string.notification_subscription_description,
|
||||
R.string.notification_sign_up_description,
|
||||
R.string.notification_update_description,
|
||||
R.string.notification_report_description,
|
||||
};
|
||||
|
||||
List<NotificationChannel> channels = new ArrayList<>(6);
|
||||
|
@ -469,7 +474,7 @@ public class NotificationHelper {
|
|||
|
||||
if (notificationManager.areNotificationsEnabled()) {
|
||||
for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
|
||||
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
|
||||
if (channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
|
||||
Log.d(TAG, "NotificationsEnabled");
|
||||
return true;
|
||||
}
|
||||
|
@ -542,7 +547,7 @@ public class NotificationHelper {
|
|||
return false;
|
||||
}
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
|
||||
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
|
||||
return channel != null && channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
|
@ -564,6 +569,8 @@ public class NotificationHelper {
|
|||
return account.getNotificationsSignUps();
|
||||
case UPDATE:
|
||||
return account.getNotificationsUpdates();
|
||||
case REPORT:
|
||||
return account.getNotificationsReports();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
@ -593,6 +600,10 @@ public class NotificationHelper {
|
|||
return CHANNEL_POLL + account.getIdentifier();
|
||||
case SIGN_UP:
|
||||
return CHANNEL_SIGN_UP + account.getIdentifier();
|
||||
case UPDATE:
|
||||
return CHANNEL_UPDATES + account.getIdentifier();
|
||||
case REPORT:
|
||||
return CHANNEL_REPORT + account.getIdentifier();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -678,11 +689,13 @@ public class NotificationHelper {
|
|||
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
|
||||
case UPDATE:
|
||||
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;
|
||||
}
|
||||
|
||||
private static String bodyForType(Notification notification, Context context) {
|
||||
private static String bodyForType(Notification notification, Context context, Boolean alwaysOpenSpoiler) {
|
||||
switch (notification.getType()) {
|
||||
case FOLLOW:
|
||||
case FOLLOW_REQUEST:
|
||||
|
@ -692,13 +705,13 @@ public class NotificationHelper {
|
|||
case FAVOURITE:
|
||||
case REBLOG:
|
||||
case STATUS:
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
|
||||
return notification.getStatus().getSpoilerText();
|
||||
} else {
|
||||
return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
|
||||
}
|
||||
case POLL:
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
|
||||
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText()) && !alwaysOpenSpoiler) {
|
||||
return notification.getStatus().getSpoilerText();
|
||||
} else {
|
||||
StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
|
||||
|
@ -715,6 +728,12 @@ public class NotificationHelper {
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -36,7 +36,6 @@ import com.keylesspalace.tusky.util.CryptoUtil
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import retrofit2.HttpException
|
||||
|
||||
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) {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain)
|
||||
.onFailure {
|
||||
Log.d(TAG, "Error unregistering push endpoint for account " + account.id)
|
||||
Log.d(TAG, Log.getStackTraceString(it))
|
||||
Log.d(TAG, (it as HttpException).response().toString())
|
||||
.onFailure { throwable ->
|
||||
Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable)
|
||||
}
|
||||
.onSuccess {
|
||||
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
|
||||
|
|
|
@ -16,11 +16,13 @@
|
|||
package com.keylesspalace.tusky.components.preference
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.AccountListActivity
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
@ -30,6 +32,7 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.TabPreferenceActivity
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
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.login.LoginActivity
|
||||
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.preferenceCategory
|
||||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.getInitialLanguage
|
||||
import com.keylesspalace.tusky.util.getLocaleList
|
||||
import com.keylesspalace.tusky.util.getTuskyDisplayName
|
||||
import com.keylesspalace.tusky.util.makeIcon
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
|
@ -66,6 +72,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
val context = requireContext()
|
||||
makePreferenceScreen {
|
||||
|
@ -73,7 +81,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
setTitle(R.string.pref_title_edit_notification_settings)
|
||||
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).apply {
|
||||
sizeRes = R.dimen.preference_icon_size
|
||||
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
|
||||
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
|
||||
}
|
||||
setOnPreferenceClickListener {
|
||||
openNotificationPrefs()
|
||||
|
@ -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 {
|
||||
setTitle(R.string.action_view_mutes)
|
||||
setIcon(R.drawable.ic_mute_24dp)
|
||||
|
@ -114,7 +136,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
setTitle(R.string.action_view_blocks)
|
||||
icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).apply {
|
||||
sizeRes = R.dimen.preference_icon_size
|
||||
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
|
||||
colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK)
|
||||
}
|
||||
setOnPreferenceClickListener {
|
||||
val intent = Intent(context, AccountListActivity::class.java)
|
||||
|
@ -154,7 +176,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO language
|
||||
preferenceCategory(R.string.pref_publishing) {
|
||||
listPreference {
|
||||
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 {
|
||||
setTitle(R.string.pref_default_media_sensitivity)
|
||||
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() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent()
|
||||
|
@ -302,8 +351,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) {
|
||||
mastodonApi.accountUpdateSource(visibility, sensitive)
|
||||
private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) {
|
||||
mastodonApi.accountUpdateSource(visibility, sensitive, language)
|
||||
.enqueue(object : Callback<Account> {
|
||||
override fun onResponse(call: Call<Account>, response: Response<Account>) {
|
||||
val account = response.body()
|
||||
|
@ -313,6 +362,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
it.defaultPostPrivacy = account.source?.privacy
|
||||
?: Status.Visibility.PUBLIC
|
||||
it.defaultMediaSensitivity = account.source?.sensitive ?: false
|
||||
it.defaultPostLanguage = language ?: ""
|
||||
accountManager.saveAccount(it)
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -144,6 +144,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
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 ->
|
||||
|
@ -193,6 +204,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().setTitle(R.string.pref_title_edit_notification_settings)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): NotificationPreferencesFragment {
|
||||
return NotificationPreferencesFragment()
|
||||
|
|
|
@ -23,6 +23,8 @@ import android.util.Log
|
|||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
|
@ -31,8 +33,9 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
@ -40,6 +43,7 @@ import javax.inject.Inject
|
|||
class PreferencesActivity :
|
||||
BaseActivity(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
|
||||
HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
|
@ -81,8 +85,6 @@ class PreferencesActivity :
|
|||
GENERAL_PREFERENCES -> PreferencesFragment.newInstance()
|
||||
ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance()
|
||||
NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance()
|
||||
TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance()
|
||||
PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance()
|
||||
else -> throw IllegalArgumentException("preferenceType not known")
|
||||
}
|
||||
|
||||
|
@ -90,18 +92,34 @@ class PreferencesActivity :
|
|||
replace(R.id.fragment_container, fragment, fragmentTag)
|
||||
}
|
||||
|
||||
when (preferenceType) {
|
||||
GENERAL_PREFERENCES -> setTitle(R.string.action_view_preferences)
|
||||
ACCOUNT_PREFERENCES -> setTitle(R.string.action_view_account_preferences)
|
||||
NOTIFICATION_PREFERENCES -> setTitle(R.string.pref_title_edit_notification_settings)
|
||||
TAB_FILTER_PREFERENCES -> setTitle(R.string.pref_title_post_tabs)
|
||||
PROXY_PREFERENCES -> setTitle(R.string.pref_title_http_proxy_settings)
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
caller: PreferenceFragmentCompat,
|
||||
pref: Preference
|
||||
): Boolean {
|
||||
val args = pref.extras
|
||||
val fragment = supportFragmentManager.fragmentFactory.instantiate(
|
||||
classLoader,
|
||||
pref.fragment!!
|
||||
)
|
||||
fragment.arguments = args
|
||||
fragment.setTargetFragment(caller, 0)
|
||||
supportFragmentManager.commit {
|
||||
setCustomAnimations(
|
||||
R.anim.slide_from_right,
|
||||
R.anim.slide_to_left,
|
||||
R.anim.slide_from_left,
|
||||
R.anim.slide_to_right
|
||||
)
|
||||
replace(R.id.fragment_container, fragment)
|
||||
addToBackStack(null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this)
|
||||
|
@ -124,9 +142,9 @@ class PreferencesActivity :
|
|||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
when (key) {
|
||||
"appTheme" -> {
|
||||
val theme = sharedPreferences.getNonNullString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT)
|
||||
Log.d("activeTheme", theme)
|
||||
ThemeUtils.setAppNightMode(theme)
|
||||
setAppNightMode(theme)
|
||||
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = true
|
||||
this.restartCurrentActivity()
|
||||
|
@ -158,8 +176,6 @@ class PreferencesActivity :
|
|||
const val GENERAL_PREFERENCES = 0
|
||||
const val ACCOUNT_PREFERENCES = 1
|
||||
const val NOTIFICATION_PREFERENCES = 2
|
||||
const val TAB_FILTER_PREFERENCES = 3
|
||||
const val PROXY_PREFERENCES = 4
|
||||
private const val EXTRA_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE"
|
||||
private const val EXTRA_RESTART_ON_BACK = "restart"
|
||||
|
||||
|
|
|
@ -31,14 +31,11 @@ import com.keylesspalace.tusky.settings.preference
|
|||
import com.keylesspalace.tusky.settings.preferenceCategory
|
||||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import com.keylesspalace.tusky.util.makeIcon
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
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 javax.inject.Inject
|
||||
|
||||
|
@ -51,7 +48,26 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
lateinit var localeManager: LocaleManager
|
||||
|
||||
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
|
||||
private var httpProxyPref: Preference? = null
|
||||
|
||||
enum class ReadingOrder {
|
||||
/** User scrolls up, reading statuses oldest to newest */
|
||||
OLDEST_FIRST,
|
||||
|
||||
/** User scrolls down, reading statuses newest to oldest. Default behaviour. */
|
||||
NEWEST_FIRST;
|
||||
|
||||
companion object {
|
||||
fun from(s: String?): ReadingOrder {
|
||||
s ?: return NEWEST_FIRST
|
||||
|
||||
return try {
|
||||
valueOf(s.uppercase())
|
||||
} catch (_: Throwable) {
|
||||
NEWEST_FIRST
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
makePreferenceScreen {
|
||||
|
@ -92,6 +108,16 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
setDefaultValue(ReadingOrder.NEWEST_FIRST.name)
|
||||
setEntries(R.array.reading_order_names)
|
||||
setEntryValues(R.array.reading_order_values)
|
||||
key = PrefKeys.READING_ORDER
|
||||
setSummaryProvider { entry }
|
||||
setTitle(R.string.pref_title_reading_order)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_sort)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
setDefaultValue("top")
|
||||
setEntries(R.array.pref_main_nav_position_options)
|
||||
|
@ -208,14 +234,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
preferenceCategory(R.string.pref_title_timeline_filters) {
|
||||
preference {
|
||||
setTitle(R.string.pref_title_post_tabs)
|
||||
setOnPreferenceClickListener {
|
||||
activity?.let { activity ->
|
||||
val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES)
|
||||
activity.startActivity(intent)
|
||||
activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
}
|
||||
true
|
||||
}
|
||||
fragment = TabFilterPreferencesFragment::class.qualifiedName
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -259,53 +278,22 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
|
||||
preferenceCategory(R.string.pref_title_proxy_settings) {
|
||||
httpProxyPref = preference {
|
||||
preference {
|
||||
setTitle(R.string.pref_title_http_proxy_settings)
|
||||
setOnPreferenceClickListener {
|
||||
activity?.let { activity ->
|
||||
val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.PROXY_PREFERENCES)
|
||||
activity.startActivity(intent)
|
||||
activity.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
}
|
||||
true
|
||||
}
|
||||
fragment = ProxyPreferencesFragment::class.qualifiedName
|
||||
summaryProvider = ProxyPreferencesFragment.SummaryProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeIcon(icon: GoogleMaterial.Icon): IconicsDrawable {
|
||||
val context = requireContext()
|
||||
return IconicsDrawable(context, icon).apply {
|
||||
sizePx = iconSize
|
||||
colorInt = ThemeUtils.getColor(context, R.attr.iconColor)
|
||||
}
|
||||
return makeIcon(requireContext(), icon, iconSize)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateHttpProxySummary()
|
||||
}
|
||||
|
||||
private fun updateHttpProxySummary() {
|
||||
preferenceManager.sharedPreferences?.let { sharedPreferences ->
|
||||
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
|
||||
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
|
||||
|
||||
try {
|
||||
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
|
||||
.toInt()
|
||||
|
||||
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
|
||||
httpProxyPref?.summary = "$httpServer:$httpPort"
|
||||
return
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// user has entered wrong port, fall back to empty summary
|
||||
}
|
||||
|
||||
httpProxyPref?.summary = ""
|
||||
}
|
||||
requireActivity().setTitle(R.string.action_view_preferences)
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||
|
|
|
@ -16,12 +16,18 @@
|
|||
package com.keylesspalace.tusky.components.preference
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.editTextPreference
|
||||
import com.keylesspalace.tusky.settings.ProxyConfiguration
|
||||
import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MAX_PROXY_PORT
|
||||
import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MIN_PROXY_PORT
|
||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||
import com.keylesspalace.tusky.settings.preferenceCategory
|
||||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
import com.keylesspalace.tusky.settings.validatedEditTextPreference
|
||||
import com.keylesspalace.tusky.util.getNonNullString
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
||||
|
@ -36,22 +42,38 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
|||
setDefaultValue(false)
|
||||
}
|
||||
|
||||
editTextPreference {
|
||||
setTitle(R.string.pref_title_http_proxy_server)
|
||||
key = PrefKeys.HTTP_PROXY_SERVER
|
||||
isIconSpaceReserved = false
|
||||
setSummaryProvider { text }
|
||||
}
|
||||
preferenceCategory { category ->
|
||||
category.dependency = PrefKeys.HTTP_PROXY_ENABLED
|
||||
category.isIconSpaceReserved = false
|
||||
|
||||
editTextPreference {
|
||||
setTitle(R.string.pref_title_http_proxy_port)
|
||||
key = PrefKeys.HTTP_PROXY_PORT
|
||||
isIconSpaceReserved = false
|
||||
setSummaryProvider { text }
|
||||
validatedEditTextPreference(null, ProxyConfiguration::isValidHostname) {
|
||||
setTitle(R.string.pref_title_http_proxy_server)
|
||||
key = PrefKeys.HTTP_PROXY_SERVER
|
||||
isIconSpaceReserved = false
|
||||
setSummaryProvider { text }
|
||||
}
|
||||
|
||||
val portErrorMessage = getString(
|
||||
R.string.pref_title_http_proxy_port_message,
|
||||
ProxyConfiguration.MIN_PROXY_PORT,
|
||||
ProxyConfiguration.MAX_PROXY_PORT
|
||||
)
|
||||
|
||||
validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) {
|
||||
setTitle(R.string.pref_title_http_proxy_port)
|
||||
key = PrefKeys.HTTP_PROXY_PORT
|
||||
isIconSpaceReserved = false
|
||||
setSummaryProvider { text }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().setTitle(R.string.pref_title_http_proxy_settings)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (pendingRestart) {
|
||||
|
@ -60,6 +82,33 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
object SummaryProvider : Preference.SummaryProvider<Preference> {
|
||||
override fun provideSummary(preference: Preference): CharSequence {
|
||||
val sharedPreferences = preference.sharedPreferences
|
||||
sharedPreferences ?: return ""
|
||||
|
||||
if (!sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)) {
|
||||
return preference.context.getString(R.string.pref_summary_http_proxy_disabled)
|
||||
}
|
||||
|
||||
val missing = preference.context.getString(R.string.pref_summary_http_proxy_missing)
|
||||
|
||||
val server = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, missing)
|
||||
val port = try {
|
||||
sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1").toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
-1
|
||||
}
|
||||
|
||||
if (port < MIN_PROXY_PORT || port > MAX_PROXY_PORT) {
|
||||
val invalid = preference.context.getString(R.string.pref_summary_http_proxy_invalid)
|
||||
return "$server:$invalid"
|
||||
}
|
||||
|
||||
return "$server:$port"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): ProxyPreferencesFragment {
|
||||
return ProxyPreferencesFragment()
|
||||
|
|
|
@ -46,6 +46,11 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().setTitle(R.string.pref_title_post_tabs)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): TabFilterPreferencesFragment {
|
||||
return TabFilterPreferencesFragment()
|
||||
|
|
|
@ -154,52 +154,46 @@ class ReportViewModel @Inject constructor(
|
|||
|
||||
fun toggleMute() {
|
||||
val alreadyMuted = muteStateMutable.value?.data == true
|
||||
if (alreadyMuted) {
|
||||
mastodonApi.unmuteAccount(accountId)
|
||||
} else {
|
||||
mastodonApi.muteAccount(accountId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
val muting = relationship.muting
|
||||
muteStateMutable.value = Success(muting)
|
||||
if (muting) {
|
||||
eventHub.dispatch(MuteEvent(accountId))
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
muteStateMutable.value = Error(false, error.message)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val relationship = if (alreadyMuted) {
|
||||
mastodonApi.unmuteAccount(accountId)
|
||||
} else {
|
||||
mastodonApi.muteAccount(accountId)
|
||||
}
|
||||
).autoDispose()
|
||||
|
||||
val muting = relationship.muting
|
||||
muteStateMutable.value = Success(muting)
|
||||
if (muting) {
|
||||
eventHub.dispatch(MuteEvent(accountId))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
muteStateMutable.value = Error(false, t.message)
|
||||
}
|
||||
}
|
||||
|
||||
muteStateMutable.value = Loading()
|
||||
}
|
||||
|
||||
fun toggleBlock() {
|
||||
val alreadyBlocked = blockStateMutable.value?.data == true
|
||||
if (alreadyBlocked) {
|
||||
mastodonApi.unblockAccount(accountId)
|
||||
} else {
|
||||
mastodonApi.blockAccount(accountId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
val blocking = relationship.blocking
|
||||
blockStateMutable.value = Success(blocking)
|
||||
if (blocking) {
|
||||
eventHub.dispatch(BlockEvent(accountId))
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
blockStateMutable.value = Error(false, error.message)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val relationship = if (alreadyBlocked) {
|
||||
mastodonApi.unblockAccount(accountId)
|
||||
} else {
|
||||
mastodonApi.blockAccount(accountId)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
|
||||
val blocking = relationship.blocking
|
||||
blockStateMutable.value = Success(blocking)
|
||||
if (blocking) {
|
||||
eventHub.dispatch(BlockEvent(accountId))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
blockStateMutable.value = Error(false, t.message)
|
||||
}
|
||||
}
|
||||
blockStateMutable.value = Loading()
|
||||
}
|
||||
|
||||
|
|
|
@ -31,8 +31,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
|
|||
import com.keylesspalace.tusky.util.StatusViewHelper
|
||||
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
|
||||
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER
|
||||
import com.keylesspalace.tusky.util.TimestampUtils
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.getRelativeTimeSpanString
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.setClickableMentions
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
|
@ -161,7 +161,7 @@ class StatusViewHolder(
|
|||
binding.timestampInfo.text = if (createdAt != null) {
|
||||
val then = createdAt.time
|
||||
val now = System.currentTimeMillis()
|
||||
TimestampUtils.getRelativeTimeSpanString(binding.timestampInfo.context, then, now)
|
||||
getRelativeTimeSpanString(binding.timestampInfo.context, then, now)
|
||||
} else {
|
||||
// unknown minutes~
|
||||
"?m"
|
||||
|
|
|
@ -128,7 +128,8 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I
|
|||
inReplyToId = item.params.inReplyToId,
|
||||
visibility = item.params.visibility,
|
||||
scheduledAt = item.scheduledAt,
|
||||
sensitive = item.params.sensitive
|
||||
sensitive = item.params.sensitive,
|
||||
kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED
|
||||
)
|
||||
)
|
||||
startActivity(intent)
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
package com.keylesspalace.tusky.components.scheduled
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
|
@ -53,8 +52,7 @@ class ScheduledStatusAdapter(
|
|||
holder.binding.edit.isEnabled = true
|
||||
holder.binding.delete.isEnabled = true
|
||||
holder.binding.text.text = item.params.text
|
||||
holder.binding.edit.setOnClickListener { v: View ->
|
||||
v.isEnabled = false
|
||||
holder.binding.edit.setOnClickListener {
|
||||
listener.edit(item)
|
||||
}
|
||||
holder.binding.delete.setOnClickListener {
|
||||
|
|
|
@ -22,12 +22,15 @@ import android.os.Bundle
|
|||
import android.view.Menu
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter
|
||||
import com.keylesspalace.tusky.databinding.ActivitySearchBinding
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
|
@ -44,6 +47,8 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
private val binding by viewBinding(ActivitySearchBinding::inflate)
|
||||
|
||||
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
@ -58,8 +63,12 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
}
|
||||
|
||||
private fun setupPages() {
|
||||
binding.pages.reduceSwipeSensitivity()
|
||||
binding.pages.adapter = SearchPagerAdapter(this)
|
||||
|
||||
val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
|
||||
binding.pages.isUserInputEnabled = enableSwipeForTabs
|
||||
|
||||
TabLayoutMediator(binding.tabs, binding.pages) {
|
||||
tab, position ->
|
||||
tab.text = getPageTitle(position)
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
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.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchViewModel @Inject constructor(
|
||||
|
@ -98,17 +101,13 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun removeItem(statusViewData: StatusViewData.Concrete) {
|
||||
timelineCases.delete(statusViewData.id)
|
||||
.subscribe(
|
||||
{
|
||||
if (loadedStatuses.remove(statusViewData))
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
},
|
||||
{ err ->
|
||||
Log.d(TAG, "Failed to delete status", err)
|
||||
viewModelScope.launch {
|
||||
if (timelineCases.delete(statusViewData.id).isSuccess) {
|
||||
if (loadedStatuses.remove(statusViewData)) {
|
||||
statusesPagingSourceFactory.invalidate()
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
|
||||
|
@ -169,7 +168,9 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) {
|
||||
timelineCases.mute(accountId, notifications, duration)
|
||||
viewModelScope.launch {
|
||||
timelineCases.mute(accountId, notifications, duration)
|
||||
}
|
||||
}
|
||||
|
||||
fun pinAccount(status: Status, isPin: Boolean) {
|
||||
|
@ -177,11 +178,15 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun blockAccount(accountId: String) {
|
||||
timelineCases.block(accountId)
|
||||
viewModelScope.launch {
|
||||
timelineCases.block(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteStatus(id: String): Single<DeletedStatus> {
|
||||
return timelineCases.delete(id)
|
||||
fun deleteStatusAsync(id: String): Deferred<NetworkResult<DeletedStatus>> {
|
||||
return viewModelScope.async {
|
||||
timelineCases.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {
|
||||
|
|
|
@ -19,24 +19,27 @@ import android.view.LayoutInflater
|
|||
import android.view.ViewGroup
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AccountViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemAccountBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) :
|
||||
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean, private val showBotOverlay: Boolean) :
|
||||
PagingDataAdapter<TimelineAccount, AccountViewHolder>(ACCOUNT_COMPARATOR) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_account, parent, false)
|
||||
return AccountViewHolder(view)
|
||||
val binding = ItemAccountBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
return AccountViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
holder.apply {
|
||||
setupWithAccount(item, animateAvatars, animateEmojis)
|
||||
setupWithAccount(item, animateAvatars, animateEmojis, showBotOverlay)
|
||||
setupLinkListener(linkListener)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,7 +30,8 @@ class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
|
|||
return SearchAccountsAdapter(
|
||||
this,
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import com.keylesspalace.tusky.databinding.FragmentSearchBinding
|
|||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -38,6 +39,9 @@ abstract class SearchFragment<T : Any> :
|
|||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
|
||||
protected val viewModel: SearchViewModel by activityViewModels { viewModelFactory }
|
||||
|
||||
protected val binding by viewBinding(FragmentSearchBinding::bind)
|
||||
|
|
|
@ -32,14 +32,14 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import autodispose2.autoDispose
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
|
@ -60,8 +60,8 @@ import com.keylesspalace.tusky.util.openLink
|
|||
import com.keylesspalace.tusky.view.showMuteAccountDialog
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
|
||||
|
||||
|
@ -219,6 +219,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
replyingStatusAuthor = actionableStatus.account.localUsername,
|
||||
replyingStatusContent = status.content.toString(),
|
||||
language = actionableStatus.language,
|
||||
kind = ComposeActivity.ComposeKind.NEW
|
||||
)
|
||||
)
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
|
||||
|
@ -351,6 +352,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
showConfirmEditDialog(id, position, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.status_edit -> {
|
||||
editStatus(id, position, status)
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
R.id.pin -> {
|
||||
viewModel.pinAccount(status, !status.isPinned())
|
||||
return@setOnMenuItemClickListener true
|
||||
|
@ -436,7 +441,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
AlertDialog.Builder(it)
|
||||
.setMessage(R.string.dialog_delete_post_warning)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.deleteStatus(id)
|
||||
viewModel.deleteStatusAsync(id)
|
||||
removeItem(position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
@ -449,10 +454,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
AlertDialog.Builder(it)
|
||||
.setMessage(R.string.dialog_redraft_post_warning)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.deleteStatus(id)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe(
|
||||
lifecycleScope.launch {
|
||||
viewModel.deleteStatusAsync(id).await().fold(
|
||||
{ deletedStatus ->
|
||||
removeItem(position)
|
||||
|
||||
|
@ -473,6 +476,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
sensitive = redraftStatus.sensitive,
|
||||
poll = redraftStatus.poll?.toNewPoll(status.createdAt),
|
||||
language = redraftStatus.language,
|
||||
kind = ComposeActivity.ComposeKind.NEW
|
||||
)
|
||||
)
|
||||
startActivity(intent)
|
||||
|
@ -482,9 +486,39 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun editStatus(id: String, position: Int, status: Status) {
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.statusSource(id).fold(
|
||||
{ source ->
|
||||
val composeOptions = ComposeOptions(
|
||||
content = source.text,
|
||||
inReplyToId = status.inReplyToId,
|
||||
visibility = status.visibility,
|
||||
contentWarning = source.spoilerText,
|
||||
mediaAttachments = status.attachments,
|
||||
sensitive = status.sensitive,
|
||||
language = status.language,
|
||||
statusId = source.id,
|
||||
poll = status.poll?.toNewPoll(status.createdAt),
|
||||
kind = ComposeActivity.ComposeKind.EDIT_POSTED,
|
||||
)
|
||||
startActivity(ComposeActivity.startIntent(requireContext(), composeOptions))
|
||||
},
|
||||
{
|
||||
Snackbar.make(
|
||||
requireView(),
|
||||
getString(R.string.error_status_source_load),
|
||||
Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,11 +42,11 @@ import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
|
|||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
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.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
|
@ -63,6 +63,7 @@ import com.keylesspalace.tusky.util.hide
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
@ -85,9 +86,6 @@ class TimelineFragment :
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
private val viewModel: TimelineViewModel by lazy {
|
||||
if (kind == TimelineViewModel.Kind.HOME) {
|
||||
ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
|
||||
|
@ -105,6 +103,38 @@ class TimelineFragment :
|
|||
private var isSwipeToRefreshEnabled = true
|
||||
private var hideFab = false
|
||||
|
||||
/**
|
||||
* Adapter position of the placeholder that was most recently clicked to "Load more". If null
|
||||
* then there is no active "Load more" operation
|
||||
*/
|
||||
private var loadMorePosition: Int? = null
|
||||
|
||||
/** ID of the status immediately below the most recent "Load more" placeholder click */
|
||||
// The Paging library assumes that the user will be scrolling down a list of items,
|
||||
// and if new items are loaded but not visible then it's reasonable to scroll to the top
|
||||
// of the inserted items. It does not seem to be possible to disable that behaviour.
|
||||
//
|
||||
// That behaviour should depend on the user's preferred reading order. If they prefer to
|
||||
// read oldest first then the list should be scrolled to the bottom of the freshly
|
||||
// inserted statuses.
|
||||
//
|
||||
// To do this:
|
||||
//
|
||||
// 1. When "Load more" is clicked (onLoadMore()):
|
||||
// a. Remember the adapter position of the "Load more" item in loadMorePosition
|
||||
// b. Remember the ID of the status immediately below the "Load more" item in
|
||||
// statusIdBelowLoadMore
|
||||
// 2. After the new items have been inserted, search the adapter for the position of the
|
||||
// status with id == statusIdBelowLoadMore.
|
||||
// 3. If this position is still visible on screen then do nothing, otherwise, scroll the view
|
||||
// so that the status is visible.
|
||||
//
|
||||
// The user can then scroll up to read the new statuses.
|
||||
private var statusIdBelowLoadMore: String? = null
|
||||
|
||||
/** The user's preferred reading order */
|
||||
private lateinit var readingOrder: ReadingOrder
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
@ -134,6 +164,8 @@ class TimelineFragment :
|
|||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
|
||||
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
|
||||
|
@ -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() {
|
||||
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
|
||||
binding.swipeRefreshLayout.setOnRefreshListener(this)
|
||||
|
@ -348,6 +410,8 @@ class TimelineFragment :
|
|||
|
||||
override fun onLoadMore(position: Int) {
|
||||
val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return
|
||||
loadMorePosition = position
|
||||
statusIdBelowLoadMore = adapter.peek(position + 1)?.id
|
||||
viewModel.loadMore(placeholder.id)
|
||||
}
|
||||
|
||||
|
@ -408,6 +472,11 @@ class TimelineFragment :
|
|||
adapter.notifyItemRangeChanged(0, adapter.itemCount)
|
||||
}
|
||||
}
|
||||
PrefKeys.READING_ORDER -> {
|
||||
readingOrder = ReadingOrder.from(
|
||||
sharedPreferences.getString(PrefKeys.READING_ORDER, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.timeline
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.keylesspalace.tusky.db.TimelineAccountEntity
|
||||
|
@ -30,6 +31,8 @@ import com.keylesspalace.tusky.entity.TimelineAccount
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import java.util.Date
|
||||
|
||||
private const val TAG = "TimelineTypeMappers"
|
||||
|
||||
data class Placeholder(
|
||||
val id: String,
|
||||
val loading: Boolean
|
||||
|
@ -77,6 +80,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
|
|||
inReplyToAccountId = null,
|
||||
content = null,
|
||||
createdAt = 0L,
|
||||
editedAt = 0L,
|
||||
emojis = null,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
|
@ -120,6 +124,7 @@ fun Status.toEntity(
|
|||
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
||||
content = actionableStatus.content,
|
||||
createdAt = actionableStatus.createdAt.time,
|
||||
editedAt = actionableStatus.editedAt?.time,
|
||||
emojis = actionableStatus.emojis.let(gson::toJson),
|
||||
reblogsCount = actionableStatus.reblogsCount,
|
||||
favouritesCount = actionableStatus.favouritesCount,
|
||||
|
@ -147,8 +152,9 @@ fun Status.toEntity(
|
|||
)
|
||||
}
|
||||
|
||||
fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
||||
if (this.status.authorServerId == null) {
|
||||
fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData {
|
||||
if (this.status.isPlaceholder) {
|
||||
Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})")
|
||||
return StatusViewData.Placeholder(this.status.serverId, this.status.expanded)
|
||||
}
|
||||
|
||||
|
@ -170,6 +176,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
|
@ -201,6 +208,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
reblog = reblog,
|
||||
content = "",
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
editedAt = null,
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
|
@ -231,6 +239,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
|
@ -256,6 +265,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
|
|||
status = status,
|
||||
isExpanded = this.status.expanded,
|
||||
isShowingContent = this.status.contentShowing,
|
||||
isCollapsed = this.status.contentCollapsed
|
||||
isCollapsed = this.status.contentCollapsed,
|
||||
isDetailed = isDetailed
|
||||
)
|
||||
}
|
||||
|
|
|
@ -153,7 +153,14 @@ class CachedTimelineRemoteMediator(
|
|||
if (oldStatus != null) break
|
||||
}
|
||||
|
||||
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
||||
// The "expanded" property for Placeholders determines whether or not they are
|
||||
// in the "loading" state, and should not be affected by the account's
|
||||
// "alwaysOpenSpoiler" preference
|
||||
val expanded = if (oldStatus?.isPlaceholder == true) {
|
||||
oldStatus.expanded
|
||||
} else {
|
||||
oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
|
||||
}
|
||||
val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
|
||||
val contentCollapsed = oldStatus?.contentCollapsed ?: true
|
||||
|
||||
|
|
|
@ -32,6 +32,8 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.FavoriteEvent
|
||||
import com.keylesspalace.tusky.appstore.PinEvent
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST
|
||||
import com.keylesspalace.tusky.components.timeline.Placeholder
|
||||
import com.keylesspalace.tusky.components.timeline.toEntity
|
||||
import com.keylesspalace.tusky.components.timeline.toViewData
|
||||
|
@ -169,13 +171,23 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
|
||||
val response = db.withTransaction {
|
||||
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
|
||||
val nextPlaceholderId =
|
||||
timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
|
||||
api.homeTimeline(
|
||||
maxId = idAbovePlaceholder,
|
||||
sinceId = nextPlaceholderId,
|
||||
limit = LOAD_AT_ONCE
|
||||
)
|
||||
val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId)
|
||||
when (readingOrder) {
|
||||
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
|
||||
// after minId and no larger than maxId
|
||||
OLDEST_FIRST -> api.homeTimeline(
|
||||
maxId = idAbovePlaceholder,
|
||||
minId = idBelowPlaceholder,
|
||||
limit = LOAD_AT_ONCE
|
||||
)
|
||||
// Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before
|
||||
// maxId, and no smaller than minId.
|
||||
NEWEST_FIRST -> api.homeTimeline(
|
||||
maxId = idAbovePlaceholder,
|
||||
sinceId = idBelowPlaceholder,
|
||||
limit = LOAD_AT_ONCE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val statuses = response.body()
|
||||
|
@ -218,12 +230,16 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
/* In case we loaded a whole page and there was no overlap with existing statuses,
|
||||
we insert a placeholder because there might be even more unknown statuses */
|
||||
if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) {
|
||||
/* This overrides the last of the newly loaded statuses with a placeholder
|
||||
/* This overrides the first/last of the newly loaded statuses with a placeholder
|
||||
to guarantee the placeholder has an id that exists on the server as not all
|
||||
servers handle client generated ids as expected */
|
||||
val idToConvert = when (readingOrder) {
|
||||
OLDEST_FIRST -> statuses.first().id
|
||||
NEWEST_FIRST -> statuses.last().id
|
||||
}
|
||||
timelineDao.insertStatus(
|
||||
Placeholder(
|
||||
statuses.last().id,
|
||||
idToConvert,
|
||||
loading = false
|
||||
).toEntity(activeAccount.id)
|
||||
)
|
||||
|
|
|
@ -259,7 +259,7 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
limit: Int
|
||||
): Response<List<Status>> {
|
||||
return when (kind) {
|
||||
Kind.HOME -> api.homeTimeline(fromId, uptoId, limit)
|
||||
Kind.HOME -> api.homeTimeline(maxId = fromId, sinceId = uptoId, limit = limit)
|
||||
Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit)
|
||||
Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit)
|
||||
Kind.TAG -> {
|
||||
|
|
|
@ -20,6 +20,7 @@ import android.util.Log
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.DomainMuteEvent
|
||||
|
@ -33,6 +34,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
|||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
||||
import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
|
@ -53,7 +55,7 @@ abstract class TimelineViewModel(
|
|||
private val api: MastodonApi,
|
||||
private val eventHub: EventHub,
|
||||
protected val accountManager: AccountManager,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
protected val sharedPreferences: SharedPreferences,
|
||||
private val filterModel: FilterModel
|
||||
) : ViewModel() {
|
||||
|
||||
|
@ -70,6 +72,7 @@ abstract class TimelineViewModel(
|
|||
protected var alwaysOpenSpoilers = false
|
||||
private var filterRemoveReplies = false
|
||||
private var filterRemoveReblogs = false
|
||||
protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST
|
||||
|
||||
fun init(
|
||||
kind: Kind,
|
||||
|
@ -87,6 +90,8 @@ abstract class TimelineViewModel(
|
|||
filterRemoveReblogs =
|
||||
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true)
|
||||
}
|
||||
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
|
||||
|
||||
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
|
||||
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
|
||||
|
||||
|
@ -124,7 +129,7 @@ abstract class TimelineViewModel(
|
|||
timelineCases.bookmark(status.actionableId, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -211,6 +216,9 @@ abstract class TimelineViewModel(
|
|||
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() {
|
||||
viewModelScope.launch {
|
||||
val filters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
Log.e(TAG, "Failed to fetch filters", t)
|
||||
val filters = api.getFilters().getOrElse {
|
||||
Log.e(TAG, "Failed to fetch filters", it)
|
||||
return@launch
|
||||
}
|
||||
filterModel.initWithFilters(
|
||||
|
|
|
@ -62,6 +62,7 @@ class ThreadAdapter(
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ThreadAdapter"
|
||||
private const val VIEW_TYPE_STATUS = 0
|
||||
private const val VIEW_TYPE_STATUS_DETAILED = 1
|
||||
|
||||
|
|
|
@ -21,7 +21,10 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
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.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
|
@ -48,6 +52,9 @@ import com.keylesspalace.tusky.util.show
|
|||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
@ -104,6 +111,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
binding.toolbar.setNavigationOnClickListener {
|
||||
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||
}
|
||||
binding.toolbar.inflateMenu(R.menu.view_thread_toolbar)
|
||||
binding.toolbar.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_reveal -> {
|
||||
|
@ -139,24 +147,50 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
|
||||
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
|
||||
|
||||
var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
||||
var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collect { uiState ->
|
||||
when (uiState) {
|
||||
is ThreadUiState.Loading -> {
|
||||
updateRevealButton(RevealButtonState.NO_BUTTON)
|
||||
|
||||
binding.recyclerView.hide()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.show()
|
||||
|
||||
initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500)
|
||||
initialProgressBar.start()
|
||||
}
|
||||
is ThreadUiState.LoadingThread -> {
|
||||
if (uiState.statusViewDatum == null) {
|
||||
// no detailed statuses available, e.g. because author is blocked
|
||||
activity?.finish()
|
||||
return@collect
|
||||
}
|
||||
|
||||
initialProgressBar.cancel()
|
||||
threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500)
|
||||
threadProgressBar.start()
|
||||
|
||||
adapter.submitList(listOf(uiState.statusViewDatum))
|
||||
|
||||
updateRevealButton(uiState.revealButton)
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
||||
binding.recyclerView.show()
|
||||
binding.statusView.hide()
|
||||
}
|
||||
is ThreadUiState.Error -> {
|
||||
Log.w(TAG, "failed to load status", uiState.throwable)
|
||||
initialProgressBar.cancel()
|
||||
threadProgressBar.cancel()
|
||||
|
||||
updateRevealButton(RevealButtonState.NO_BUTTON)
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
||||
binding.recyclerView.hide()
|
||||
binding.statusView.show()
|
||||
binding.progressBar.hide()
|
||||
|
||||
if (uiState.throwable is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
|
@ -169,22 +203,31 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
}
|
||||
}
|
||||
is ThreadUiState.Success -> {
|
||||
adapter.submitList(uiState.statuses) {
|
||||
if (viewModel.isInitialLoad) {
|
||||
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
|
||||
// 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
|
||||
val detailedPosition = adapter.currentList.indexOfFirst { viewData ->
|
||||
viewData.isDetailed
|
||||
}
|
||||
binding.recyclerView.scrollToPosition(detailedPosition)
|
||||
|
||||
// Ensure the top of the status is visible
|
||||
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0)
|
||||
}
|
||||
}
|
||||
|
||||
updateRevealButton(uiState.revealButton)
|
||||
binding.swipeRefreshLayout.isRefreshing = uiState.refreshing
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
||||
binding.recyclerView.show()
|
||||
binding.statusView.hide()
|
||||
binding.progressBar.hide()
|
||||
}
|
||||
is ThreadUiState.Refreshing -> {
|
||||
threadProgressBar.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -204,6 +247,28 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
viewModel.loadThread(thisThreadsStatusId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job to implement a delayed-visible progress bar.
|
||||
*
|
||||
* Delaying the visibility of the progress bar can improve user perception of UI speed because
|
||||
* fewer UI elements are appearing and disappearing.
|
||||
*
|
||||
* When started the job will wait `delayMs` then show `view`. If the job is cancelled at
|
||||
* any time `view` is hidden.
|
||||
*/
|
||||
@CheckResult()
|
||||
private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch(
|
||||
start = CoroutineStart.LAZY
|
||||
) {
|
||||
try {
|
||||
delay(delayMs)
|
||||
view.show()
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
view.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRevealButton(state: RevealButtonState) {
|
||||
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal)
|
||||
|
||||
|
@ -319,6 +384,17 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener,
|
|||
viewModel.voteInPoll(choices, status)
|
||||
}
|
||||
|
||||
override fun onShowEdits(position: Int) {
|
||||
val status = adapter.currentList[position]
|
||||
val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId)
|
||||
|
||||
parentFragmentManager.commit {
|
||||
setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right)
|
||||
replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id")
|
||||
addToBackStack(null)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ViewThreadFragment"
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.google.gson.Gson
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.BookmarkEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
|
@ -28,8 +29,10 @@ import com.keylesspalace.tusky.appstore.PinEvent
|
|||
import com.keylesspalace.tusky.appstore.ReblogEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
|
||||
import com.keylesspalace.tusky.components.timeline.toViewData
|
||||
import com.keylesspalace.tusky.components.timeline.util.ifExpected
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.FilterModel
|
||||
|
@ -54,7 +57,9 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private val filterModel: FilterModel,
|
||||
private val timelineCases: TimelineCases,
|
||||
eventHub: EventHub,
|
||||
accountManager: AccountManager
|
||||
accountManager: AccountManager,
|
||||
private val db: AppDatabase,
|
||||
private val gson: Gson
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
|
||||
|
@ -95,36 +100,70 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun loadThread(id: String) {
|
||||
_uiState.value = ThreadUiState.Loading
|
||||
|
||||
viewModelScope.launch {
|
||||
Log.d(TAG, "Finding status with: $id")
|
||||
val contextCall = async { api.statusContext(id) }
|
||||
val statusCall = async { api.statusAsync(id) }
|
||||
val timelineStatus = db.timelineDao().getStatus(id)
|
||||
|
||||
val contextResult = contextCall.await()
|
||||
val statusResult = statusCall.await()
|
||||
var detailedStatus = if (timelineStatus != null) {
|
||||
Log.d(TAG, "Loaded status from local timeline")
|
||||
val viewData = timelineStatus.toViewData(
|
||||
gson,
|
||||
isDetailed = true
|
||||
) as StatusViewData.Concrete
|
||||
|
||||
val status = statusResult.getOrElse { exception ->
|
||||
_uiState.value = ThreadUiState.Error(exception)
|
||||
return@launch
|
||||
// Return the correct status, depending on which one matched. If you do not do
|
||||
// this the status IDs will be different between the status that's displayed with
|
||||
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
|
||||
// status content is the same. Then the status flickers as it is drawn twice.
|
||||
if (viewData.actionableId == id) {
|
||||
viewData.actionable.toViewData(isDetailed = true)
|
||||
} else {
|
||||
viewData
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Loaded status from network")
|
||||
val result = api.status(id).getOrElse { exception ->
|
||||
_uiState.value = ThreadUiState.Error(exception)
|
||||
return@launch
|
||||
}
|
||||
result.toViewData(isDetailed = true)
|
||||
}
|
||||
|
||||
contextResult.fold({ statusContext ->
|
||||
_uiState.value = ThreadUiState.LoadingThread(
|
||||
statusViewDatum = detailedStatus,
|
||||
revealButton = detailedStatus.getRevealButtonState()
|
||||
)
|
||||
|
||||
// If the detailedStatus was loaded from the database it might be out-of-date
|
||||
// compared to the remote one. Now the user has a working UI do a background fetch
|
||||
// for the status. Ignore errors, the user still has a functioning UI if the fetch
|
||||
// failed.
|
||||
if (timelineStatus != null) {
|
||||
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true)
|
||||
if (viewData != null) { detailedStatus = viewData }
|
||||
}
|
||||
|
||||
val contextResult = contextCall.await()
|
||||
|
||||
contextResult.fold({ statusContext ->
|
||||
val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter()
|
||||
val detailedStatus = status.toViewData(true)
|
||||
val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter()
|
||||
val statuses = ancestors + detailedStatus + descendants
|
||||
|
||||
_uiState.value = ThreadUiState.Success(
|
||||
statuses = statuses,
|
||||
revealButton = statuses.getRevealButtonState(),
|
||||
refreshing = false
|
||||
statusViewData = statuses,
|
||||
detailedStatusPosition = ancestors.size,
|
||||
revealButton = statuses.getRevealButtonState()
|
||||
)
|
||||
}, { throwable ->
|
||||
_errors.emit(throwable)
|
||||
_uiState.value = ThreadUiState.Success(
|
||||
statuses = listOf(status.toViewData(true)),
|
||||
statusViewData = listOf(detailedStatus),
|
||||
detailedStatusPosition = 0,
|
||||
revealButton = RevealButtonState.NO_BUTTON,
|
||||
refreshing = false
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -136,15 +175,17 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun refresh(id: String) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(refreshing = true)
|
||||
}
|
||||
_uiState.value = ThreadUiState.Refreshing
|
||||
loadThread(id)
|
||||
}
|
||||
|
||||
fun detailedStatus(): StatusViewData.Concrete? {
|
||||
return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status ->
|
||||
status.isDetailed
|
||||
return when (val uiState = _uiState.value) {
|
||||
is ThreadUiState.Success -> uiState.statusViewData.find { status ->
|
||||
status.isDetailed
|
||||
}
|
||||
is ThreadUiState.LoadingThread -> uiState.statusViewDatum
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -173,7 +214,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
timelineCases.bookmark(status.actionableId, bookmark).await()
|
||||
} catch (t: Exception) {
|
||||
ifExpected(t) {
|
||||
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
|
||||
Log.d(TAG, "Failed to bookmark status " + status.actionableId, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -201,14 +242,14 @@ class ViewThreadViewModel @Inject constructor(
|
|||
fun removeStatus(statusToRemove: StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filterNot { status -> status == statusToRemove }
|
||||
statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses.map { viewData ->
|
||||
val statuses = uiState.statusViewData.map { viewData ->
|
||||
if (viewData.id == status.id) {
|
||||
viewData.copy(isExpanded = expanded)
|
||||
} else {
|
||||
|
@ -216,7 +257,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
uiState.copy(
|
||||
statuses = statuses,
|
||||
statusViewData = statuses,
|
||||
revealButton = statuses.getRevealButtonState()
|
||||
)
|
||||
}
|
||||
|
@ -261,8 +302,8 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private fun removeAllByAccountId(accountId: String) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filter { viewData ->
|
||||
viewData.status.account.id == accountId
|
||||
statusViewData = uiState.statusViewData.filter { viewData ->
|
||||
viewData.status.account.id != accountId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -271,7 +312,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private fun handleStatusComposedEvent(event: StatusComposedEvent) {
|
||||
val eventStatus = event.status
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses
|
||||
val statuses = uiState.statusViewData
|
||||
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
|
||||
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
|
||||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
||||
|
@ -279,7 +320,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
||||
eventStatus.toViewData() +
|
||||
statuses.subList(repliedIndex + 1, statuses.size)
|
||||
uiState.copy(statuses = newStatuses)
|
||||
uiState.copy(statusViewData = newStatuses)
|
||||
} else {
|
||||
uiState
|
||||
}
|
||||
|
@ -289,7 +330,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.filter { status ->
|
||||
statusViewData = uiState.statusViewData.filter { status ->
|
||||
status.id != event.statusId
|
||||
}
|
||||
)
|
||||
|
@ -300,13 +341,13 @@ class ViewThreadViewModel @Inject constructor(
|
|||
updateSuccess { uiState ->
|
||||
when (uiState.revealButton) {
|
||||
RevealButtonState.HIDE -> uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
statusViewData = uiState.statusViewData.map { viewData ->
|
||||
viewData.copy(isExpanded = false)
|
||||
},
|
||||
revealButton = RevealButtonState.REVEAL
|
||||
)
|
||||
RevealButtonState.REVEAL -> uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
statusViewData = uiState.statusViewData.map { viewData ->
|
||||
viewData.copy(isExpanded = true)
|
||||
},
|
||||
revealButton = RevealButtonState.HIDE
|
||||
|
@ -316,16 +357,11 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
|
||||
val hasWarnings = any { viewData ->
|
||||
viewData.status.spoilerText.isNotEmpty()
|
||||
}
|
||||
private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState {
|
||||
val hasWarnings = status.spoilerText.isNotEmpty()
|
||||
|
||||
return if (hasWarnings) {
|
||||
val allExpanded = none { viewData ->
|
||||
!viewData.isExpanded
|
||||
}
|
||||
if (allExpanded) {
|
||||
if (isExpanded) {
|
||||
RevealButtonState.HIDE
|
||||
} else {
|
||||
RevealButtonState.REVEAL
|
||||
|
@ -335,14 +371,38 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reveal button state based on the state of all the statuses in the list.
|
||||
*
|
||||
* - If any status sets it to REVEAL, use REVEAL
|
||||
* - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE
|
||||
* - Otherwise use NO_BUTTON
|
||||
*/
|
||||
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
|
||||
var seenHide = false
|
||||
|
||||
forEach {
|
||||
when (val state = it.getRevealButtonState()) {
|
||||
RevealButtonState.NO_BUTTON -> return@forEach
|
||||
RevealButtonState.REVEAL -> return state
|
||||
RevealButtonState.HIDE -> seenHide = true
|
||||
}
|
||||
}
|
||||
|
||||
if (seenHide) {
|
||||
return RevealButtonState.HIDE
|
||||
}
|
||||
|
||||
return RevealButtonState.NO_BUTTON
|
||||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
viewModelScope.launch {
|
||||
val filters = try {
|
||||
api.getFilters().await()
|
||||
} catch (t: Exception) {
|
||||
Log.w(TAG, "Failed to fetch filters", t)
|
||||
val filters = api.getFilters().getOrElse {
|
||||
Log.w(TAG, "Failed to fetch filters", it)
|
||||
return@launch
|
||||
}
|
||||
|
||||
filterModel.initWithFilters(
|
||||
filters.filter { filter ->
|
||||
filter.context.contains(Filter.THREAD)
|
||||
|
@ -350,9 +410,9 @@ class ViewThreadViewModel @Inject constructor(
|
|||
)
|
||||
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statuses.filter()
|
||||
val statuses = uiState.statusViewData.filter()
|
||||
uiState.copy(
|
||||
statuses = statuses,
|
||||
statusViewData = statuses,
|
||||
revealButton = statuses.getRevealButtonState()
|
||||
)
|
||||
}
|
||||
|
@ -365,13 +425,15 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id }
|
||||
private fun Status.toViewData(
|
||||
isDetailed: Boolean = false
|
||||
): StatusViewData.Concrete {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id }
|
||||
return toViewData(
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
||||
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = oldStatus?.isCollapsed ?: !detailed,
|
||||
isDetailed = oldStatus?.isDetailed ?: detailed
|
||||
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
||||
isDetailed = oldStatus?.isDetailed ?: isDetailed
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -388,7 +450,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) {
|
||||
updateSuccess { uiState ->
|
||||
uiState.copy(
|
||||
statuses = uiState.statuses.map { viewData ->
|
||||
statusViewData = uiState.statusViewData.map { viewData ->
|
||||
if (viewData.id == statusId) {
|
||||
updater(viewData)
|
||||
} else {
|
||||
|
@ -413,13 +475,27 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
sealed interface ThreadUiState {
|
||||
/** The initial load of the detailed status for this thread */
|
||||
object Loading : ThreadUiState
|
||||
class Error(val throwable: Throwable) : ThreadUiState
|
||||
data class Success(
|
||||
val statuses: List<StatusViewData.Concrete>,
|
||||
val revealButton: RevealButtonState,
|
||||
val refreshing: Boolean
|
||||
|
||||
/** Loading the detailed status has completed, now loading ancestors/descendants */
|
||||
data class LoadingThread(
|
||||
val statusViewDatum: StatusViewData.Concrete?,
|
||||
val revealButton: RevealButtonState
|
||||
) : ThreadUiState
|
||||
|
||||
/** An error occurred at any point */
|
||||
class Error(val throwable: Throwable) : ThreadUiState
|
||||
|
||||
/** Successfully loaded the full thread */
|
||||
data class Success(
|
||||
val statusViewData: List<StatusViewData.Concrete>,
|
||||
val revealButton: RevealButtonState,
|
||||
val detailedStatusPosition: Int
|
||||
) : ThreadUiState
|
||||
|
||||
/** Refreshing the thread with a swipe */
|
||||
object Refreshing : ThreadUiState
|
||||
}
|
||||
|
||||
enum class RevealButtonState {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -54,6 +54,7 @@ data class AccountEntity(
|
|||
var notificationsSubscriptions: Boolean = true,
|
||||
var notificationsSignUps: Boolean = true,
|
||||
var notificationsUpdates: Boolean = true,
|
||||
var notificationsReports: Boolean = true,
|
||||
var notificationSound: Boolean = true,
|
||||
var notificationVibration: Boolean = true,
|
||||
var notificationLight: Boolean = true,
|
||||
|
@ -61,6 +62,7 @@ data class AccountEntity(
|
|||
var defaultMediaSensitivity: Boolean = false,
|
||||
var defaultPostLanguage: String = "",
|
||||
var alwaysShowSensitiveMedia: Boolean = false,
|
||||
/** True if content behind a content warning is shown by default */
|
||||
var alwaysOpenSpoiler: Boolean = false,
|
||||
var mediaPreviewEnabled: Boolean = true,
|
||||
var lastNotificationId: String = "0",
|
||||
|
|
|
@ -45,9 +45,8 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
init {
|
||||
accounts = accountDao.loadAll().toMutableList()
|
||||
|
||||
activeAccount = accounts.find { acc ->
|
||||
acc.isActive
|
||||
}
|
||||
activeAccount = accounts.find { acc -> acc.isActive }
|
||||
?: accounts.firstOrNull()?.also { acc -> acc.isActive = true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -169,15 +168,17 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
*/
|
||||
fun setActiveAccount(accountId: Long) {
|
||||
|
||||
val newActiveAccount = accounts.find { (id) ->
|
||||
id == accountId
|
||||
} ?: return // invalid accountId passed, do nothing
|
||||
|
||||
activeAccount?.let {
|
||||
Log.d(TAG, "setActiveAccount: saving account with id " + it.id)
|
||||
it.isActive = false
|
||||
saveAccount(it)
|
||||
}
|
||||
|
||||
activeAccount = accounts.find { (id) ->
|
||||
id == accountId
|
||||
}
|
||||
activeAccount = newActiveAccount
|
||||
|
||||
activeAccount?.let {
|
||||
it.isActive = true
|
||||
|
|
|
@ -31,7 +31,7 @@ import java.io.File;
|
|||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 43)
|
||||
}, version = 47)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
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 ''");
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -126,13 +126,13 @@ class Converters @Inject constructor (
|
|||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToLong(date: Date): Long {
|
||||
return date.time
|
||||
fun dateToLong(date: Date?): Long? {
|
||||
return date?.time
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun longToDate(date: Long): Date {
|
||||
return Date(date)
|
||||
fun longToDate(date: Long?): Date? {
|
||||
return date?.let { Date(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
|
@ -30,6 +31,12 @@ interface DraftDao {
|
|||
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC")
|
||||
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")
|
||||
suspend fun loadDrafts(accountId: Long): List<DraftEntity>
|
||||
|
||||
|
|
|
@ -40,8 +40,10 @@ data class DraftEntity(
|
|||
val attachments: List<DraftAttachment>,
|
||||
val poll: NewPoll?,
|
||||
val failedToSend: Boolean,
|
||||
val failedToSendNew: Boolean,
|
||||
val scheduledAt: String?,
|
||||
val language: String?,
|
||||
val statusId: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
99
app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt
Normal file
99
app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ abstract class TimelineDao {
|
|||
@Query(
|
||||
"""
|
||||
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.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,
|
||||
|
@ -53,6 +53,29 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
|
|||
)
|
||||
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.serverId, s.url, s.timelineUserId,
|
||||
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt,
|
||||
s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
|
||||
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
|
||||
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language,
|
||||
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(
|
||||
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
|
||||
(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")
|
||||
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]
|
||||
*/
|
||||
|
@ -200,4 +230,12 @@ AND timelineUserId = :accountId
|
|||
|
||||
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
|
||||
abstract suspend fun getStatusCount(accountId: Long): Int
|
||||
|
||||
/** Developer tools: Find N most recent status IDs */
|
||||
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count")
|
||||
abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List<String>
|
||||
|
||||
/** Developer tools: Convert a status to a placeholder */
|
||||
@Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId")
|
||||
abstract suspend fun convertStatustoPlaceholder(serverId: String)
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ data class TimelineStatusEntity(
|
|||
val inReplyToAccountId: String?,
|
||||
val content: String?,
|
||||
val createdAt: Long,
|
||||
val editedAt: Long?,
|
||||
val emojis: String?,
|
||||
val reblogsCount: Int,
|
||||
val favouritesCount: Int,
|
||||
|
@ -76,13 +77,17 @@ data class TimelineStatusEntity(
|
|||
val reblogAccountId: String?,
|
||||
val poll: String?,
|
||||
val muted: Boolean?,
|
||||
val expanded: Boolean, // used as the "loading" attribute when this TimelineStatusEntity is a placeholder
|
||||
/** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */
|
||||
val expanded: Boolean,
|
||||
val contentCollapsed: Boolean,
|
||||
val contentShowing: Boolean,
|
||||
val pinned: Boolean,
|
||||
val card: String?,
|
||||
val language: String?,
|
||||
)
|
||||
) {
|
||||
val isPlaceholder: Boolean
|
||||
get() = this.authorServerId == null
|
||||
}
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["serverId", "timelineUserId"]
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue