Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2022-11-10 22:20:09 +09:00
commit 004a9b4921
No known key found for this signature in database
GPG Key ID: F7BDE7DD42BF366A
268 changed files with 9249 additions and 4145 deletions

View File

@ -19,7 +19,7 @@ def getGitSha = {
}
android {
compileSdkVersion 31
compileSdkVersion 33
defaultConfig {
applicationId 'net.accelf.yuito'
minSdkVersion 21
@ -99,118 +99,72 @@ android {
}
}
ext.coroutinesVersion = "1.6.1"
ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.42'
ext.materialdrawerVersion = '8.4.5'
ext.emoji2_version = '1.1.0'
ext.filemojicompat_version = '3.2.2'
repositories {
maven {
url 'https://maven.accelf.net/'
}
}
// if libraries are changed here, they should also be changed in LicenseActivity
// library versions are in PROJECT_ROOT/gradle/libs.versions.toml
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.coroutines.rx3
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.browser:browser:1.4.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji2:emoji2:$emoji2_version"
implementation "androidx.emoji2:emoji2-views:$emoji2_version"
implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.paging:paging-runtime-ktx:3.1.1"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
implementation libs.bundles.androidx
implementation libs.bundles.room
kapt libs.androidx.room.compiler
implementation "com.google.android.material:material:1.6.0"
implementation libs.android.material
implementation "com.google.code.gson:gson:2.9.0"
implementation libs.gson
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "at.connyduck:networkresult-calladapter:1.0.0"
implementation libs.bundles.retrofit
implementation libs.networkresult.calladapter
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
implementation libs.bundles.okhttp
implementation "org.conscrypt:conscrypt-android:2.5.2"
implementation libs.conscrypt.android
implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation libs.bundles.glide
kapt libs.glide.compiler
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.22.0"
implementation libs.bundles.rxjava3
implementation "io.reactivex.rxjava3:rxjava:3.1.3"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
implementation "io.reactivex.rxjava3:rxkotlin:3.0.1"
implementation libs.bundles.autodispose
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"
implementation "com.uber.autodispose2:autodispose:2.1.1"
implementation libs.bundles.dagger
kapt libs.bundles.dagger.processors
implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion"
implementation "com.google.dagger:dagger-android:$daggerVersion"
implementation "com.google.dagger:dagger-android-support:$daggerVersion"
kapt "com.google.dagger:dagger-android-processor:$daggerVersion"
implementation libs.sparkbutton
implementation "com.github.connyduck:sparkbutton:4.1.0"
implementation libs.photoview
implementation "com.github.chrisbanes:PhotoView:2.3.0"
implementation libs.bundles.material.drawer
implementation libs.material.typeface, {
artifact {
type = "aar"
}
}
implementation "com.mikepenz:materialdrawer:$materialdrawerVersion"
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation libs.image.cropper
implementation "com.github.CanHub:Android-Image-Cropper:4.2.1"
implementation libs.bundles.filemojicompat
implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
implementation "de.c1710:filemojicompat:$filemojicompat_version"
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
implementation libs.bouncycastle
implementation libs.unified.push
implementation "org.bouncycastle:bcprov-jdk15on:1.70"
implementation "com.github.UnifiedPush:android-connector:2.0.0"
testImplementation libs.androidx.test.junit
testImplementation libs.robolectric
testImplementation libs.bundles.mockito
testImplementation libs.mockwebserver
testImplementation libs.androidx.core.testing
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.androidx.work.testing
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:4.4.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
androidTestImplementation libs.espresso.core
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.androidx.test.junit
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
implementation 'net.accelf:easter:1.0.2'
implementation 'org.jsoup:jsoup:1.13.1'
implementation libs.accelfeaster
implementation libs.jsoup
}

View File

@ -0,0 +1,929 @@
{
"formatVersion": 1,
"database": {
"version": 40,
"identityHash": "0423fb3f7d09db5f12023f2f4e7297b5",
"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)",
"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
}
],
"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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER 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": "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": "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, 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
}
],
"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, 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
}
],
"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, '0423fb3f7d09db5f12023f2f4e7297b5')"
]
}
}

View File

@ -0,0 +1,935 @@
{
"formatVersion": 1,
"database": {
"version": 41,
"identityHash": "1de8f20c7f28e1f11b33e7a55137feef",
"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)",
"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
}
],
"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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER 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": "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": "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, 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
}
],
"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, 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
}
],
"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, '1de8f20c7f28e1f11b33e7a55137feef')"
]
}
}

View File

@ -0,0 +1,959 @@
{
"formatVersion": 1,
"database": {
"version": 42,
"identityHash": "2a851b591f39b1dfe9bb0eb62cf49ef3",
"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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER 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": "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": "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, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repliesCount",
"columnName": "repliesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quote",
"columnName": "quote",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_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, '2a851b591f39b1dfe9bb0eb62cf49ef3')"
]
}
}

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<size android:width="108dp" android:height="108dp" />
<gradient
android:type="radial"
android:centerX="50%"
android:centerY="50%"
android:startColor="#25d069"
android:endColor="#19a341"
android:gradientRadius="100"/>
</shape>

View File

@ -3,4 +3,6 @@
<color name="notification_color">#19A341</color>
<color name="icon_background">#097b44</color>
<color name="icon_highlight">#39ff9e</color>
</resources>

View File

@ -5,12 +5,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.VIBRATE" /> <!-- For notifications -->
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="22" /> <!-- for day/night mode -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
@ -102,11 +100,12 @@
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name=".ViewThreadActivity"
android:name=".components.viewthread.ViewThreadActivity"
android:configChanges="orientation|screenSize" />
<activity
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
android:theme="@style/TuskyBaseTheme"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity
android:name=".components.account.AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
@ -174,7 +173,7 @@
<service
android:name=".service.TuskyTileService"
android:icon="@drawable/ic_tusky"
android:icon="@mipmap/ic_launcher"
android:label="@string/tusky_compose_post_quicksetting_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,488 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg181"
viewBox="0 0 135.46666 135.46666"
version="1.1"
height="512"
width="512"
sodipodi:docname="ic_launcher2.svg"
inkscape:version="0.92.1 r15371">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1147"
id="namedview4579"
showgrid="false"
inkscape:measure-start="140.54,184.424"
inkscape:measure-end="499.461,-175.02"
inkscape:zoom="1.9140625"
inkscape:cx="253.80846"
inkscape:cy="248.95369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g157" />
<metadata
id="metadata185">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs147">
<linearGradient
inkscape:collect="always"
id="linearGradient4780">
<stop
style="stop-color:#2588d0;stop-opacity:1;"
offset="0"
id="stop4776" />
<stop
style="stop-color:#1967a3;stop-opacity:1"
offset="1"
id="stop4778" />
</linearGradient>
<linearGradient
id="c">
<stop
id="stop2"
offset="0" />
<stop
id="stop4"
offset="1"
stop-opacity="0" />
</linearGradient>
<clipPath
id="g">
<circle
id="circle7"
fill="#2588d0"
r="112.52"
cy="125"
cx="125" />
</clipPath>
<clipPath
id="e">
<g
id="g132"
opacity=".05"
fill="url(#a)"
clip-path="url(#g)"
transform="matrix(.26458 0 0 .26458 7.7244 -29.561)">
<g
id="g128"
stroke-width=".5">
<path
id="path10"
fill="url(#a)"
d="m98.535 73.473c-1.8371-0.09054-2.2298 0.10241-1.635 0.69722l70.711 70.711c-0.59481-0.59481-0.2021-0.78775 1.635-0.69722z" />
<path
id="path12"
fill="url(#a)"
d="m96.9 74.17c0.70818 0.70818 2.8162 1.986 5.553 4.0332l70.711 70.711c-2.7368-2.0472-4.8448-3.3251-5.553-4.0332z" />
<path
id="path14"
fill="url(#a)"
d="m102.45 78.203c2.4807 1.8557 4.9237 4.0232 7.1498 6.2492l70.711 70.711c-2.226-2.226-4.6691-4.3935-7.1498-6.2492z" />
<path
id="path16"
fill="url(#a)"
d="m109.6 84.452c3.2825 3.2825 6.093 6.6922 7.8561 9.4168l70.711 70.711c-1.7631-2.7246-4.5736-6.1343-7.8561-9.4168z" />
<path
id="path18"
fill="url(#a)"
d="m117.46 93.869c2.9912 4.6225 5.6692 8.3037 12.262 28.254l70.711 70.711c-6.5925-19.95-9.2705-23.631-12.262-28.254z" />
<path
id="path20"
fill="url(#a)"
d="m129.72 122.12c2.4794 7.5032 5.7538 14.14 6.6211 16.113l70.711 70.711c-0.86733-1.9736-4.1417-8.6101-6.6211-16.113z" />
<path
id="path22"
fill="url(#a)"
d="m136.34 138.24 1.3476 3.0059 70.711 70.711-1.3476-3.0059z" />
<path
id="path24"
fill="url(#a)"
d="m137.69 141.24c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l70.711 70.711c0.0663-0.0833 0.12024-0.14516 0.18554-0.22656z" />
<path
id="path26"
fill="url(#a)"
d="m137.5 141.47-3.4395 3.0098 70.711 70.711 3.4395-3.0098z" />
<path
id="path28"
fill="url(#a)"
d="m134.06 144.48c-5.5481 5.7637-16.033 11.691-24.285 13.729l70.711 70.711c8.2524-2.0372 18.737-7.9648 24.285-13.729z" />
<path
id="path30"
fill="url(#a)"
d="m109.78 158.21c-6.3614 1.5706-21.301 1.1651-27.809 0.0137l70.711 70.711c6.5077 1.1514 21.447 1.5569 27.809-0.0137z" />
<path
id="path32"
fill="url(#a)"
d="m81.971 158.22c-7.8527-1.3894-14.205-5.074-19.134-10.002l70.711 70.711c4.9282 4.9282 11.281 8.6128 19.134 10.002z" />
<path
id="path34"
fill="url(#a)"
d="m62.837 148.22c-6.1069-6.1069-10.026-14.123-11.902-22.049l70.711 70.711c1.8752 7.9251 5.7946 15.942 11.902 22.049z" />
<path
id="path36"
fill="url(#a)"
d="m50.936 126.17c-1.5987-9.5426 0.52116-16.959 1.6895-21.707l70.711 70.711c-1.1683 4.7477-3.2882 12.164-1.6894 21.707z" />
<path
id="path38"
fill="url(#a)"
d="m52.625 104.46c2.6637-10.825 9.7356-20.465 18.479-26.402l70.711 70.711c-8.7429 5.9372-15.815 15.578-18.479 26.402z" />
<path
id="path40"
fill="url(#a)"
d="m71.104 78.061c3.0009-2.0378 4.6792-2.7066 4.4597-2.9261l70.711 70.711c0.21947 0.21947-1.4588 0.88834-4.4597 2.9261z" />
<path
id="path42"
fill="url(#a)"
d="m75.563 75.134c-0.11026-0.11026-0.69947-0.10709-1.8406-0.10709l70.711 70.711c1.1411 0 1.7303-3e-3 1.8406 0.10709z" />
<path
id="path44"
fill="url(#a)"
d="m73.723 75.027c-3.9668 0-10.077 2.6389-14.275 6.0215l70.711 70.711c4.1987-3.3826 10.309-6.0215 14.275-6.0215z" />
<path
id="path46"
fill="url(#a)"
d="m59.447 81.049c-8.0199 6.461-14.768 15.598-18.961 26.336l70.711 70.711c4.1931-10.738 10.941-19.875 18.961-26.336z" />
<path
id="path48"
fill="url(#a)"
d="m40.486 107.38c-2.2433 6.6848-2.2747 5.5109-2.418 15.162l70.711 70.711c0.14324-9.6512 0.17463-8.4773 2.418-15.162z" />
<path
id="path50"
fill="url(#a)"
d="m38.068 122.55c0.37633 6.9829 1.0389 9.3071 3.0723 15.406l70.711 70.711c-2.0333-6.0991-2.6959-8.4234-3.0723-15.406z" />
<path
id="path52"
fill="url(#a)"
d="m41.141 137.95c2.3472 7.0404 6.2768 13.161 11.343 18.227l70.711 70.711c-5.0663-5.0663-8.9959-11.187-11.343-18.227z" />
<path
id="path54"
fill="url(#a)"
d="m52.484 156.18c2.9744 2.9744 6.3406 5.5855 10.008 7.806l70.711 70.711c-3.6678-2.2205-7.034-4.8316-10.008-7.806z" />
<path
id="path56"
fill="url(#a)"
d="m62.492 163.99c4.9985 3.0261 15.324 6.7023 22.098 7.8027l70.711 70.711c-6.7734-1.1005-17.099-4.7766-22.098-7.8027z" />
<path
id="path58"
fill="url(#a)"
d="m84.59 171.79c1.8904 0.30967 4.992 0.90865 7.8848 1.1875l70.711 70.711c-2.8928-0.27885-5.9944-0.87783-7.8848-1.1875z" />
<path
id="path60"
fill="url(#a)"
d="m92.475 172.98c12.195 0.94625 15.055-0.32666 24.506-1.8418l70.711 70.711c-9.4508 1.5151-12.311 2.788-24.506 1.8418z" />
<path
id="path62"
fill="url(#a)"
d="m116.98 171.13c8.5906-2.4983 16.678-7.275 22.934-12.367l70.711 70.711c-6.2561 5.0922-14.343 9.8689-22.934 12.367z" />
<path
id="path64"
fill="url(#a)"
d="m139.91 158.77 5.7871-4.7168 70.711 70.711-5.7871 4.7168z" />
<path
id="path66"
fill="url(#a)"
d="m145.7 154.05c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l70.711 70.711c-0.0864-0.10738-0.19411-0.26225-0.28321-0.375z" />
<path
id="path68"
fill="url(#a)"
d="m145.98 154.43 1.9648 2.0644 70.711 70.711-1.9648-2.0644z" />
<path
id="path70"
fill="url(#a)"
d="m147.95 156.49c0.29948 0.31455 0.59902 0.62179 0.89884 0.92161l70.711 70.711c-0.29982-0.29982-0.59936-0.60705-0.89884-0.9216z" />
<path
id="path72"
fill="url(#a)"
d="m148.85 157.41c5.8411 5.8411 11.787 8.8672 19.377 8.303l70.711 70.711c-7.5898 0.56423-13.535-2.4619-19.377-8.303z" />
<path
id="path74"
fill="url(#a)"
d="m168.22 165.71c7.5551-0.56163 13.041-2.6761 17.49-6.7422l70.711 70.711c-4.4488 4.066-9.9351 6.1806-17.49 6.7422z" />
<path
id="path76"
fill="url(#a)"
d="m185.71 158.97c4.5132-4.1248 5.5354-6.236 7.6699-12.512l70.711 70.711c-2.1345 6.2758-3.1567 8.3869-7.6699 12.512z" />
<path
id="path78"
fill="url(#a)"
d="m193.38 146.46c2.9552-8.6889 4.3184-16.193 3.9922-29.721l70.711 70.711c0.32618 13.527-1.0369 21.032-3.9922 29.721z" />
<path
id="path80"
fill="url(#a)"
d="m197.38 116.74c-0.33294-13.818-1.6567-19.14-5.4434-23.453l70.711 70.711c3.7867 4.3128 5.1104 9.6355 5.4434 23.453z" />
<path
id="path82"
fill="url(#a)"
d="m191.93 93.287c-0.0959-0.10288-0.19218-0.20269-0.28888-0.2994l70.711 70.711c0.0967 0.0967 0.19301 0.19652 0.28888 0.2994z" />
<path
id="path84"
fill="url(#a)"
d="m191.64 92.988c-3.0781-3.0781-6.553-3.0044-8.5724 1.8287l70.711 70.711c2.0194-4.8331 5.4943-4.9068 8.5724-1.8287z" />
<path
id="path86"
fill="url(#a)"
d="m183.07 94.816c-0.79737 1.9089-0.78178 2.91 0.11132 6.918l70.711 70.711c-0.8931-4.008-0.90869-5.0091-0.11132-6.918z" />
<path
id="path88"
fill="url(#a)"
d="m183.18 101.73c1.6289 7.3102 1.7837 25.828 0.45313 32.072l70.711 70.711c1.3306-6.2445 1.1758-24.762-0.45313-32.072z" />
<path
id="path90"
fill="url(#a)"
d="m183.64 133.81c-2.7904 13.095-11.463 17.859-22.207 12.49l70.711 70.711c10.744 5.3688 19.417 0.60451 22.207-12.49z" />
<path
id="path92"
fill="url(#a)"
d="m161.43 146.3c-2.4274-1.213-3.8861-1.9048-5.748-3.5762l70.711 70.711c1.862 1.6714 3.3206 2.3632 5.748 3.5762z" />
<path
id="path94"
fill="url(#a)"
d="m155.68 142.72c-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101l70.711 70.711c0.17623 0.18753 0.31813 0.34519 0.50391 0.54101z" />
<path
id="path96"
fill="url(#a)"
d="m155.18 142.18c1.047-1.7899 1.5781-3.0116 2.7754-4.9883l70.711 70.711c-1.1973 1.9767-1.7284 3.1984-2.7754 4.9883z" />
<path
id="path98"
fill="url(#a)"
d="m157.95 137.19c4.9715-8.2077 9.7196-21.436 11.604-32.795l70.711 70.711c-1.8839 11.359-6.632 24.587-11.604 32.795z" />
<path
id="path100"
fill="url(#a)"
d="m169.56 104.4c1.1273-6.797 0.81713-9.1729-0.83126-10.821l70.711 70.711c1.6484 1.6484 1.9586 4.0243 0.83126 10.821z" />
<path
id="path102"
fill="url(#a)"
d="m168.73 93.575c-0.32731-0.32731-0.70738-0.62594-1.1394-0.92479l70.711 70.711c0.43206 0.29886 0.81213 0.59748 1.1394 0.92479z" />
<path
id="path104"
fill="url(#a)"
d="m167.59 92.65c-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844l70.711 70.711c2.12-0.65064 2.9438-0.58478 4.9531 0.39844z" />
<path
id="path106"
fill="url(#a)"
d="m162.63 92.252c-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531l70.711 70.711c0.58337-0.64492 2.2067-1.5238 3.6055-1.9531z" />
<path
id="path108"
fill="url(#a)"
d="m159.03 94.205c-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945l70.711 70.711c1.1145-4.2479 2.5043-8.2496 3.0879-8.8945z" />
<path
id="path110"
fill="url(#a)"
d="m155.94 103.1c-1.1144 4.2479-2.7555 9.2858-3.6465 11.193l70.711 70.711c0.89102-1.9076 2.532-6.9455 3.6465-11.193z" />
<path
id="path112"
fill="url(#a)"
d="m152.29 114.29c-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777l70.711 70.711c0.28157-0.94013 1.2399-3.2702 2.1309-5.1777z" />
<path
id="path114"
fill="url(#a)"
d="m150.16 119.47c-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l70.711 70.711c0.94515-1.8798 1.9489-4.1868 2.2305-5.127z" />
<path
id="path116"
fill="url(#a)"
d="m147.93 124.6-2 4.0059 70.711 70.711 2-4.0059z" />
<path
id="path118"
fill="url(#a)"
d="m145.93 128.6-1.1016-2.5469 70.711 70.711 1.1016 2.5469z" />
<path
id="path120"
fill="url(#a)"
d="m144.83 126.06c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898l70.711 70.711c2.4766 6.0168 4.6425 11.771 5.1504 12.898z" />
<path
id="path122"
fill="url(#a)"
d="m139.68 113.16c-5.7036-13.857-11.141-23.408-17.401-29.668l70.711 70.711c6.2598 6.2598 11.697 15.811 17.401 29.668z" />
<path
id="path124"
fill="url(#a)"
d="m122.28 83.49c-6.0835-6.0835-12.944-9.058-21.58-9.8534l70.711 70.711c8.6358 0.79538 15.496 3.7699 21.58 9.8534z" />
<path
id="path126"
fill="url(#a)"
d="m100.7 73.637c-0.8718-0.08029-1.5892-0.13573-2.1641-0.16406l70.711 70.711c0.57491 0.0283 1.2923 0.0838 2.1641 0.16407z" />
</g>
<path
id="path130"
fill="url(#a)"
d="m98.535 73.473c-4.0243-0.19833-1.1175 0.96369 3.918 4.7305 6.1387 4.592 12.047 11.094 15.006 15.666 2.9912 4.6225 5.6692 8.3037 12.262 28.254 2.4794 7.5032 5.7538 14.14 6.6211 16.113l1.3476 3.0059c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l-3.4395 3.0098c-5.5481 5.7637-16.033 11.691-24.285 13.729-6.3614 1.5706-21.301 1.1651-27.809 0.0137-17.584-3.1111-27.647-17.73-31.035-32.051-1.5987-9.5426 0.52116-16.959 1.6895-21.707 2.6637-10.825 9.7356-20.465 18.479-26.402 4.5085-3.0615 6.0316-3.0332 2.6191-3.0332-3.9668 0-10.077 2.6389-14.275 6.0215-8.0199 6.461-14.768 15.598-18.961 26.336-2.2433 6.6848-2.2747 5.5109-2.418 15.162 0.37633 6.9829 1.0389 9.3071 3.0723 15.406 3.7252 11.174 11.436 20.03 21.352 26.033 4.9985 3.0261 15.324 6.7023 22.098 7.8027 1.8904 0.30967 4.992 0.90865 7.8848 1.1875 12.195 0.94625 15.055-0.32666 24.506-1.8418 8.5906-2.4983 16.678-7.275 22.934-12.367l5.7871-4.7168c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l1.9648 2.0644c6.134 6.4426 12.296 9.8178 20.275 9.2246 7.5551-0.56163 13.041-2.6761 17.49-6.7422 4.5132-4.1248 5.5354-6.236 7.6699-12.512 2.9552-8.6889 4.3184-16.193 3.9922-29.721-0.33294-13.818-1.6567-19.14-5.4434-23.453-3.1472-3.3774-6.7785-3.4556-8.8613 1.5293-0.79737 1.9089-0.78178 2.91 0.11132 6.918 1.6289 7.3102 1.7837 25.828 0.45313 32.072-2.7904 13.095-11.463 17.859-22.207 12.49-2.4274-1.213-3.8861-1.9048-5.748-3.5762-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101 1.047-1.7899 1.5781-3.0116 2.7754-4.9883 4.9715-8.2077 9.7196-21.436 11.604-32.795 1.3511-8.1466 0.63728-9.9421-1.9707-11.746-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945-1.1144 4.2479-2.7555 9.2858-3.6465 11.193-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l-2 4.0059-1.1016-2.5469c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898-11.247-27.323-21.459-37.908-38.98-39.522-0.8718-0.080295-1.5892-0.13573-2.1641-0.16406z" />
</g>
</clipPath>
<linearGradient
xlink:href="#c"
gradientUnits="userSpaceOnUse"
y2="210.2"
y1="153.65"
x2="201.21"
x1="146.2"
id="a" />
<clipPath
id="f">
<circle
id="circle136"
opacity=".1"
clip-path="url(#e)"
r="29.772"
cy="2.6396"
cx="40.292"
transform="matrix(1.0037 0 0 1.0037 1.3836 -1.2671)" />
</clipPath>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="22.179"
y1="6.5707"
x2="61.309"
x1="45.701"
id="b">
<stop
id="stop139"
offset="0" />
<stop
id="stop141"
offset="1"
stop-opacity="0" />
</linearGradient>
<filter
color-interpolation-filters="sRGB"
height="1.048"
width="1.048"
y="-.024"
x="-.024"
id="d">
<feGaussianBlur
id="feGaussianBlur144"
stdDeviation="13.532652" />
</filter>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4599">
<circle
cx="725.02002"
cy="-684.40002"
id="circle4601"
style="display:inline;fill:#2588d0;stroke-width:1"
r="666.01202" />
</clipPath>
<linearGradient
inkscape:collect="always"
xlink:href="#b"
id="linearGradient4609"
x1="932.39093"
y1="-448.59128"
x2="1103.4291"
y2="-292.29779"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4780"
id="radialGradient4782"
cx="725.02002"
cy="-684.40002"
fx="725.02002"
fy="-684.40002"
r="666.01202"
gradientUnits="userSpaceOnUse" />
</defs>
<g
style="display:inline"
id="g177"
transform="translate(-8.0628,99.804)">
<g
style="display:inline"
id="g175"
transform="matrix(2,0,0,2,-5.3112,-36.986)">
<g
style="display:inline"
id="g173"
transform="matrix(0.046875,0,0,0.046875,6.3858,34.613)">
<g
style="display:inline"
id="g157">
<circle
style="display:inline;opacity:0.36000001;filter:url(#d)"
id="circle149"
r="676.63"
cy="-672.09003"
cx="710.14001"
transform="matrix(0.98504,0,0,0.98504,29.403,-5.6005)" />
<circle
r="666.01202"
style="display:inline;fill:url(#radialGradient4782);fill-opacity:1"
id="ellipse153"
cy="-684.40002"
cx="725.02002" />
<path
style="opacity:0.1;fill:url(#linearGradient4609);fill-opacity:1.0;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 502.01302,-949.02227 384.24915,383.32798 -3.43645,-89.82106 -77.68618,-110.93471 -187.18224,-187.80303 137.91285,50.53567 165.36793,165.01042 87.04612,-110.32758 124.5448,123.76483 1.5739,-125.34778 679.4554,673.25412 -544.5472,559.35788 -866.24945,-865.97293 -82.57017,-258.03173 64.87656,-135.65098 z"
id="path4595"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccc"
clip-path="url(#clipPath4599)" />
</g>
<g
style="display:inline;stroke-width:0.052917"
id="g171"
transform="matrix(21.768,0,0,21.716,-148.18,-741.68)">
<g
style="display:inline;stroke:#efefef"
id="g167">
<g
style="display:inline;stroke:#efefef;stroke-width:0.052917"
id="g165">
<g
style="display:inline;stroke-width:0.052853"
id="g163"
transform="matrix(1.0012,0,0,1.0012,-0.2806,-0.13189)">
<path
style="display:inline;fill:#fefefe;stroke:#e9e9eb"
id="path159"
d="M 33.997,14.956 C 27.2465,14.30008 22.721,10.953 21.162,5.4628 c -0.97232,-3.4236 -0.40007,-6.6554 1.7689,-9.9897 1.963,-3.0177 4.5842,-4.9793 6.6538,-4.9793 h 0.55721 l -0.68124,0.45556 c -2.3327,1.5599 -4.1732,3.9523 -5.0456,6.5585 -0.51198,1.5295 -0.64528,4.5724 -0.27628,6.3067 0.63604,2.9894 2.4635,5.3876 5.0854,6.6734 1.7788,0.87238 3.409,1.2191 5.7324,1.2191 3.8422,0 6.9587,-1.203 9.6137,-3.711 l 0.90726,-0.85704 -0.45582,-1.0092 c -0.40414,-0.89475 -1.3238,-3.3221 -3.0074,-7.9377 -0.66266,-1.8167 -1.3902,-3.0367 -2.6217,-4.3966 -0.83567,-0.92274 -2.4857,-2.3558 -3.8598,-3.3523 -0.41933,-0.3041 -0.41152,-0.30624 0.78481,-0.21562 4.547,0.34444 6.8542,2.8969 10.192,11.276 0.58286,1.463 0.84878,2.0983 0.96426,2.3599 0.02953,0.0669 0.07669,0.21387 0.13247,0.16887 0.05579,-0.044998 0.2425,-0.39246 0.30776,-0.50578 0.06527,-0.11332 0.13756,-0.25964 0.22146,-0.43667 0.0839,-0.17703 0.1794,-0.38476 0.29108,-0.62088 0.81832,-1.7301 1.8142,-4.5739 2.1755,-6.2124 0.17685,-0.80191 0.31139,-1.0422 0.6944,-1.2403 1.2546,-0.64879 2.3739,-0.046158 2.3739,1.2782 0,2.3978 -1.562,7.2043 -3.3553,10.325 -0.11458,0.1994 -0.2299,0.32992 -0.28958,0.44825 -0.05969,0.11833 -0.22117,0.32942 -0.18689,0.38982 0.0262,0.046164 0.03631,0.058028 0.07857,0.1003 0.04494,0.044952 0.10546,0.1025 0.18606,0.16729 0.59444,0.47791 2.1261,1.0701 2.9132,1.178 1.0012,0.13722 2.1429,-0.34975 2.7808,-1.186 1.4436,-1.8927 1.7772,-5.2915 1.0067,-10.256 -0.28139,-1.8131 -0.2804,-1.8507 0.0612,-2.3095 0.8275,-1.1115 1.8285,-0.9019 2.5288,0.52951 1.4339,2.9307 1.2081,10.094 -0.43371,13.76 -0.9119,2.0362 -2.4949,3.1576 -5.135,3.6376 -2.1119,0.38401 -3.6271,-0.10683 -5.3738,-1.7407 l -1.0573,-0.98905 -0.49724,0.52519 c -1.2916,1.3642 -4.2775,3.0339 -6.4849,3.6264 -1.0871,0.29178 -3.7581,0.64007 -4.619,0.60228 -0.28227,-0.01239 -1.0906,-0.07862 -1.7963,-0.14719 z"
inkscape:connector-curvature="0" />
<path
style="display:inline;fill:#e9e9eb;stroke:#efefef"
id="path161"
d="M 49.244,6.7482 C 48.93619,6.36517 48.43741,5.6114 48.1356,5.0733 L 47.58686,4.09477 48.05882,3.2394 C 48.3184,2.76894 48.70519,1.8739 48.94989,1.2573 l 0.39948,-0.98883 1.3378,1.3563 c 0.8003,0.81132 1.266,1.4388 1.2301,1.6104 -0.08034,0.3845 -1.217,2.7582 -1.713,3.561 l -0.40066,0.6485 z"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
<path
style="display:inline;fill:none;stroke:#e9e9eb"
id="path169"
d="m 45.251,7.0162 c 0,0 0.6208,1.4785 1.9195,3.2142"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</g>
<style
id="style179">.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@ -132,7 +132,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
getOnBackPressedDispatcher().onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);

View File

@ -27,6 +27,7 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
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.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@ -35,8 +36,8 @@ import java.net.URISyntaxException
import javax.inject.Inject
/** this is the base class for all activities that open links
* links are checked against the api if they are mastodon links so they can be openend in Tusky
* Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierachy
* links are checked against the api if they are mastodon links so they can be opened in Tusky
* Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierarchy
*/
abstract class BottomSheetActivity : BaseActivity() {

View File

@ -28,6 +28,7 @@ import android.widget.ImageView
import androidx.activity.viewModels
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -37,6 +38,7 @@ import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
@ -50,6 +52,7 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import javax.inject.Inject
class EditProfileActivity : BaseActivity(), Injectable {
@ -58,8 +61,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val AVATAR_SIZE = 400
const val HEADER_WIDTH = 1500
const val HEADER_HEIGHT = 500
private const val MAX_ACCOUNT_FIELDS = 4
}
@Inject
@ -71,6 +72,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS
private enum class PickType {
AVATAR,
HEADER
@ -112,7 +115,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
if (accountFieldEditAdapter.itemCount >= maxAccountFields) {
it.isVisible = false
}
@ -134,7 +137,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS
binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
if (viewModel.avatarData.value == null) {
Glide.with(this)
@ -165,13 +169,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
viewModel.obtainInstance()
viewModel.instanceData.observe(this) { result ->
if (result is Success) {
val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
}
lifecycleScope.launch {
viewModel.instanceData.collect { instanceInfo ->
maxAccountFields = instanceInfo.maxFields
accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
binding.addFieldButton.isVisible =
accountFieldEditAdapter.itemCount < maxAccountFields
}
}

View File

@ -1,26 +1,25 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.text.format.DateUtils
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
import com.keylesspalace.tusky.databinding.DialogFilterBinding
import com.keylesspalace.tusky.entity.Filter
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.view.getSecondsForDurationIndex
import com.keylesspalace.tusky.view.setupEditDialogForFilter
import com.keylesspalace.tusky.view.showAddFilterDialog
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
@ -47,7 +46,7 @@ class FiltersActivity : BaseActivity() {
setDisplayShowHomeEnabled(true)
}
binding.addFilterButton.setOnClickListener {
showAddFilterDialog()
showAddFilterDialog(this)
}
title = intent?.getStringExtra(FILTERS_TITLE)
@ -55,15 +54,10 @@ class FiltersActivity : BaseActivity() {
loadFilters()
}
private fun updateFilter(filter: Filter, itemIndex: Int) {
api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt)
.enqueue(object : Callback<Filter> {
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val updatedFilter = response.body()!!
fun updateFilter(id: String, phrase: String, filterContext: List<String>, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) {
lifecycleScope.launch {
api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold(
{ updatedFilter ->
if (updatedFilter.context.contains(context)) {
filters[itemIndex] = updatedFilter
} else {
@ -71,25 +65,30 @@ class FiltersActivity : BaseActivity() {
}
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
})
)
}
}
private fun deleteFilter(itemIndex: Int) {
fun deleteFilter(itemIndex: Int) {
val filter = filters[itemIndex]
if (filter.context.size == 1) {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
}
})
lifecycleScope.launch {
// This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).fold(
{
filters.removeAt(itemIndex)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
},
{
Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
}
)
}
} else {
// Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex]
@ -97,69 +96,50 @@ class FiltersActivity : BaseActivity() {
oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(newFilter, itemIndex)
updateFilter(
newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord,
getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex
)
}
}
private fun createFilter(phrase: String, wholeWord: Boolean) {
api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object : Callback<Filter> {
override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val filterResponse = response.body()
if (response.isSuccessful && filterResponse != null) {
filters.add(filterResponse)
fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) {
lifecycleScope.launch {
api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold(
{ filter ->
filters.add(filter)
refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context))
} else {
},
{
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
}
override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show()
}
})
}
private fun showAddFilterDialog() {
val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
}
private fun setupEditDialogForItem(itemIndex: Int) {
val binding = DialogFilterBinding.inflate(layoutInflater)
val filter = filters[itemIndex]
binding.phraseEditText.setText(filter.phrase)
binding.phraseWholeWord.isChecked = filter.wholeWord
AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex]
val newFilter = Filter(
oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked
)
updateFilter(newFilter, itemIndex)
}
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
deleteFilter(itemIndex)
}
.setNeutralButton(android.R.string.cancel, null)
.show()
)
}
}
private fun refreshFilterDisplay() {
binding.filtersView.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, filters.map { filter -> filter.phrase })
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForItem(position) }
binding.filtersView.adapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_1,
filters.map { filter ->
if (filter.expiresAt == null) {
filter.phrase
} else {
getString(
R.string.filter_expiration_format,
filter.phrase,
DateUtils.getRelativeTimeSpanString(
filter.expiresAt.time,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
)
}
}
)
binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) }
}
private fun loadFilters() {

View File

@ -20,7 +20,7 @@ import android.util.Log
import android.widget.TextView
import androidx.annotation.RawRes
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.closeQuietly
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
@ -61,7 +61,7 @@ class LicenseActivity : BaseActivity() {
Log.w("LicenseActivity", e)
}
IOUtils.closeQuietly(br)
br.closeQuietly()
textView.text = sb.toString()
}

View File

@ -15,10 +15,12 @@
package com.keylesspalace.tusky
import android.Manifest
import android.annotation.SuppressLint
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
@ -38,10 +40,12 @@ import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import androidx.activity.viewModels
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.GravityCompat
@ -85,6 +89,7 @@ import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
@ -203,6 +208,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (accountRequested && accountId != activeAccount.id) {
accountManager.setActiveAccount(accountId)
}
val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false)
if (canHandleMimeType(intent.type)) {
// Sharing to Tusky from an external app
if (accountRequested) {
@ -227,9 +235,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
)
}
} else if (openDrafts) {
val intent = DraftsActivity.newIntent(this)
startActivity(intent)
} else if (accountRequested && savedInstanceState == null) {
// user clicked a notification, show notification tab
showNotificationTab = true
// user clicked a notification, show follow requests for type FOLLOW_REQUEST,
// otherwise show notification tab
if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) {
val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true)
startActivityWithSlideInAnimation(intent)
} else {
showNotificationTab = true
}
}
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
@ -292,6 +309,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
else -> {
finish()
}
}
}
}
)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
}
override fun onPause() {
@ -337,20 +381,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun onBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
else -> {
super.onBackPressed()
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_MENU -> {
@ -426,7 +456,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
closeDrawerOnProfileListClick = true
}
header.accountHeaderBackground.setColorFilter(ContextCompat.getColor(this, R.color.headerBackgroundFilter))
header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent))
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
@ -606,7 +636,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun tintCheckIcon(item: MenuItem) {
if (item.isChecked) {
@Suppress("DEPRECATION")
item.icon.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN)
item.icon?.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN)
} else {
ThemeUtils.setDrawableTint(this, item.icon, android.R.attr.textColorTertiary)
}
@ -1000,6 +1030,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.clear()
header.profiles = profiles
header.setActiveProfile(accountManager.activeAccount!!.id)
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) {
accountManager.activeAccount!!.fullName
} else null
}
override fun getActionButton() = binding.composeButton
@ -1011,6 +1044,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val REDIRECT_URL = "redirectUrl"
const val OPEN_DRAFTS = "draft"
}
}

View File

@ -18,17 +18,25 @@ package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.appstore.EventHub
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
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.di.ViewModelFactory
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import kotlinx.coroutines.launch
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import net.accelf.yuito.QuickTootViewModel
import javax.inject.Inject
@ -44,16 +52,21 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private val quickTootViewModel: QuickTootViewModel by viewModels{ viewModelFactory }
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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityStatuslistBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
val kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
val listId = intent.getStringExtra(EXTRA_LIST_ID)
val hashtag = intent.getStringExtra(EXTRA_HASHTAG)
hashtag = intent.getStringExtra(EXTRA_HASHTAG)
val title = when (kind) {
Kind.FAVOURITES -> getString(R.string.title_favourites)
@ -88,6 +101,70 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val tag = hashtag
if (kind == Kind.TAG && tag != null) {
lifecycleScope.launch {
mastodonApi.tag(tag).fold(
{ tagEntity ->
menuInflater.inflate(R.menu.view_hashtag_toolbar, menu)
followTagItem = menu.findItem(R.id.action_follow_hashtag)
unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag)
followTagItem?.isVisible = tagEntity.following == false
unfollowTagItem?.isVisible = tagEntity.following == true
followTagItem?.setOnMenuItemClickListener { followTag() }
unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() }
},
{
Log.w(TAG, "Failed to query tag #$tag", it)
}
)
}
}
return super.onCreateOptionsMenu(menu)
}
private fun followTag(): Boolean {
val tag = hashtag
if (tag != null) {
lifecycleScope.launch {
mastodonApi.followTag(tag).fold(
{
followTagItem?.isVisible = false
unfollowTagItem?.isVisible = true
},
{
Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to follow #$tag", it)
}
)
}
}
return true
}
private fun unfollowTag(): Boolean {
val tag = hashtag
if (tag != null) {
lifecycleScope.launch {
mastodonApi.unfollowTag(tag).fold(
{
followTagItem?.isVisible = true
unfollowTagItem?.isVisible = false
},
{
Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show()
Log.e(TAG, "Failed to unfollow #$tag", it)
}
)
}
}
return true
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
@ -96,6 +173,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private const val EXTRA_LIST_ID = "id"
private const val EXTRA_LIST_TITLE = "title"
private const val EXTRA_HASHTAG = "tag"
const val TAG = "StatusListActivity"
fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {

View File

@ -20,9 +20,9 @@ import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@ -74,6 +74,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) }
private val onFabDismissedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
toggleFab(false)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -149,6 +155,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT)
updateAvailableTabs()
onBackPressedDispatcher.addCallback(onFabDismissedCallback)
}
override fun onTabAdded(tab: TabData) {
@ -209,6 +217,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
binding.actionButton.visible(!expand)
binding.sheet.visible(expand)
binding.scrim.visible(expand)
onFabDismissedCallback.isEnabled = expand
}
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
@ -338,14 +348,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
tabsChanged = true
}
override fun onBackPressed() {
if (binding.actionButton.isVisible) {
super.onBackPressed()
} else {
toggleFab(false)
}
}
override fun onPause() {
super.onPause()
if (tabsChanged) {

View File

@ -47,6 +47,7 @@ import autodispose2.autoDispose
import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
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

View File

@ -1,130 +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;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentTransaction;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import android.view.Menu;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.ViewThreadFragment;
import com.keylesspalace.tusky.util.LinkHelper;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector;
public class ViewThreadActivity extends BottomSheetActivity implements HasAndroidInjector {
public static final int REVEAL_BUTTON_HIDDEN = 1;
public static final int REVEAL_BUTTON_REVEAL = 2;
public static final int REVEAL_BUTTON_HIDE = 3;
public static Intent startIntent(Context context, String id, String url) {
Intent intent = new Intent(context, ViewThreadActivity.class);
intent.putExtra(ID_EXTRA, id);
intent.putExtra(URL_EXTRA, url);
return intent;
}
private static final String ID_EXTRA = "id";
private static final String URL_EXTRA = "url";
private static final String FRAGMENT_TAG = "ViewThreadFragment_";
private int revealButtonState = REVEAL_BUTTON_HIDDEN;
@Inject
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
private ViewThreadFragment fragment;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_thread);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setTitle(R.string.title_view_thread);
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setDisplayShowHomeEnabled(true);
}
String id = getIntent().getStringExtra(ID_EXTRA);
fragment = (ViewThreadFragment)getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG + id);
if(fragment == null) {
fragment = ViewThreadFragment.newInstance(id);
}
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
fragmentTransaction.replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id);
fragmentTransaction.commit();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.view_thread_toolbar, menu);
MenuItem menuItem = menu.findItem(R.id.action_reveal);
menuItem.setVisible(revealButtonState != REVEAL_BUTTON_HIDDEN);
menuItem.setIcon(revealButtonState == REVEAL_BUTTON_REVEAL ?
R.drawable.ic_eye_24dp : R.drawable.ic_hide_media_24dp);
return super.onCreateOptionsMenu(menu);
}
public void setRevealButtonState(int state) {
switch (state) {
case REVEAL_BUTTON_HIDDEN:
case REVEAL_BUTTON_REVEAL:
case REVEAL_BUTTON_HIDE:
this.revealButtonState = state;
invalidateOptionsMenu();
break;
default:
throw new IllegalArgumentException("Invalid reveal button state: " + state);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_open_in_web: {
openLink(getIntent().getStringExtra(URL_EXTRA));
return true;
}
case R.id.action_reveal: {
fragment.onRevealPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Override
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;
}
}

View File

@ -27,6 +27,8 @@ import com.keylesspalace.tusky.util.BindingHolder
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
private val fieldData = mutableListOf<MutableStringPair>()
private var maxNameLength: Int? = null
private var maxValueLength: Int? = null
fun setFields(fields: List<StringField>) {
fieldData.clear()
@ -41,6 +43,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
notifyDataSetChanged()
}
fun setFieldLimits(maxNameLength: Int?, maxValueLength: Int?) {
this.maxNameLength = maxNameLength
this.maxValueLength = maxValueLength
notifyDataSetChanged()
}
fun getFieldData(): List<StringField> {
return fieldData.map {
StringField(it.first, it.second)
@ -60,10 +68,20 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
}
override fun onBindViewHolder(holder: BindingHolder<ItemEditFieldBinding>, position: Int) {
holder.binding.accountFieldName.setText(fieldData[position].first)
holder.binding.accountFieldValue.setText(fieldData[position].second)
holder.binding.accountFieldNameText.setText(fieldData[position].first)
holder.binding.accountFieldValueText.setText(fieldData[position].second)
holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher {
holder.binding.accountFieldNameTextLayout.isCounterEnabled = maxNameLength != null
maxNameLength?.let {
holder.binding.accountFieldNameTextLayout.counterMaxLength = it
}
holder.binding.accountFieldValueTextLayout.isCounterEnabled = maxValueLength != null
maxValueLength?.let {
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
}
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].first = newText.toString()
}
@ -73,7 +91,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
})
holder.binding.accountFieldValue.addTextChangedListener(object : TextWatcher {
holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].second = newText.toString()
}

View File

@ -0,0 +1,43 @@
/* 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.adapter
import android.content.Context
import android.graphics.Typeface
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import com.keylesspalace.tusky.util.ThemeUtils
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))
typeface = Typeface.DEFAULT_BOLD
text = super.getItem(position)?.language?.uppercase()
}
}
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)})"
}
}
}

View File

@ -178,12 +178,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
return;
}
NotificationViewData.Concrete concreteNotificaton =
NotificationViewData.Concrete concreteNotification =
(NotificationViewData.Concrete) notification;
switch (viewHolder.getItemViewType()) {
case VIEW_TYPE_STATUS: {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
StatusViewData.Concrete status = concreteNotification.getStatusViewData();
if (status == null) {
/* in some very rare cases servers sends null status even though they should not,
* we have to handle it somehow */
@ -194,8 +194,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
}
if (concreteNotificaton.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId()));
if (concreteNotification.getType() == Notification.Type.POLL) {
holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId()));
} else {
holder.hideStatusInfo();
}
@ -203,7 +203,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
case VIEW_TYPE_STATUS_NOTIFICATION: {
StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder;
StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData();
StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData();
if (payloadForHolder == null) {
if (statusViewData == null) {
/* in some very rare cases servers sends null status even though they should not,
@ -217,19 +217,19 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if (concreteNotificaton.getType() == Notification.Type.STATUS ||
concreteNotificaton.getType() == Notification.Type.UPDATE) {
if (concreteNotification.getType() == Notification.Type.STATUS ||
concreteNotification.getType() == Notification.Type.UPDATE) {
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else {
holder.setAvatars(status.getAccount().getAvatar(),
concreteNotificaton.getAccount().getAvatar());
concreteNotification.getAccount().getAvatar());
}
}
holder.setMessage(concreteNotificaton, statusListener);
holder.setMessage(concreteNotification, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotificaton.getAccount().getId(),
concreteNotificaton.getId());
concreteNotification.getAccount().getId(),
concreteNotification.getId());
} else {
if (payloadForHolder instanceof List)
for (Object item : (List) payloadForHolder) {
@ -243,16 +243,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId());
holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId());
}
break;
}
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotificaton.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(accountActionListener, concreteNotificaton.getAccount().getId());
holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId());
}
break;
}
@ -499,7 +499,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
Drawable icon = ContextCompat.getDrawable(context, drawable);
if (icon != null) {
icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP);
icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP);
}
return icon;
}

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemPollBinding
@ -97,7 +96,7 @@ class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
}
resultTextView.background.level = level
resultTextView.background.setTint(ContextCompat.getColor(resultTextView.context, optionColor))
resultTextView.background.setTint(resultTextView.context.getColor(optionColor))
resultTextView.setOnClickListener(resultClickListener)
}
SINGLE -> {

View File

@ -29,10 +29,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.imageview.ShapeableImageView;
import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
import com.keylesspalace.tusky.util.AttachmentHelper;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -58,8 +59,6 @@ import com.keylesspalace.tusky.viewdata.PollViewData;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import net.accelf.yuito.QuoteInlineHelper;
import java.text.NumberFormat;
import java.util.Date;
import java.util.List;
@ -70,6 +69,8 @@ import kotlin.collections.CollectionsKt;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
import net.accelf.yuito.QuoteInlineHelper;
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public static class Key {
public static final String KEY_CREATED = "created";
@ -106,7 +107,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private LinearLayout cardView;
private LinearLayout cardInfo;
private ImageView cardImage;
private ShapeableImageView cardImage;
private TextView cardTitle;
private TextView cardDescription;
private TextView cardUrl;
@ -644,7 +645,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (i < attachments.size()) {
Attachment attachment = attachments.get(i);
mediaLabel.setVisibility(View.VISIBLE);
mediaDescriptions[i] = getAttachmentDescription(context, attachment);
mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context);
updateMediaLabel(i, sensitive, showingContent);
// Set the icon next to the label.
@ -671,24 +672,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
});
view.setOnLongClickListener(v -> {
CharSequence description = getAttachmentDescription(view.getContext(), attachment);
CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext());
Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show();
return true;
});
}
private static CharSequence getAttachmentDescription(Context context, Attachment attachment) {
String duration = "";
if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) {
duration = formatDuration(attachment.getMeta().getDuration()) + " ";
}
if (TextUtils.isEmpty(attachment.getDescription())) {
return duration + context.getString(R.string.description_post_media_no_description_placeholder);
} else {
return duration + attachment.getDescription();
}
}
protected void hideSensitiveMediaWarning() {
sensitiveMediaWarning.setVisibility(View.GONE);
sensitiveMediaShow.setVisibility(View.GONE);
@ -721,7 +710,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
replyButton.setClickable(!isNotestock);
if (reblogButton != null) {
reblogButton.setEventListener((button, buttonState) -> {
// return true to play animaion
// return true to play animation
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmReblogs()) {
@ -738,7 +727,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
favouriteButton.setEventListener((button, buttonState) -> {
// return true to play animaion
// return true to play animation
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
if (statusDisplayOptions.confirmFavourites()) {
@ -817,9 +806,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
private void showConfirmFavouriteDialog(StatusActionListener listener,
String statusContent,
boolean buttonState,
int position) {
String statusContent,
boolean buttonState,
int position) {
int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite;
new AlertDialog.Builder(favouriteButton.getContext())
.setMessage(statusContent)
@ -994,16 +983,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
int resource;
switch (visibility) {
case PUBLIC:
resource = R.string.description_visiblity_public;
resource = R.string.description_visibility_public;
break;
case UNLISTED:
resource = R.string.description_visiblity_unlisted;
resource = R.string.description_visibility_unlisted;
break;
case PRIVATE:
resource = R.string.description_visiblity_private;
resource = R.string.description_visibility_private;
break;
case DIRECT:
resource = R.string.description_visiblity_direct;
resource = R.string.description_visibility_direct;
break;
default:
return "";
@ -1178,13 +1167,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0;
int topRightRadius = 0;
int bottomRightRadius = 0;
int bottomLeftRadius = 0;
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder();
if (card.getWidth() > card.getHeight()) {
cardView.setOrientation(LinearLayout.VERTICAL);
@ -1194,8 +1179,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
topLeftRadius = radius;
topRightRadius = radius;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
@ -1203,19 +1188,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
topLeftRadius = radius;
bottomLeftRadius = radius;
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius);
cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius);
}
RequestBuilder<Drawable> builder = Glide.with(cardImage).load(card.getImage());
cardImage.setShapeAppearanceModel(cardImageShape.build());
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
RequestBuilder<Drawable> builder = Glide.with(cardImage.getContext())
.load(card.getImage())
.dontTransform();
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
}
builder.transform(
new CenterCrop(),
new GranularRoundedCorners(topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius)
)
.into(cardImage);
builder.into(cardImage);
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
@ -1226,11 +1213,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
Glide.with(cardImage).load(decodeBlurHash(card.getBlurhash()))
.transform(
new CenterCrop(),
new GranularRoundedCorners(radius, 0, 0, radius)
)
ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
.build();
cardImage.setShapeAppearanceModel(cardImageShape);
cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP);
Glide.with(cardImage.getContext())
.load(decodeBlurHash(card.getBlurhash()))
.dontTransform()
.into(cardImage);
} else {
cardView.setOrientation(LinearLayout.HORIZONTAL);
@ -1239,16 +1233,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.setImageResource(R.drawable.card_image_placeholder);
cardImage.setShapeAppearanceModel(new ShapeAppearanceModel());
cardImage.setScaleType(ImageView.ScaleType.CENTER);
Glide.with(cardImage.getContext())
.load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder))
.into(cardImage);
}
View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl(), "");
View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url()));
cardInfo.setOnClickListener(visitLink);
cardView.setOnClickListener(visitLink);
// View embedded photos in our image viewer instead of opening the browser
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbed_url()) ?
openImage :
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ?
v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) :
visitLink);
cardView.setClipToOutline(true);
@ -1278,13 +1278,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bookmarkButton.setVisibility(visibility);
moreButton.setVisibility(visibility);
}
private static String formatDuration(double durationInSeconds) {
int seconds = (int) Math.round(durationInSeconds) % 60;
int minutes = (int) durationInSeconds % 3600 / 60;
int hours = (int) durationInSeconds / 3600;
return String.format("%d:%02d:%02d", hours, minutes, seconds);
}
}

View File

@ -1,8 +1,6 @@
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.widget.TextView;
@ -12,7 +10,6 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode;
@ -22,15 +19,13 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.text.DateFormat;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView reblogs;
private TextView favourites;
private View infoDivider;
public class StatusDetailedViewHolder extends StatusBaseViewHolder {
private final TextView reblogs;
private final TextView favourites;
private final View infoDivider;
StatusDetailedViewHolder(View view) {
public StatusDetailedViewHolder(View view) {
super(view);
reblogs = view.findViewById(R.id.status_reblogs);
favourites = view.findViewById(R.id.status_favourites);

View File

@ -1,129 +0,0 @@
/* 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.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class ThreadAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusActionListener: StatusActionListener
) : RecyclerView.Adapter<StatusBaseViewHolder>() {
private val statuses = mutableListOf<StatusViewData.Concrete>()
var detailedStatusPosition: Int = RecyclerView.NO_POSITION
private set
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder {
return when (viewType) {
VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false)
StatusViewHolder(view)
}
VIEW_TYPE_STATUS_DETAILED -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status_detailed, parent, false)
StatusDetailedViewHolder(view)
}
else -> error("Unknown item type: $viewType")
}
}
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) {
val status = statuses[position]
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
}
override fun getItemViewType(position: Int): Int {
return if (position == detailedStatusPosition) {
VIEW_TYPE_STATUS_DETAILED
} else {
VIEW_TYPE_STATUS
}
}
override fun getItemCount(): Int = statuses.size
fun setStatuses(statuses: List<StatusViewData.Concrete>?) {
this.statuses.clear()
this.statuses.addAll(statuses!!)
notifyDataSetChanged()
}
fun addItem(position: Int, statusViewData: StatusViewData.Concrete) {
statuses.add(position, statusViewData)
notifyItemInserted(position)
}
fun clearItems() {
val oldSize = statuses.size
statuses.clear()
detailedStatusPosition = RecyclerView.NO_POSITION
notifyItemRangeRemoved(0, oldSize)
}
fun addAll(position: Int, statuses: List<StatusViewData.Concrete>) {
this.statuses.addAll(position, statuses)
notifyItemRangeInserted(position, statuses.size)
}
fun addAll(statuses: List<StatusViewData.Concrete>) {
val end = statuses.size
this.statuses.addAll(statuses)
notifyItemRangeInserted(end, statuses.size)
}
fun removeItem(position: Int) {
statuses.removeAt(position)
notifyItemRemoved(position)
}
fun clear() {
statuses.clear()
detailedStatusPosition = RecyclerView.NO_POSITION
notifyDataSetChanged()
}
fun setItem(position: Int, status: StatusViewData.Concrete, notifyAdapter: Boolean) {
statuses[position] = status
if (notifyAdapter) {
notifyItemChanged(position)
}
}
fun getItem(position: Int): StatusViewData.Concrete? = statuses.getOrNull(position)
fun setDetailedStatusPosition(position: Int) {
if (position != detailedStatusPosition &&
detailedStatusPosition != RecyclerView.NO_POSITION
) {
val prior = detailedStatusPosition
detailedStatusPosition = position
notifyItemChanged(prior)
} else {
detailedStatusPosition = position
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1
}
}

View File

@ -31,7 +31,6 @@ import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@ -176,7 +175,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
*/
private fun loadResources() {
toolbarColor = ThemeUtils.getColor(this, R.attr.colorSurface)
statusBarColorTransparent = ContextCompat.getColor(this, R.color.transparent_statusbar_background)
statusBarColorTransparent = getColor(R.color.transparent_statusbar_background)
statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark)
avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size)
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)

View File

@ -35,7 +35,7 @@ class AccountPagerAdapter(
0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false)
1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false)
2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false)
3 -> AccountMediaFragment.newInstance(accountId, false)
3 -> AccountMediaFragment.newInstance(accountId)
else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds")
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -15,41 +15,35 @@
package com.keylesspalace.tusky.components.account.media
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
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.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.SingleObserver
import io.reactivex.rxjava3.disposables.Disposable
import retrofit2.Response
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import java.util.Random
import javax.inject.Inject
/**
@ -58,192 +52,98 @@ import javax.inject.Inject
* Fragment with multiple columns of media previews for the specified account.
*/
class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, Injectable {
class AccountMediaFragment :
Fragment(R.layout.fragment_timeline),
RefreshableFragment,
Injectable {
@Inject
lateinit var api: MastodonApi
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var accountId: String
private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory }
private val adapter = MediaGridAdapter()
private val statuses = mutableListOf<Status>()
private var fetchingStatus = FetchingStatus.NOT_FETCHING
private var isSwipeToRefreshEnabled: Boolean = true
private var needToRefresh = false
private val callback = object : SingleObserver<Response<List<Status>>> {
override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) {
binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.visibility = View.GONE
binding.topProgressBar.hide()
binding.statusView.show()
if (t is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
doInitialLoadingIfNeeded()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
doInitialLoadingIfNeeded()
}
}
}
Log.d(TAG, "Failed to fetch account media", t)
}
override fun onSuccess(response: Response<List<Status>>) {
fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) {
binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.visibility = View.GONE
binding.topProgressBar.hide()
val body = response.body()
body?.let { fetched ->
statuses.addAll(0, fetched)
// flatMap requires iterable but I don't want to box each array into list
val result = mutableListOf<AttachmentViewData>()
for (status in fetched) {
result.addAll(AttachmentViewData.list(status))
}
adapter.addTop(result)
if (result.isNotEmpty())
binding.recyclerView.scrollToPosition(0)
if (statuses.isEmpty()) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
}
}
}
}
override fun onSubscribe(d: Disposable) {}
}
private val bottomCallback = object : SingleObserver<Response<List<Status>>> {
override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING
Log.d(TAG, "Failed to fetch account media", t)
}
override fun onSuccess(response: Response<List<Status>>) {
fetchingStatus = FetchingStatus.NOT_FETCHING
val body = response.body()
body?.let { fetched ->
Log.d(TAG, "fetched ${fetched.size} statuses")
if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}")
statuses.addAll(fetched)
Log.d(TAG, "now there are ${statuses.size} statuses")
// flatMap requires iterable but I don't want to box each array into list
val result = mutableListOf<AttachmentViewData>()
for (status in fetched) {
result.addAll(AttachmentViewData.list(status))
}
adapter.addBottom(result)
}
}
override fun onSubscribe(d: Disposable) { }
}
private lateinit var adapter: AccountMediaGridAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true
accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
viewModel.accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
adapter = AccountMediaGridAdapter(
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
useBlurhash = useBlurhash,
context = view.context,
onAttachmentClickListener = ::onAttachmentClick
)
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val layoutManager = GridLayoutManager(view.context, columnCount)
val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing)
adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground)
binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0))
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount)
binding.recyclerView.adapter = adapter
if (isSwipeToRefreshEnabled) {
binding.swipeRefreshLayout.setOnRefreshListener {
refresh()
}
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
binding.swipeRefreshLayout.isEnabled = false
binding.statusView.visibility = View.GONE
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) {
if (dy > 0) {
val itemCount = layoutManager.itemCount
val lastItem = layoutManager.findLastCompletelyVisibleItemPosition()
if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) {
statuses.lastOrNull()?.let { (id) ->
Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)")
fetchingStatus = FetchingStatus.FETCHING_BOTTOM
api.accountStatuses(accountId, id, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(bottomCallback)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.media.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
adapter.addLoadStateListener { loadState ->
binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0)
if (loadState.refresh is LoadState.Error) {
binding.recyclerView.hide()
binding.statusView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { adapter.retry() }
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { adapter.retry() }
}
Log.w(TAG, "error loading account media", errorState.error)
} else {
binding.recyclerView.show()
binding.statusView.hide()
}
})
doInitialLoadingIfNeeded()
}
private fun refresh() {
binding.statusView.hide()
if (fetchingStatus != FetchingStatus.NOT_FETCHING) return
if (statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING
api.accountStatuses(accountId, null, null, null, null, true, null)
} else {
fetchingStatus = FetchingStatus.REFRESHING
api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
if (!isSwipeToRefreshEnabled)
binding.topProgressBar.show()
}
private fun doInitialLoadingIfNeeded() {
if (isAdded) {
binding.statusView.hide()
}
if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING
api.accountStatuses(accountId, null, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
} else if (needToRefresh)
refresh()
needToRefresh = false
}
private fun viewMedia(items: List<AttachmentViewData>, currentIndex: Int, view: View?) {
private fun onAttachmentClick(selected: AttachmentViewData, view: View) {
if (!selected.isRevealed) {
viewModel.revealAttachment(selected)
return
}
val attachmentsFromSameStatus = viewModel.attachmentData.filter { attachmentViewData ->
attachmentViewData.statusId == selected.statusId
}
val currentIndex = attachmentsFromSameStatus.indexOf(selected)
when (items[currentIndex].attachment.type) {
when (selected.attachment.type) {
Attachment.Type.IMAGE,
Attachment.Type.GIFV,
Attachment.Type.VIDEO,
Attachment.Type.AUDIO -> {
val intent = ViewMediaActivity.newIntent(context, items, currentIndex)
if (view != null && activity != null) {
val url = items[currentIndex].attachment.url
val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex)
if (activity != null) {
val url = selected.attachment.url
ViewCompat.setTransitionName(view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url)
startActivity(intent, options.toBundle())
@ -252,96 +152,26 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
}
}
Attachment.Type.UNKNOWN -> {
context?.openLink(items[currentIndex].attachment.url)
}
}
}
private enum class FetchingStatus {
NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING
}
inner class MediaGridAdapter :
RecyclerView.Adapter<MediaGridAdapter.MediaViewHolder>() {
var baseItemColor = Color.BLACK
private val items = mutableListOf<AttachmentViewData>()
private val itemBgBaseHSV = FloatArray(3)
private val random = Random()
fun addTop(newItems: List<AttachmentViewData>) {
items.addAll(0, newItems)
notifyItemRangeInserted(0, newItems.size)
}
fun addBottom(newItems: List<AttachmentViewData>) {
if (newItems.isEmpty()) return
val oldLen = items.size
items.addAll(newItems)
notifyItemRangeInserted(oldLen, newItems.size)
}
override fun onAttachedToRecyclerView(recycler_view: RecyclerView) {
val hsv = FloatArray(3)
Color.colorToHSV(baseItemColor, hsv)
super.onAttachedToRecyclerView(recycler_view)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder {
val view = SquareImageView(parent.context)
view.scaleType = ImageView.ScaleType.CENTER_CROP
return MediaViewHolder(view)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
itemBgBaseHSV[2] = random.nextFloat() * (1f - 0.3f) + 0.3f
holder.imageView.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
val item = items[position]
Glide.with(holder.imageView)
.load(item.attachment.previewUrl)
.centerInside()
.into(holder.imageView)
}
inner class MediaViewHolder(val imageView: ImageView) :
RecyclerView.ViewHolder(imageView),
View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
// saving some allocations
override fun onClick(v: View?) {
viewMedia(items, bindingAdapterPosition, imageView)
context?.openLink(selected.attachment.url)
}
}
}
override fun refreshContent() {
if (isAdded)
refresh()
else
needToRefresh = true
adapter.refresh()
}
companion object {
@JvmStatic
fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment {
fun newInstance(accountId: String): AccountMediaFragment {
val fragment = AccountMediaFragment()
val args = Bundle()
val args = Bundle(1)
args.putString(ACCOUNT_ID_ARG, accountId)
args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh)
fragment.arguments = args
return fragment
}
private const val ACCOUNT_ID_ARG = "account_id"
private const val TAG = "AccountMediaFragment"
private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh"
}
}

View File

@ -0,0 +1,126 @@
package com.keylesspalace.tusky.components.account.media
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.setPadding
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.bumptech.glide.Glide
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
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
class AccountMediaGridAdapter(
private val alwaysShowSensitiveMedia: Boolean,
private val useBlurhash: Boolean,
context: Context,
private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit
) : PagingDataAdapter<AttachmentViewData, BindingHolder<ItemAccountMediaBinding>>(
object : DiffUtil.ItemCallback<AttachmentViewData>() {
override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
return oldItem.attachment.id == newItem.attachment.id
}
override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean {
return oldItem == newItem
}
}
) {
private val baseItemBackgroundColor = ThemeUtils.getColor(context, R.attr.colorSurface)
private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator)
private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp)
private val itemBgBaseHSV = FloatArray(3)
private val random = Random()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAccountMediaBinding> {
val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false)
Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV)
itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f
binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemAccountMediaBinding>, position: Int) {
val context = holder.binding.root.context
getItem(position)?.let { item ->
val imageView = holder.binding.accountMediaImageView
val overlay = holder.binding.accountMediaImageViewOverlay
val blurhash = item.attachment.blurhash
val placeholder = if (useBlurhash && blurhash != null) {
decodeBlurHash(context, blurhash)
} else {
null
}
if (item.attachment.type == Attachment.Type.AUDIO) {
overlay.hide()
imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding))
Glide.with(imageView)
.load(R.drawable.ic_music_box_preview_24dp)
.centerInside()
.into(imageView)
imageView.contentDescription = item.attachment.getFormattedDescription(context)
} else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) {
overlay.show()
overlay.setImageDrawable(mediaHiddenDrawable)
imageView.setPadding(0)
Glide.with(imageView)
.load(placeholder)
.centerInside()
.into(imageView)
imageView.contentDescription = imageView.context.getString(R.string.post_media_hidden_title)
} else {
if (item.attachment.type == Attachment.Type.VIDEO || item.attachment.type == Attachment.Type.GIFV) {
overlay.show()
overlay.setImageDrawable(videoIndicator)
} else {
overlay.hide()
}
imageView.setPadding(0)
Glide.with(imageView)
.asBitmap()
.load(item.attachment.previewUrl)
.placeholder(placeholder)
.centerInside()
.into(imageView)
imageView.contentDescription = item.attachment.getFormattedDescription(context)
}
holder.binding.root.setOnClickListener {
onAttachmentClickListener(item, imageView)
}
holder.binding.root.setOnLongClickListener { view ->
val description = item.attachment.getFormattedDescription(view.context)
Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
true
}
}
}
}

View File

@ -0,0 +1,37 @@
/* 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.account.media
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.viewdata.AttachmentViewData
class AccountMediaPagingSource(
private val viewModel: AccountMediaViewModel
) : PagingSource<String, AttachmentViewData>() {
override fun getRefreshKey(state: PagingState<String, AttachmentViewData>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, AttachmentViewData> {
return if (params is LoadParams.Refresh) {
val list = viewModel.attachmentData.toList()
LoadResult.Page(list, null, list.lastOrNull()?.statusId)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View File

@ -0,0 +1,79 @@
/* 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.account.media
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class AccountMediaRemoteMediator(
private val api: MastodonApi,
private val viewModel: AccountMediaViewModel
) : RemoteMediator<String, AttachmentViewData>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, AttachmentViewData>
): MediatorResult {
try {
val statusResponse = when (loadType) {
LoadType.REFRESH -> {
api.accountStatuses(viewModel.accountId, onlyMedia = true)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.lastItemOrNull()?.statusId
if (maxId != null) {
api.accountStatuses(viewModel.accountId, maxId = maxId, onlyMedia = true)
} else {
return MediatorResult.Success(endOfPaginationReached = false)
}
}
}
val statuses = statusResponse.body()
if (!statusResponse.isSuccessful || statuses == null) {
return MediatorResult.Error(HttpException(statusResponse))
}
val attachments = statuses.flatMap { status ->
AttachmentViewData.list(status)
}
if (loadType == LoadType.REFRESH) {
viewModel.attachmentData.clear()
}
viewModel.attachmentData.addAll(attachments)
viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) {
return ifExpected(e) {
MediatorResult.Error(e)
}
}
}
}

View File

@ -0,0 +1,64 @@
/* 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.account.media
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.network.MastodonApi
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import javax.inject.Inject
class AccountMediaViewModel @Inject constructor (
api: MastodonApi
) : ViewModel() {
lateinit var accountId: String
val attachmentData: MutableList<AttachmentViewData> = mutableListOf()
var currentSource: AccountMediaPagingSource? = null
@OptIn(ExperimentalPagingApi::class)
val media = Pager(
config = PagingConfig(
pageSize = LOAD_AT_ONCE,
prefetchDistance = LOAD_AT_ONCE * 2
),
pagingSourceFactory = {
AccountMediaPagingSource(
viewModel = this
).also { source ->
currentSource = source
}
},
remoteMediator = AccountMediaRemoteMediator(api, this)
).flow
.cachedIn(viewModelScope)
fun revealAttachment(viewData: AttachmentViewData) {
val position = attachmentData.indexOfFirst { oldViewData -> oldViewData.id == viewData.id }
attachmentData[position] = viewData.copy(isRevealed = true)
currentSource?.invalidate()
}
companion object {
private const val LOAD_AT_ONCE = 30
}
}

View File

@ -0,0 +1,47 @@
/* 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.account.media
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
class GridSpacingItemDecoration(
private val spanCount: Int,
private val spacing: Int,
private val topOffset: Int
) : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view) // item position
if (position < topOffset) return
val column = (position - topOffset) % spanCount // item column
outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing)
outRect.right =
spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing)
if (position - topOffset >= spanCount) {
outRect.top = spacing // item top
}
}
}

View File

@ -1,4 +1,4 @@
package com.keylesspalace.tusky.view
package com.keylesspalace.tusky.components.account.media
import android.content.Context
import android.util.AttributeSet

View File

@ -34,6 +34,7 @@ import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.visible
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener {
@ -73,6 +74,9 @@ class AnnouncementAdapter(
return
}
// hide button if announcement badge limit is already reached
addReactionChip.visible(item.reactions.size < 8)
item.reactions.forEachIndexed { i, reaction ->
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?

View File

@ -36,10 +36,12 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.ColorInt
@ -54,7 +56,6 @@ import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
@ -68,10 +69,12 @@ import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
@ -89,8 +92,6 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
@ -99,16 +100,19 @@ import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.withLifecycleContext
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
import java.util.*
import java.text.DecimalFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
@ -120,7 +124,8 @@ class ComposeActivity :
OnEmojiSelectedListener,
Injectable,
OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener {
ComposeScheduleView.OnTimeSetListener,
CaptionDialog.Listener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -144,8 +149,7 @@ class ComposeActivity :
private val binding by viewBinding(ActivityComposeBinding::inflate)
private val maxUploadMediaNumber = 4
private var mediaCount = 0
private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
@ -153,7 +157,7 @@ class ComposeActivity :
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
if (mediaCount + uris.size > maxUploadMediaNumber) {
if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
} else {
uris.forEach { uri ->
@ -175,6 +179,7 @@ class ComposeActivity :
uriNew,
size,
itemOld.description,
null, // Intentionally reset focus when cropping
itemOld
)
}
@ -218,8 +223,12 @@ class ComposeActivity :
val mediaAdapter = MediaPreviewAdapter(
this,
onAddCaption = { item ->
makeCaptionDialog(item.description, item.uri) { newDescription ->
viewModel.updateDescription(item.localId, newDescription)
CaptionDialog.newInstance(item.localId, item.description, item.uri)
.show(supportFragmentManager, "caption_dialog")
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
viewModel.updateFocus(item.localId, newFocus)
}
},
onEditImage = this::editImageInQueue,
@ -230,17 +239,25 @@ class ComposeActivity :
binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null
subscribeToUpdates(mediaAdapter)
setupButtons()
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
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)
viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true))
if (accountManager.shouldDisplaySelfUsername(this)) {
binding.composeUsernameView.text = getString(
R.string.compose_active_account_description,
activeAccount.fullName
)
binding.composeUsernameView.show()
} else {
binding.composeUsernameView.hide()
}
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent)
val statusContent = composeOptions?.content
@ -248,35 +265,54 @@ class ComposeActivity :
binding.composeEditField.setText(statusContent)
}
viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true))
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguage(composeOptions?.language))
setupComposeField(preferences, viewModel.startingText)
setupDefaultTagViews(preferences)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
applyShareIntent(intent, savedInstanceState)
viewModel.setupComplete.value = true
/* Finally, overwrite state with data from saved instance state. */
savedInstanceState?.let {
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY)
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
setStatusVisibility(this)
}
it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply {
viewModel.contentWarningChanged(this)
}
it.getString(SCHEDULED_TIME_KEY)?.let { time ->
viewModel.updateScheduledAt(time)
}
}
if (composeOptions?.tootRightNow == true && calculateTextLength() > 0) {
onSendClicked()
}
binding.composeEditField.post {
binding.composeEditField.requestFocus()
}
}
private fun loadInstanceData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean {
private fun useCachedData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean {
if (tootRightNow) {
return false // from Quick Toot
return true // from Quick Toot
}
if (!preferences.getBoolean("limitedBandwidthActive", false)) {
return true // Limited Bandwidth Mode disabled
return false // Limited Bandwidth Mode disabled
}
if (!preferences.getBoolean("limitedBandwidthOnlyMobileNetwork", true)) {
return false // Limited Bandwidth Mode enabled && Only Mobile Network disabled
return true // Limited Bandwidth Mode enabled && Only Mobile Network disabled
}
return !(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered
return (getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered
}
private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) {
@ -417,36 +453,48 @@ class ComposeActivity :
}
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
withLifecycleContext {
viewModel.instanceInfo.observe { instanceData ->
lifecycleScope.launch {
viewModel.instanceInfo.collect { instanceData ->
maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
maxUploadMediaNumber = instanceData.maxMediaAttachments
updateVisibleCharactersLeft()
}
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
}
lifecycleScope.launch {
viewModel.emoji.collect(::setEmojiList)
}
lifecycleScope.launch {
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
updateSensitiveMediaToggle(markSensitive, showContentWarning)
showContentWarning(showContentWarning)
}.subscribe()
viewModel.statusVisibility.observe { visibility ->
setStatusVisibility(visibility)
}
lifecycleScope.launch {
viewModel.media.collect { media ->
mediaAdapter.submitList(media)
if (media.size != mediaCount) {
mediaCount = media.size
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
}
}
}
}.collect()
}
viewModel.poll.observe { poll ->
lifecycleScope.launch {
viewModel.statusVisibility.collect(::setStatusVisibility)
}
lifecycleScope.launch {
viewModel.media.collect { media ->
mediaAdapter.submitList(media)
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
}
}
lifecycleScope.launch {
viewModel.poll.collect { poll ->
binding.pollPreview.visible(poll != null)
poll?.let(binding.pollPreview::setPoll)
}
viewModel.scheduledAt.observe { scheduledAt ->
}
lifecycleScope.launch {
viewModel.scheduledAt.collect { scheduledAt ->
if (scheduledAt == null) {
binding.composeScheduleView.resetSchedule()
} else {
@ -454,25 +502,26 @@ class ComposeActivity :
}
updateScheduleButton()
}
combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll ->
}
lifecycleScope.launch {
viewModel.media.combine(viewModel.poll) { media, poll ->
val active = poll == null &&
media!!.size != 4 &&
media.size < maxUploadMediaNumber &&
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty())
}.subscribe()
viewModel.uploadError.observe { throwable ->
Log.w(TAG, "media upload failed", throwable)
enablePollButton(media.isEmpty())
}.collect()
}
lifecycleScope.launch {
viewModel.uploadError.collect { throwable ->
if (throwable is UploadServerError) {
displayTransientError(throwable.errorMessage)
} else {
displayTransientError(R.string.error_media_upload_sending)
}
}
viewModel.setupComplete.observe {
// Focus may have changed during view model setup, ensure initial focus is on the edit field
binding.composeEditField.requestFocus()
}
}
}
@ -532,6 +581,61 @@ class ComposeActivity :
binding.actionPhotoTake.setOnClickListener { initiateCameraApp() }
binding.actionPhotoPick.setOnClickListener { onMediaPick() }
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
return
}
handleCloseButton()
}
}
)
}
private fun setupLanguageSpinner(initialLanguage: String?) {
val locales = Locale.getAvailableLocales()
.filter { it.country.isNullOrEmpty() && it.script.isNullOrEmpty() && it.variant.isNullOrEmpty() } // Only "base" languages, "en" but not "en_DK"
var currentLocaleIndex = locales.indexOfFirst { it.language == initialLanguage }
if (currentLocaleIndex < 0) {
Log.e(TAG, "Error looking up language tag '$initialLanguage', falling back to english")
currentLocaleIndex = locales.indexOfFirst { it.language == "en" }
}
val context = this
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).language
}
override fun onNothingSelected(parent: AdapterView<*>) {
parent.setSelection(locales.indexOfFirst { it.language == getInitialLanguage() })
}
}
binding.composePostLanguageButton.apply {
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales)
setSelection(currentLocaleIndex)
}
}
private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) {
// Setting the application ui preference sets the default locale
Locale.getDefault().language
} else {
language
}
}
private fun setupActionBar() {
@ -628,6 +732,9 @@ class ComposeActivity :
override fun onSaveInstanceState(outState: Bundle) {
outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri)
outState.putSerializable(VISIBILITY_KEY, viewModel.statusVisibility.value)
outState.putBoolean(CONTENT_WARNING_VISIBLE_KEY, viewModel.showContentWarning.value)
outState.putString(SCHEDULED_TIME_KEY, viewModel.scheduledAt.value)
super.onSaveInstanceState(outState)
}
@ -654,12 +761,12 @@ class ComposeActivity :
@ColorInt val color = if (contentWarningShown) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false
ContextCompat.getColor(this, R.color.transparent_tusky_blue)
getColor(R.color.transparent_tusky_blue)
} else {
binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
ContextCompat.getColor(this, R.color.tusky_blue)
getColor(R.color.tusky_blue)
} else {
binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
@ -673,7 +780,7 @@ class ComposeActivity :
@ColorInt val color = if (binding.composeScheduleView.time == null) {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
} else {
ContextCompat.getColor(this, R.color.tusky_blue)
getColor(R.color.tusky_blue)
}
binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
@ -785,13 +892,17 @@ class ComposeActivity :
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
private fun openPollDialog() {
private fun openPollDialog() = lifecycleScope.launch {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceInfo.value!!
val instanceParams = viewModel.instanceInfo.first()
showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
viewModel::updatePoll
context = this@ComposeActivity,
poll = viewModel.poll.value,
maxOptionCount = instanceParams.pollMaxOptions,
maxOptionLength = instanceParams.pollMaxLength,
minDuration = instanceParams.pollMinDuration,
maxDuration = instanceParams.pollMaxDuration,
onUpdatePoll = viewModel::updatePoll
)
}
@ -845,18 +956,22 @@ class ComposeActivity :
if (binding.checkboxUseDefaultText.isChecked) {
length += 1 + binding.editTextDefaultText.length()
}
if (viewModel.showContentWarning.value!!) {
if (viewModel.showContentWarning.value) {
length += binding.composeContentWarningField.length()
}
return length
}
@VisibleForTesting
val selectedLanguage: String?
get() = viewModel.postLanguage
private fun updateVisibleCharactersLeft() {
val remainingLength = maximumTootCharacters - calculateTextLength()
binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
val textColor = if (remainingLength < 0) {
ContextCompat.getColor(this, R.color.tusky_red)
getColor(R.color.tusky_red)
} else {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
}
@ -899,7 +1014,7 @@ class ComposeActivity :
enableButtons(false)
var contentText = binding.composeEditField.text.toString()
var spoilerText = ""
if (viewModel.showContentWarning.value!!) {
if (viewModel.showContentWarning.value) {
spoilerText = binding.composeContentWarningField.text.toString()
}
val characterCount = calculateTextLength()
@ -918,9 +1033,8 @@ class ComposeActivity :
)
}
viewModel.sendStatus(contentText, spoilerText).observe(
this
) {
lifecycleScope.launch {
viewModel.sendStatus(contentText, spoilerText)
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
@ -1016,13 +1130,17 @@ class ComposeActivity :
private fun pickMedia(uri: Uri) {
lifecycleScope.launch {
viewModel.pickMedia(uri).onFailure { throwable ->
val errorId = when (throwable) {
is VideoSizeException -> R.string.error_video_upload_size
is AudioSizeException -> R.string.error_audio_upload_size
is VideoOrImageException -> R.string.error_media_upload_image_or_video
else -> R.string.error_media_upload_opening
val errorString = when (throwable) {
is FileSizeException -> {
val decimalFormat = DecimalFormat("0.##")
val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024)
val formattedSize = decimalFormat.format(allowedSizeInMb)
getString(R.string.error_multimedia_size_limit, formattedSize)
}
is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video)
else -> getString(R.string.error_media_upload_opening)
}
displayTransientError(errorId)
displayTransientError(errorString)
}
}
}
@ -1033,7 +1151,7 @@ class ComposeActivity :
binding.composeContentWarningBar.show()
binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length)
binding.composeContentWarningField.requestFocus()
ContextCompat.getColor(this, R.color.tusky_blue)
getColor(R.color.tusky_blue)
} else {
binding.composeContentWarningBar.hide()
binding.composeEditField.requestFocus()
@ -1051,23 +1169,6 @@ class ComposeActivity :
return super.onOptionsItemSelected(item)
}
override fun onBackPressed() {
// Acting like a teen: deliberately ignoring parent.
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
return
}
handleCloseButton()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, event.toString())
if (event.action == KeyEvent.ACTION_DOWN) {
@ -1080,7 +1181,7 @@ class ComposeActivity :
}
if (keyCode == KeyEvent.KEYCODE_BACK) {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
return true
}
}
@ -1091,8 +1192,15 @@ 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(R.string.compose_save_draft)
.setMessage(warning)
.setPositiveButton(R.string.action_save) { _, _ ->
saveDraftAndFinish(contentText, contentWarning)
}
@ -1146,7 +1254,8 @@ class ComposeActivity :
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
val description: String? = null,
val focus: Attachment.Focus? = null
) {
enum class Type {
IMAGE, VIDEO, AUDIO;
@ -1167,6 +1276,14 @@ class ComposeActivity :
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
override fun onUpdateDescription(localId: Int, description: String) {
lifecycleScope.launch {
if (!viewModel.updateDescription(localId, description)) {
Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
}
}
}
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
@ -1191,6 +1308,7 @@ class ComposeActivity :
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null,
var language: String? = null,
var tootRightNow: Boolean? = null,
) : Parcelable
@ -1202,6 +1320,9 @@ class ComposeActivity :
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
private const val VISIBILITY_KEY = "VISIBILITY"
private const val SCHEDULED_TIME_KEY = "SCHEDULE"
private const val CONTENT_WARNING_VISIBLE_KEY = "CONTENT_WARNING_VISIBLE"
@JvmField
val CAN_USE_UNLEAKABLE = arrayOf("itabashi.0j0.jp", "odakyu.app")

View File

@ -97,11 +97,11 @@ class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer {
return if (i > 0 && text[i - 1] == ' ') {
text
} else if (text is Spanned) {
val s = SpannableString(text.toString() + " ")
val s = SpannableString("$text ")
TextUtils.copySpansFrom(text, 0, text.length, Object::class.java, s, 0)
s
} else {
text.toString() + " "
"$text "
}
}
}

View File

@ -18,55 +18,45 @@ package com.keylesspalace.tusky.components.compose
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Attachment
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.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val instanceInfoRepo: InstanceInfoRepository
instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
internal var startingText: String? = null
internal var postLanguage: String? = null
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
@ -78,49 +68,41 @@ class ComposeViewModel @Inject constructor(
private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false
private var hasScheduledTimeChanged: Boolean = false
val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
private val useCache = MutableStateFlow(true)
val instanceInfo = useCache.map { when (it) {
true -> instanceInfoRepo.getCachedInstanceInfo()
false -> instanceInfoRepo.getInstanceInfo()
} }.shareIn(viewModelScope, SharingStarted.Eagerly, 1)
val emoji = useCache.map { when (it) {
true -> instanceInfoRepo.getCachedEmojis()
false -> instanceInfoRepo.getEmojis()
} }.shareIn(viewModelScope, SharingStarted.Eagerly, 1)
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive: MutableStateFlow<Boolean> =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false)
val setupComplete = mutableLiveData(false)
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableLiveData<Throwable>()
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val domain = accountManager.activeAccount?.domain!!
private val mediaToJob = mutableMapOf<Int, Job>()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
fun loadInstanceDataFromNetwork(loadActually: Boolean) {
viewModelScope.launch {
emoji.postValue(when (loadActually) {
true -> instanceInfoRepo.getEmojis()
false -> instanceInfoRepo.getCachedEmojis()
})
}
viewModelScope.launch {
instanceInfo.postValue(when (loadActually) {
true -> instanceInfoRepo.getInstanceInfo()
false -> instanceInfoRepo.getCachedInstanceInfo()
})
}
}
private var setupComplete = false
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = media.value
if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() &&
@ -128,7 +110,7 @@ class ComposeViewModel @Inject constructor(
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description)
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
@ -141,6 +123,7 @@ class ComposeViewModel @Inject constructor(
uri: Uri,
mediaSize: Long,
description: String? = null,
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
@ -151,7 +134,8 @@ class ComposeViewModel @Inject constructor(
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
description = description,
focus = focus
)
stashMediaItem = mediaItem
@ -168,10 +152,10 @@ class ComposeViewModel @Inject constructor(
mediaToJob[mediaItem.localId] = viewModelScope.launch {
mediaUploader
.uploadMedia(mediaItem)
.uploadMedia(mediaItem, instanceInfo.first())
.catch { error ->
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
uploadError.postValue(error)
uploadError.emit(error)
}
.collect { event ->
val item = media.value.find { it.localId == mediaItem.localId }
@ -196,7 +180,7 @@ class ComposeViewModel @Inject constructor(
return mediaItem
}
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
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,
@ -205,7 +189,8 @@ class ComposeViewModel @Inject constructor(
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description
description = description,
focus = focus
)
mediaValue + mediaItem
}
@ -227,13 +212,14 @@ class ComposeViewModel @Inject constructor(
startingText?.startsWith(content.toString()) ?: false
)
val contentWarningChanged = showContentWarning.value!! &&
val contentWarningChanged = showContentWarning.value &&
!contentWarning.isNullOrEmpty() &&
!startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null
val didScheduledTimeChange = hasScheduledTimeChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
}
fun contentWarningChanged(value: Boolean) {
@ -259,9 +245,11 @@ class ComposeViewModel @Inject constructor(
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
media.value.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
}
draftHelper.saveDraft(
@ -270,53 +258,55 @@ class ComposeViewModel @Inject constructor(
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!,
visibility = statusVisibility.value!!,
sensitive = markMediaAsSensitive.value,
visibility = statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
failedToSend = false
failedToSend = false,
scheduledAt = scheduledAt.value,
language = postLanguage,
)
}
/**
* Send status to the server.
* Uses current state plus provided arguments.
* @return LiveData which will signal once the screen can be closed or null if there are errors
*/
fun sendStatus(
suspend fun sendStatus(
content: String,
spoilerText: String
): LiveData<Unit> {
) {
val deletionObservable = if (isEditingScheduledToot) {
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else {
Observable.just(Unit)
}.toLiveData()
if (!scheduledTootId.isNullOrEmpty()) {
api.deleteScheduledStatus(scheduledTootId!!)
}
val sendFlow = media
media
.filter { items -> items.all { it.uploadPercent == -1 } }
.map {
.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()
for (item in media.value) {
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!!),
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,
@ -327,20 +317,21 @@ class ComposeViewModel @Inject constructor(
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
mediaProcessed = mediaProcessed
mediaProcessed = mediaProcessed,
language = postLanguage,
)
serviceClient.sendToot(tootToSend)
true
}
return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
}
suspend fun updateDescription(localId: Int, description: String): Boolean {
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
val newMediaList = media.updateAndGet { mediaValue ->
mediaValue.map { mediaItem ->
if (mediaItem.localId == localId) {
mediaItem.copy(description = description)
mutator(mediaItem)
} else {
mediaItem
}
@ -349,7 +340,9 @@ class ComposeViewModel @Inject constructor(
val updatedItem = newMediaList.find { it.localId == localId }
if (updatedItem?.id != null) {
return api.updateMedia(updatedItem.id, description)
val focus = updatedItem.focus
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
.fold({
true
}, { throwable ->
@ -360,6 +353,18 @@ class ComposeViewModel @Inject constructor(
return true
}
suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(description = description)
})
}
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(focus = focus)
})
}
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
@ -381,7 +386,7 @@ class ComposeViewModel @Inject constructor(
})
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1)
return emojiList.filter { emoji ->
@ -399,9 +404,9 @@ class ComposeViewModel @Inject constructor(
}
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) {
if (setupComplete.value == true) {
if (setupComplete) {
return
}
@ -434,7 +439,7 @@ class ComposeViewModel @Inject constructor(
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description)
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
} else composeOptions?.mediaAttachments?.forEach { a ->
@ -444,12 +449,13 @@ class ComposeViewModel @Inject constructor(
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.content
postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
@ -480,6 +486,10 @@ class ComposeViewModel @Inject constructor(
}
replyingStatusContent = composeOptions?.replyingStatusContent
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
this.useCache.update { useCache }
setupComplete = true
}
fun updatePoll(newPoll: NewPoll) {
@ -487,6 +497,10 @@ class ComposeViewModel @Inject constructor(
}
fun updateScheduledAt(newScheduledAt: String?) {
if (newScheduledAt != scheduledAt.value) {
hasScheduledTimeChanged = true
}
scheduledAt.value = newScheduledAt
}
@ -495,8 +509,6 @@ class ComposeViewModel @Inject constructor(
}
}
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
/**
* Thrown when trying to add an image when video is already present or the other way around
*/

View File

@ -20,8 +20,8 @@ import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.graphics.BitmapFactory
import android.net.Uri
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.calculateInSampleSize
import com.keylesspalace.tusky.util.closeQuietly
import com.keylesspalace.tusky.util.getImageOrientation
import com.keylesspalace.tusky.util.reorientBitmap
import java.io.File
@ -51,7 +51,7 @@ fun downsizeImage(
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(decodeBoundsInputStream, null, options)
IOUtils.closeQuietly(decodeBoundsInputStream)
decodeBoundsInputStream.closeQuietly()
// Get EXIF data, for orientation info.
val orientation = getImageOrientation(uri, contentResolver)
/* Unfortunately, there isn't a determined worst case compression ratio for image
@ -78,7 +78,7 @@ fun downsizeImage(
} catch (error: OutOfMemoryError) {
return false
} finally {
IOUtils.closeQuietly(decodeBitmapInputStream)
decodeBitmapInputStream.closeQuietly()
} ?: return false
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
@ -44,15 +45,19 @@ class MediaPreviewAdapter(
val item = differ.currentList[position]
val popup = PopupMenu(view.context, view)
val addCaptionId = 1
val editImageId = 2
val removeId = 3
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)
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 ->
when (menuItem.itemId) {
addCaptionId -> onAddCaption(item)
addFocusId -> onAddFocus(item)
editImageId -> onEditImage(item)
removeId -> onRemove(item)
}
@ -78,11 +83,24 @@ class MediaPreviewAdapter(
// TODO: Fancy waveform display?
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
val imageView = holder.progressImageView
val focus = item.focus
if (focus != null)
imageView.setFocalPoint(focus)
else
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
var glide = Glide.with(holder.itemView.context)
.load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.progressImageView)
.centerInside()
if (focus != null)
glide = glide.addListener(imageView)
glide.into(imageView)
}
}

View File

@ -27,6 +27,7 @@ import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
@ -70,8 +71,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
class AudioSizeException : Exception()
class VideoSizeException : Exception()
class FileSizeException(val allowedSizeInBytes: Int) : Exception()
class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception()
@ -82,10 +82,10 @@ class MediaUploader @Inject constructor(
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
return flow {
if (shouldResizeMedia(media)) {
emit(downsize(media))
if (shouldResizeMedia(media, instanceInfo)) {
emit(downsize(media, instanceInfo))
} else {
emit(media)
}
@ -94,7 +94,7 @@ class MediaUploader @Inject constructor(
.flowOn(Dispatchers.IO)
}
fun prepareMedia(inUri: Uri): PreparedMedia {
fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri
val mimeType: String?
@ -164,8 +164,8 @@ class MediaUploader @Inject constructor(
if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> {
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
throw VideoSizeException()
if (mediaSize > instanceInfo.videoSizeLimit) {
throw FileSizeException(instanceInfo.videoSizeLimit)
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
}
@ -173,8 +173,8 @@ class MediaUploader @Inject constructor(
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
throw AudioSizeException()
if (mediaSize > instanceInfo.videoSizeLimit) {
throw FileSizeException(instanceInfo.videoSizeLimit)
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
}
@ -225,7 +225,13 @@ class MediaUploader @Inject constructor(
null
}
mediaUploadApi.uploadMedia(body, description).fold({ result ->
val focus = if (media.focus != null) {
MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}")
} else {
null
}
mediaUploadApi.uploadMedia(body, description, focus).fold({ result ->
if (media.uri.scheme == "file") {
media.uri.path?.let {
File(it).delete()
@ -244,22 +250,18 @@ class MediaUploader @Inject constructor(
}
}
private fun downsize(media: QueuedMedia): QueuedMedia {
private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia {
val file = createNewImageFile(context)
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file)
return media.copy(uri = file.toUri(), mediaSize = file.length())
}
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean {
return media.type == QueuedMedia.Type.IMAGE &&
(media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
(media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
}
private companion object {
private const val TAG = "MediaUploader"
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
}
}

View File

@ -77,7 +77,7 @@ fun showAddPollDialog(
}
val pollDurationId = durations.indexOfLast {
it <= poll?.expiresIn ?: 0
it <= (poll?.expiresIn ?: 0)
}
binding.pollDurationSpinner.setSelection(pollDurationId)

View File

@ -15,19 +15,22 @@
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Activity
import android.content.DialogInterface
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.text.InputFilter
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
@ -35,84 +38,123 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.github.chrisbanes.photoview.PhotoView
import com.keylesspalace.tusky.R
import kotlinx.coroutines.launch
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(
existingDescription: String?,
previewUri: Uri,
onUpdateDescription: suspend (String) -> Boolean
) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8)
dialogLayout.setPadding(padding, padding, padding, padding)
class CaptionDialog : DialogFragment() {
dialogLayout.orientation = LinearLayout.VERTICAL
val imageView = PhotoView(this).apply {
maximumScale = 6f
}
private lateinit var listener: Listener
private lateinit var input: EditText
val margin = Utils.dpToPx(this, 4)
dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
imageView.layoutParams.height = 0
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
val dialogLayout = LinearLayout(context)
val padding = Utils.dpToPx(context, 8)
dialogLayout.setPadding(padding, padding, padding, padding)
val input = EditText(this)
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2)
input.inputType = (
InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.setText(existingDescription)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
val okListener = { dialog: DialogInterface, _: Int ->
lifecycleScope.launch {
if (!onUpdateDescription(input.text.toString())) {
showFailedCaptionMessage()
}
dialogLayout.orientation = LinearLayout.VERTICAL
val imageView = PhotoView(context).apply {
maximumScale = 6f
}
dialog.dismiss()
val margin = Utils.dpToPx(context, 4)
dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
imageView.layoutParams.height = 0
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
input = EditText(context)
input.hint = resources.getQuantityString(
R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2)
input.inputType = (
InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG))
val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId")
val dialog = AlertDialog.Builder(context)
.setView(dialogLayout)
.setPositiveButton(android.R.string.ok) { _, _ ->
listener.onUpdateDescription(localId, input.text.toString())
}
.setNegativeButton(android.R.string.cancel, null)
.create()
isCancelable = false
val window = dialog.window
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null")
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.into(object : CustomTarget<Drawable>(4096, 4096) {
override fun onLoadCleared(placeholder: Drawable?) {
imageView.setImageDrawable(placeholder)
}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?,
) {
imageView.setImageDrawable(resource)
}
})
return dialog
}
val dialog = AlertDialog.Builder(this)
.setView(dialogLayout)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()
override fun onSaveInstanceState(outState: Bundle) {
outState.putString(DESCRIPTION_KEY, input.text.toString())
super.onSaveInstanceState(outState)
}
val window = dialog.window
window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
savedInstanceState?.getString(DESCRIPTION_KEY)?.let {
input.setText(it)
}
return super.onCreateView(inflater, container, savedInstanceState)
}
dialog.show()
override fun onAttach(context: Context) {
super.onAttach(context)
listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener")
}
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.into(object : CustomTarget<Drawable>(4096, 4096) {
override fun onLoadCleared(placeholder: Drawable?) {
imageView.setImageDrawable(placeholder)
}
interface Listener {
fun onUpdateDescription(localId: Int, description: String)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
imageView.setImageDrawable(resource)
}
})
}
private fun Activity.showFailedCaptionMessage() {
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
companion object {
fun newInstance(
localId: Int,
existingDescription: String?,
previewUri: Uri,
) = CaptionDialog().apply {
arguments = bundleOf(
LOCAL_ID_ARG to localId,
EXISTING_DESCRIPTION_ARG to existingDescription,
PREVIEW_URI_ARG to previewUri,
)
}
private const val DESCRIPTION_KEY = "description"
private const val EXISTING_DESCRIPTION_ARG = "existing_description"
private const val PREVIEW_URI_ARG = "preview_uri"
private const val LOCAL_ID_ARG = "local_id"
}
}

View File

@ -0,0 +1,105 @@
/* Copyright 2019 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.compose.dialog
import android.app.Activity
import android.content.DialogInterface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch
fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Boolean
) where T : Activity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)
dialogBinding.focusIndicator.setFocus(focus)
Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource!!.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)
// We want the dialog to be a little taller than the image, so you can slide your thumb past the image border,
// but if it's *too* much taller that looks weird. See if a threshold has been crossed:
if (width > height) {
val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight()
if (dialogBinding.imageView.height > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
dialogBinding.imageView.layoutParams = verticalShrinkLayout
dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout
}
}
return false // Pass through
}
})
.into(dialogBinding.imageView)
val okListener = { dialog: DialogInterface, _: Int ->
lifecycleScope.launch {
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
showFailedFocusMessage()
}
}
dialog.dismiss()
}
val dialog = AlertDialog.Builder(this)
.setView(dialogBinding.root)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()
val window = dialog.window
window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
dialog.show()
}
private fun Activity.showFailedFocusMessage() {
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,130 @@
package com.keylesspalace.tusky.components.compose.view
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Point
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.keylesspalace.tusky.entity.Attachment
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
class FocusIndicatorView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var focus: Attachment.Focus? = null
private var imageSize: Point? = null
private var circleRadius: Float? = null
fun setImageSize(width: Int, height: Int) {
this.imageSize = Point(width, height)
if (focus != null)
invalidate()
}
fun setFocus(focus: Attachment.Focus) {
this.focus = focus
if (imageSize != null)
invalidate()
}
// Assumes setFocus called first
fun getFocus(): Attachment.Focus {
return focus!!
}
// This needs to be consistent every time it is consulted over the lifetime of the object,
// so base it on the view width/height whenever the first access occurs.
private fun getCircleRadius(): Float {
val circleRadius = this.circleRadius
if (circleRadius != null)
return circleRadius
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
this.circleRadius = newCircleRadius
return newCircleRadius
}
// Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y)
private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame
val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1
return min(1.0f, max(-1.0f, result)) // Clamp
}
private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
val offset = (outerLimit - innerLimit) / 2
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
}
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
return false
val imageSize = this.imageSize
if (imageSize == null)
return false
// Convert touch xy to point inside image
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
invalidate()
return true
}
private val transparentDarkGray = 0x40000000
private val strokeWidth = 4.0f * this.resources.displayMetrics.density
private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val curtainPath = Path()
init {
curtainPaint.color = transparentDarkGray
curtainPaint.style = Paint.Style.FILL
strokePaint.style = Paint.Style.STROKE
strokePaint.strokeWidth = strokeWidth
strokePaint.color = Color.WHITE
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val imageSize = this.imageSize
val focus = this.focus
if (imageSize != null && focus != null) {
val x = axisFromFocus(focus.x, imageSize.x, this.width)
val y = axisFromFocus(-focus.y, imageSize.y, this.height)
val circleRadius = getCircleRadius()
curtainPath.reset() // Draw a flood fill with a hole cut out of it
curtainPath.fillType = Path.FillType.WINDING
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
canvas.drawPath(curtainPath, curtainPaint)
canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle
canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot
}
}
// Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked
fun maxAttractiveHeight(): Int {
val height = this.imageSize!!.y
val circleRadius = getCircleRadius()
// Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth
return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt()
}
}

View File

@ -30,9 +30,10 @@ 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 AppCompatImageView {
public final class ProgressImageView extends MediaPreviewImageView {
private int progress = -1;
private final RectF progressRect = new RectF();
@ -58,15 +59,14 @@ public final class ProgressImageView extends AppCompatImageView {
}
private void init() {
circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.tusky_blue));
circlePaint.setColor(getContext().getColor(R.color.tusky_blue));
circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4));
circlePaint.setStyle(Paint.Style.STROKE);
clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
markBgPaint.setStyle(Paint.Style.FILL);
markBgPaint.setColor(ContextCompat.getColor(getContext(),
R.color.tusky_grey_10));
markBgPaint.setColor(getContext().getColor(R.color.tusky_grey_10));
captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck);
}
@ -81,8 +81,7 @@ public final class ProgressImageView extends AppCompatImageView {
}
public void setChecked(boolean checked) {
this.markBgPaint.setColor(ContextCompat.getColor(getContext(),
checked ? R.color.tusky_blue : R.color.tusky_grey_10));
this.markBgPaint.setColor(getContext().getColor(checked ? R.color.tusky_blue : R.color.tusky_grey_10));
invalidate();
}

View File

@ -94,7 +94,8 @@ data class ConversationStatusEntity(
val expanded: Boolean,
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?
val poll: Poll?,
val language: String?,
) {
fun toViewData(): StatusViewData.Concrete {
@ -126,6 +127,7 @@ data class ConversationStatusEntity(
muted = muted,
poll = poll,
card = null,
language = language,
quote = null,
),
isExpanded = expanded,
@ -168,7 +170,8 @@ fun Status.toEntity() =
expanded = false,
collapsed = true,
muted = muted ?: false,
poll = poll
poll = poll,
language = language,
)
fun Conversation.toEntity(accountId: Long, order: Int) =

View File

@ -85,6 +85,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll
poll = poll,
language = status.language,
)
}

View File

@ -25,9 +25,10 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.copyToFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@ -59,8 +60,11 @@ class DraftHelper @Inject constructor(
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
mediaFocus: List<Attachment.Focus?>,
poll: NewPoll?,
failedToSend: Boolean
failedToSend: Boolean,
scheduledAt: String?,
language: String?,
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -77,11 +81,11 @@ class DraftHelper @Inject constructor(
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.mapNotNull { uri ->
}.mapIndexedNotNull { index, uri ->
if (uri.isInFolder(draftDirectory)) {
uri
} else {
uri.copyToFolder(draftDirectory)
uri.copyToFolder(draftDirectory, index)
}
}
@ -101,6 +105,7 @@ class DraftHelper @Inject constructor(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
focus = mediaFocus[i],
type = types[i]
)
)
@ -116,7 +121,9 @@ class DraftHelper @Inject constructor(
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
failedToSend = failedToSend,
scheduledAt = scheduledAt,
language = language,
)
draftDao.insertOrReplace(draft)
@ -153,7 +160,7 @@ class DraftHelper @Inject constructor(
return File(filePath).parentFile == folder
}
private fun Uri.copyToFolder(folder: File): Uri? {
private fun Uri.copyToFolder(folder: File, index: Int): Uri? {
val contentResolver = context.contentResolver
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
@ -165,7 +172,7 @@ class DraftHelper @Inject constructor(
map.getExtensionFromMimeType(mimeType)
}
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension)
val filename = String.format("Tusky_Draft_Media_%s_%d.%s", timeStamp, index, fileExtension)
val file = File(folder, filename)
if (scheme == "https") {
@ -187,7 +194,7 @@ class DraftHelper @Inject constructor(
return null
}
} else {
IOUtils.copyToFile(contentResolver, this, file)
this.copyToFile(contentResolver, file)
}
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
}

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.drafts
import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@ -26,6 +25,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.view.MediaPreviewImageView
class DraftMediaAdapter(
private val attachmentClick: () -> Unit
@ -42,24 +42,34 @@ class DraftMediaAdapter(
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
return DraftMediaViewHolder(AppCompatImageView(parent.context))
return DraftMediaViewHolder(MediaPreviewImageView(parent.context))
}
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
getItem(position)?.let { attachment ->
if (attachment.type == DraftAttachment.Type.AUDIO) {
holder.imageView.clearFocus()
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
if (attachment.focus != null)
holder.imageView.setFocalPoint(attachment.focus)
else
holder.imageView.clearFocus()
var glide = Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
.centerInside()
if (attachment.focus != null)
glide = glide.addListener(holder.imageView)
glide.into(holder.imageView)
}
}
}
inner class DraftMediaViewHolder(val imageView: ImageView) :
inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) :
RecyclerView.ViewHolder(imageView) {
init {
val thumbnailViewSize =

View File

@ -106,7 +106,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
visibility = draft.visibility,
scheduledAt = draft.scheduledAt,
language = draft.language,
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -143,7 +145,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
visibility = draft.visibility,
scheduledAt = draft.scheduledAt,
language = draft.language,
)
startActivity(ComposeActivity.startIntent(this, composeOptions))

View File

@ -21,5 +21,12 @@ data class InstanceInfo(
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int
val charactersReservedPerUrl: Int,
val videoSizeLimit: Int,
val imageSizeLimit: Int,
val imageMatrixLimit: Int,
val maxMediaAttachments: Int,
val maxFields: Int,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
)

View File

@ -45,7 +45,7 @@ class InstanceInfoRepository @Inject constructor(
*/
suspend fun getEmojis(): List<Emoji> = withContext(Dispatchers.IO) {
api.getCustomEmojis()
.onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) }
.onSuccess { emojiList -> dao.upsert(EmojisEntity(instanceName, emojiList)) }
.getOrElse { throwable ->
Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable)
getCachedEmojis()
@ -72,9 +72,16 @@ class InstanceInfoRepository @Inject constructor(
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
version = instance.version,
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit,
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit,
imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit,
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
)
dao.insertOrReplace(instanceEntity)
dao.upsert(instanceEntity)
instanceEntity
},
{ throwable ->
@ -99,6 +106,10 @@ class InstanceInfoRepository @Inject constructor(
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
private const val DEFAULT_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
private const val DEFAULT_IMAGE_SIZE_LIMIT = 10485760 // 10MiB
private const val DEFAULT_IMAGE_MATRIX_LIMIT = 16777216 // 4096^2 Pixels
@JvmField
val CAN_USE_QUOTE_ID = arrayOf(
"odakyu.app",
@ -120,6 +131,9 @@ class InstanceInfoRepository @Inject constructor(
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4
const val DEFAULT_MAX_ACCOUNT_FIELDS = 4
fun InstanceInfoEntity?.toInstanceInfo(): InstanceInfo =
InstanceInfo(
maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
@ -127,7 +141,14 @@ class InstanceInfoRepository @Inject constructor(
pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL
charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
maxMediaAttachments = this?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
maxFieldNameLength = this?.maxFieldNameLength,
maxFieldValueLength = this?.maxFieldValueLength,
)
}
}

View File

@ -209,7 +209,7 @@ class LoginActivity : BaseActivity(), Injectable {
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES)
.build()
doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri()))
doWebViewAuth.launch(LoginData(domain, url.toString().toUri(), oauthRedirectUri.toUri()))
}
override fun onStart() {

View File

@ -1,3 +1,18 @@
/* 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.login
import android.annotation.SuppressLint
@ -16,15 +31,22 @@ import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/** Contract for starting [LoginWebViewActivity]. */
class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
@ -61,6 +83,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
@Parcelize
data class LoginData(
val domain: String,
val url: Uri,
val oauthRedirectUrl: Uri,
) : Parcelable
@ -80,6 +103,11 @@ sealed class LoginResult : Parcelable {
class LoginWebViewActivity : BaseActivity(), Injectable {
private val binding by viewBinding(ActivityLoginWebviewBinding::inflate)
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: LoginWebViewViewModel by viewModels { viewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -103,7 +131,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
webView.settings.databaseEnabled = false
webView.settings.displayZoomControls = false
webView.settings.javaScriptCanOpenWindowsAutomatically = false
// Javascript needs to be enabled because otherwise 2FA does not work in some instances
// JavaScript needs to be enabled because otherwise 2FA does not work in some instances
@SuppressLint("SetJavaScriptEnabled")
webView.settings.javaScriptEnabled = true
webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}"
@ -161,6 +189,25 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
} else {
webView.restoreState(savedInstanceState)
}
binding.loginRules.text = getString(R.string.instance_rule_info, data.domain)
viewModel.init(data.domain)
lifecycleScope.launch {
viewModel.instanceRules.collect { instanceRules ->
binding.loginRules.visible(instanceRules.isNotEmpty())
binding.loginRules.setOnClickListener {
AlertDialog.Builder(this@LoginWebViewActivity)
.setTitle(getString(R.string.instance_rule_title, data.domain))
.setMessage(
instanceRules.joinToString(separator = "\n\n") { "$it" }
)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {

View File

@ -0,0 +1,47 @@
/* 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.login
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginWebViewViewModel @Inject constructor(
private val api: MastodonApi
) : ViewModel() {
val instanceRules: MutableStateFlow<List<String>> = MutableStateFlow(emptyList())
private var domain: String? = null
fun init(domain: String) {
if (this.domain == null) {
this.domain = domain
viewModelScope.launch {
api.getInstance(domain).fold({ instance ->
instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty()
}, { throwable ->
Log.w("LoginWebViewViewModel", "failed to load instance info", throwable)
})
}
}
}
}

View File

@ -38,7 +38,6 @@ import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
import androidx.core.content.ContextCompat;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
@ -57,7 +56,6 @@ import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
@ -86,6 +84,8 @@ public class NotificationHelper {
*/
public static final String ACCOUNT_ID = "account_id";
public static final String TYPE = "type";
private static final String TAG = "NotificationHelper";
public static final String REPLY_ACTION = "REPLY_ACTION";
@ -270,6 +270,7 @@ public class NotificationHelper {
private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
Intent summaryResultIntent = new Intent(context, MainActivity.class);
summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
summaryResultIntent.putExtra(TYPE, body.getType().name());
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
summaryStackBuilder.addParentStack(MainActivity.class);
summaryStackBuilder.addNextIntent(summaryResultIntent);
@ -280,6 +281,7 @@ public class NotificationHelper {
// we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class);
eventResultIntent.putExtra(ACCOUNT_ID, account.getId());
eventResultIntent.putExtra(TYPE, body.getType().name());
TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context);
eventStackBuilder.addParentStack(MainActivity.class);
eventStackBuilder.addNextIntent(eventResultIntent);
@ -296,7 +298,7 @@ public class NotificationHelper {
.setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent)
.setColor(ContextCompat.getColor(context, R.color.notification_color))
.setColor(context.getColor(R.color.notification_color))
.setGroup(account.getAccountId())
.setAutoCancel(true)
.setShortcutId(Long.toString(account.getId()))
@ -367,6 +369,7 @@ public class NotificationHelper {
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(actionableStatus.getLanguage());
Intent composeIntent = ComposeActivity.startIntent(
context,

View File

@ -20,6 +20,7 @@ import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.preference.PreferenceManager
@ -47,7 +48,17 @@ class PreferencesActivity :
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
private var restartActivitiesOnExit: Boolean = false
private val restartActivitiesOnBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
/* Switching themes won't actually change the theme of activities on the back stack.
* Either the back stack activities need to all be recreated, or do the easier thing, which
* is hijack the back button press and use it to launch a new MainActivity and clear the
* back stack. */
val intent = Intent(this@PreferencesActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivityWithSlideInAnimation(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -92,7 +103,8 @@ class PreferencesActivity :
replace(R.id.fragment_container, fragment, fragmentTag)
}
restartActivitiesOnExit = intent.getBooleanExtra("restart", false)
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
}
override fun onResume() {
@ -106,11 +118,11 @@ class PreferencesActivity :
}
private fun saveInstanceState(outState: Bundle) {
outState.putBoolean("restart", restartActivitiesOnExit)
outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean("restart", restartActivitiesOnExit)
outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled)
super.onSaveInstanceState(outState)
}
@ -121,16 +133,16 @@ class PreferencesActivity :
Log.d("activeTheme", theme)
ThemeUtils.setAppNightMode(theme)
restartActivitiesOnExit = true
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
"showCardsInTimelines", "confirmReblogs", "confirmFavourites",
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, "viewPagerOffScreenLimit" -> {
restartActivitiesOnExit = true
restartActivitiesOnBackPressedCallback.isEnabled = true
}
"language" -> {
restartActivitiesOnExit = true
restartActivitiesOnBackPressedCallback.isEnabled = true
this.restartCurrentActivity()
}
}
@ -148,20 +160,6 @@ class PreferencesActivity :
overridePendingTransition(R.anim.fade_in, R.anim.fade_out)
}
override fun onBackPressed() {
/* Switching themes won't actually change the theme of activities on the back stack.
* Either the back stack activities need to all be recreated, or do the easier thing, which
* is hijack the back button press and use it to launch a new MainActivity and clear the
* back stack. */
if (restartActivitiesOnExit) {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivityWithSlideInAnimation(intent)
} else {
super.onBackPressed()
}
}
override fun androidInjector() = androidInjector
companion object {
@ -172,6 +170,7 @@ class PreferencesActivity :
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"
@JvmStatic
fun newIntent(context: Context, preferenceType: Int): Intent {

View File

@ -102,6 +102,16 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_main_nav_position)
}
listPreference {
setDefaultValue("disambiguate")
setEntries(R.array.pref_show_self_username_names)
setEntryValues(R.array.pref_show_self_username_values)
key = PrefKeys.SHOW_SELF_USERNAME
setSummaryProvider { entry }
setTitle(R.string.pref_title_show_self_username)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.HIDE_TOP_TOOLBAR

View File

@ -19,6 +19,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
@ -134,7 +135,13 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I
}
override fun delete(item: ScheduledStatus) {
viewModel.deleteScheduledStatus(item)
AlertDialog.Builder(this)
.setMessage(R.string.delete_scheduled_post_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteScheduledStatus(item)
}
.show()
}
companion object {

View File

@ -57,8 +57,7 @@ class ScheduledStatusAdapter(
v.isEnabled = false
listener.edit(item)
}
holder.binding.delete.setOnClickListener { v: View ->
v.isEnabled = false
holder.binding.delete.setOnClickListener {
listener.delete(item)
}
}

View File

@ -26,7 +26,6 @@ import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFacto
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi
@ -144,11 +143,7 @@ class SearchViewModel @Inject constructor(
}
fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = statusViewData.copy(isExpanded = expanded)
statusesPagingSourceFactory.invalidate()
}
updateStatusViewData(statusViewData.copy(isExpanded = expanded))
}
fun expandedNotestockChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
@ -170,17 +165,16 @@ class SearchViewModel @Inject constructor(
}
private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
statusViewData.status.reblogged = reblog
statusViewData.status.reblog?.reblogged = reblog
statusesPagingSourceFactory.invalidate()
updateStatus(
statusViewData.status.copy(
reblogged = reblog,
reblog = statusViewData.status.reblog?.copy(reblogged = reblog)
)
)
}
fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = statusViewData.copy(isShowingContent = isShowing)
statusesPagingSourceFactory.invalidate()
}
updateStatusViewData(statusViewData.copy(isShowingContent = isShowing))
}
fun contentHiddenNotestockChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
@ -192,11 +186,7 @@ class SearchViewModel @Inject constructor(
}
fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = statusViewData.copy(isCollapsed = collapsed)
statusesPagingSourceFactory.invalidate()
}
updateStatusViewData(statusViewData.copy(isCollapsed = collapsed))
}
fun collapsedNotestockChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
@ -209,28 +199,16 @@ class SearchViewModel @Inject constructor(
fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList<Int>) {
val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices)
updateStatus(statusViewData, votedPoll)
updateStatus(statusViewData.status.copy(poll = votedPoll))
timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(statusViewData, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) }
)
.doOnError { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) }
.subscribe()
.autoDispose()
}
private fun updateStatus(statusViewData: StatusViewData.Concrete, newPoll: Poll) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = statusViewData.status.copy(poll = newPoll)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
}
fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) {
statusViewData.status.favourited = isFavorited
statusesPagingSourceFactory.invalidate()
updateStatus(statusViewData.status.copy(favourited = isFavorited))
timelineCases.favourite(statusViewData.id, isFavorited)
.onErrorReturnItem(statusViewData.status)
.subscribe()
@ -238,18 +216,13 @@ class SearchViewModel @Inject constructor(
}
fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) {
statusViewData.status.bookmarked = isBookmarked
statusesPagingSourceFactory.invalidate()
updateStatus(statusViewData.status.copy(bookmarked = isBookmarked))
timelineCases.bookmark(statusViewData.id, isBookmarked)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
return accountManager.getAllAccountsOrderedByActive()
}
fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) {
timelineCases.mute(accountId, notifications, duration)
}
@ -267,18 +240,28 @@ class SearchViewModel @Inject constructor(
}
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = statusViewData.status.copy(muted = mute)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
updateStatus(statusViewData.status.copy(muted = mute))
timelineCases.muteConversation(statusViewData.id, mute)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) {
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
if (idx >= 0) {
loadedStatuses[idx] = newStatusViewData
statusesPagingSourceFactory.invalidate()
}
}
private fun updateStatus(newStatus: Status) {
val statusViewData = loadedStatuses.find { it.id == newStatus.id }
if (statusViewData != null) {
updateStatusViewData(statusViewData.copy(status = newStatus))
}
}
companion object {
private const val TAG = "SearchViewModel"
private const val DEFAULT_LOAD_SIZE = 20

View File

@ -236,9 +236,6 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
val accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
val statusUrl = status.actionableStatus.url
val accounts = viewModel.getAllAccountsOrderedByActive()
var openAsTitle: String? = null
val loggedInAccountId = viewModel.activeAccount?.accountId
val popup = PopupMenu(view.context, view)
@ -270,18 +267,12 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
}
val openAsItem = popup.menu.findItem(R.id.status_open_as)
when (accounts.size) {
0, 1 -> openAsItem.isVisible = false
2 -> for (account in accounts) {
if (account !== viewModel.activeAccount) {
openAsTitle =
String.format(getString(R.string.action_open_as), account.fullName)
break
}
}
else -> openAsTitle = String.format(getString(R.string.action_open_as), "")
val openAsText = bottomSheetActivity?.openAsText
if (openAsText == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = openAsText
}
openAsItem.title = openAsTitle
val mutable =
statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
@ -308,7 +299,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
val stringToShare = statusToShare.account.username +
" - " +
statusToShare.content.toString()
statusToShare.content
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
sendIntent.type = "text/plain"
startActivity(
@ -413,7 +404,7 @@ class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), Statu
} != null
}
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) {
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) {
bottomSheetActivity?.showAccountChooserDialog(
dialogTitle,
false,

View File

@ -223,7 +223,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = actionableStatus.spoilerText,
mentionedUsernames = mentionedUsernames,
replyingStatusAuthor = actionableStatus.account.localUsername,
replyingStatusContent = status.content.toString()
replyingStatusContent = status.content.toString(),
language = actionableStatus.language,
)
)
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
@ -318,7 +319,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
val stringToShare = statusToShare.account.username +
" - " +
statusToShare.content.toString()
statusToShare.content
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_content_to)))
@ -412,7 +413,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
} != null
}
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) {
private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) {
bottomSheetActivity?.showAccountChooserDialog(
dialogTitle, false,
object : AccountSelectionListener {
@ -491,7 +492,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = redraftStatus.spoilerText,
mediaAttachments = redraftStatus.attachments,
sensitive = redraftStatus.sensitive,
poll = redraftStatus.poll?.toNewPoll(status.createdAt)
poll = redraftStatus.poll?.toNewPoll(status.createdAt),
language = redraftStatus.language,
)
)
startActivity(intent)

View File

@ -100,6 +100,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
pinned = false,
card = null,
repliesCount = 0,
language = null,
quote = null,
)
}
@ -143,6 +144,7 @@ fun Status.toEntity(
pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
language = actionableStatus.language,
quote = actionableStatus.quote?.let(gson::toJson),
)
}
@ -189,6 +191,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
quote = quote,
)
}
@ -220,6 +223,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
poll = null,
card = null,
repliesCount = status.repliesCount,
language = status.language,
quote = null,
)
} else {
@ -250,6 +254,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
quote = quote,
)
}

View File

@ -30,7 +30,6 @@ import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
@ -71,7 +70,7 @@ class CachedTimelineRemoteMediator(
maxId = cachedTopId,
sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten
limit = state.config.pageSize
).await()
)
val statuses = statusResponse.body()
if (statusResponse.isSuccessful && statuses != null) {
@ -86,14 +85,14 @@ class CachedTimelineRemoteMediator(
val statusResponse = when (loadType) {
LoadType.REFRESH -> {
api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize).await()
api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId
api.homeTimeline(maxId = maxId, limit = state.config.pageSize).await()
api.homeTimeline(maxId = maxId, limit = state.config.pageSize)
}
}

View File

@ -51,7 +51,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamingManager
import retrofit2.HttpException
import javax.inject.Inject
@ -180,7 +179,7 @@ class CachedTimelineViewModel @Inject constructor(
sinceId = nextPlaceholderId,
limit = LOAD_AT_ONCE
)
}.await()
}
val statuses = response.body()
if (!response.isSuccessful || statuses == null) {

View File

@ -45,7 +45,6 @@ import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamingManager
import retrofit2.HttpException
import retrofit2.Response
@ -319,7 +318,7 @@ class NetworkTimelineViewModel @Inject constructor(
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
}.await()
}
}
private fun StatusViewData.Concrete.update() {

View File

@ -13,16 +13,16 @@
* 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.view
package com.keylesspalace.tusky.components.viewthread
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ThreadAdapter
class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
@ -32,29 +32,25 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie
val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start)
val dividerEnd = dividerStart + divider.intrinsicWidth
val childCount = parent.childCount
val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin)
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val items = (parent.adapter as ThreadAdapter).currentList
parent.forEach { child ->
val position = parent.getChildAdapterPosition(child)
val adapter = parent.adapter as ThreadAdapter
val current = adapter.getItem(position)
val dividerTop: Int
val dividerBottom: Int
val current = items.getOrNull(position)
if (current != null) {
val above = adapter.getItem(position - 1)
dividerTop = if (above != null && above.id == current.status.inReplyToId) {
val above = items.getOrNull(position - 1)
val dividerTop = if (above != null && above.id == current.status.inReplyToId) {
child.top
} else {
child.top + avatarMargin
}
val below = adapter.getItem(position + 1)
dividerBottom = if (below != null && current.id == below.status.inReplyToId &&
adapter.detailedStatusPosition != position
) {
val below = items.getOrNull(position + 1)
val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) {
child.bottom
} else {
child.top + avatarMargin

View File

@ -0,0 +1,95 @@
/* 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.components.viewthread
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class ThreadAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusActionListener: StatusActionListener
) : ListAdapter<StatusViewData.Concrete, StatusBaseViewHolder>(ThreadDifferCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder {
return when (viewType) {
VIEW_TYPE_STATUS -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status, parent, false)
StatusViewHolder(view)
}
VIEW_TYPE_STATUS_DETAILED -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status_detailed, parent, false)
StatusDetailedViewHolder(view)
}
else -> error("Unknown item type: $viewType")
}
}
override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) {
val status = getItem(position)
viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions)
}
override fun getItemViewType(position: Int): Int {
return if (getItem(position).isDetailed) {
VIEW_TYPE_STATUS_DETAILED
} else {
VIEW_TYPE_STATUS
}
}
companion object {
private const val VIEW_TYPE_STATUS = 0
private const val VIEW_TYPE_STATUS_DETAILED = 1
val ThreadDifferCallback = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areItemsTheSame(
oldItem: StatusViewData.Concrete,
newItem: StatusViewData.Concrete
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: StatusViewData.Concrete,
newItem: StatusViewData.Concrete
): Boolean {
return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(
oldItem: StatusViewData.Concrete,
newItem: StatusViewData.Concrete
): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update the whole view holder
null
}
}
}
}

View File

@ -0,0 +1,62 @@
/* 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
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_view_thread)
val id = intent.getStringExtra(ID_EXTRA)!!
val url = intent.getStringExtra(URL_EXTRA)!!
val fragment =
supportFragmentManager.findFragmentByTag(FRAGMENT_TAG + id) as ViewThreadFragment?
?: ViewThreadFragment.newInstance(id, url)
supportFragmentManager.commit {
replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id)
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
fun startIntent(context: Context, id: String, url: String): Intent {
val intent = Intent(context, ViewThreadActivity::class.java)
intent.putExtra(ID_EXTRA, id)
intent.putExtra(URL_EXTRA, url)
return intent
}
private const val ID_EXTRA = "id"
private const val URL_EXTRA = "url"
private const val FRAGMENT_TAG = "ViewThreadFragment_"
}
}

View File

@ -0,0 +1,343 @@
/* 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
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import com.google.android.material.snackbar.Snackbar
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.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
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.launch
import java.io.IOException
import javax.inject.Inject
class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: ViewThreadViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentViewThreadBinding::bind)
private lateinit var adapter: ThreadAdapter
private lateinit var thisThreadsStatusId: String
private var alwaysShowSensitiveMedia = false
private var alwaysOpenSpoiler = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!!
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) {
CardViewMode.INDENTED
} else {
CardViewMode.NONE
},
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
confirmFavourites = preferences.getBoolean("confirmFavourites", false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = accountManager.activeAccount!!.domain in CAN_USE_QUOTE_ID
)
adapter = ThreadAdapter(statusDisplayOptions, this)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_view_thread, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener {
activity?.onBackPressedDispatcher?.onBackPressed()
}
binding.toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_reveal -> {
viewModel.toggleRevealButton()
true
}
R.id.action_open_in_web -> {
context?.openLink(requireArguments().getString(URL_EXTRA)!!)
true
}
else -> false
}
}
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(
binding.recyclerView,
this
) { index -> adapter.currentList.getOrNull(index) }
)
val divider = DividerItemDecoration(context, LinearLayout.VERTICAL)
binding.recyclerView.addItemDecoration(divider)
binding.recyclerView.addItemDecoration(ConversationLineItemDecoration(requireContext()))
alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler
binding.recyclerView.adapter = adapter
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
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()
}
is ThreadUiState.Error -> {
Log.w(TAG, "failed to load status", uiState.throwable)
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) {
viewModel.retry(thisThreadsStatusId)
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
viewModel.retry(thisThreadsStatusId)
}
}
}
is ThreadUiState.Success -> {
adapter.submitList(uiState.statuses) {
if (viewModel.isInitialLoad) {
viewModel.isInitialLoad = false
val detailedPosition = adapter.currentList.indexOfFirst { viewData ->
viewData.isDetailed
}
binding.recyclerView.scrollToPosition(detailedPosition)
}
}
updateRevealButton(uiState.revealButton)
binding.swipeRefreshLayout.isRefreshing = uiState.refreshing
binding.recyclerView.show()
binding.statusView.hide()
binding.progressBar.hide()
}
}
}
}
lifecycleScope.launch {
viewModel.errors.collect { throwable ->
Log.w(TAG, "failed to load status context", throwable)
Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT)
.setAction(R.string.action_retry) {
viewModel.retry(thisThreadsStatusId)
}
.show()
}
}
viewModel.loadThread(thisThreadsStatusId)
}
private fun updateRevealButton(state: RevealButtonState) {
val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal)
menuItem.isVisible = state != RevealButtonState.NO_BUTTON
menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp)
}
override fun onRefresh() {
viewModel.refresh(thisThreadsStatusId)
}
override fun onReply(position: Int) {
super.reply(adapter.currentList[position].status)
}
override fun onReblog(reblog: Boolean, position: Int) {
val status = adapter.currentList[position]
viewModel.reblog(reblog, status)
}
override fun onFavourite(favourite: Boolean, position: Int) {
val status = adapter.currentList[position]
viewModel.favorite(favourite, status)
}
override fun onQuote(position: Int) {
super.quote(adapter.currentList[position].status)
}
override fun onBookmark(bookmark: Boolean, position: Int) {
val status = adapter.currentList[position]
viewModel.bookmark(bookmark, status)
}
override fun onMore(view: View, position: Int) {
super.more(adapter.currentList[position].status, view, position)
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
val status = adapter.currentList[position].status
super.viewMedia(attachmentIndex, list(status), view)
}
override fun onViewThread(position: Int) {
val status = adapter.currentList[position]
if (thisThreadsStatusId == status.id) {
// If already viewing this thread, don't reopen it.
return
}
super.viewThread(status.actionableId, status.actionable.url)
}
override fun onViewUrl(url: String, text: String) {
val status: StatusViewData.Concrete? = viewModel.detailedStatus()
if (status != null && status.status.url == url) {
// already viewing the status with this url
// probably just a preview federated and the user is clicking again to view more -> open the browser
// this can happen with some friendica statuses
requireContext().openLink(url)
return
}
super.onViewUrl(url, text)
}
override fun onOpenReblog(position: Int) {
// there are no reblogs in threads
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.changeExpanded(expanded, adapter.currentList[position])
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.changeContentShowing(isShowing, adapter.currentList[position])
}
override fun onLoadMore(position: Int) {
// only used in timelines
}
override fun onShowReblogs(position: Int) {
val statusId = adapter.currentList[position].id
val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId)
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent)
}
override fun onShowFavs(position: Int) {
val statusId = adapter.currentList[position].id
val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId)
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent)
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position])
}
override fun onViewTag(tag: String) {
super.viewTag(tag)
}
override fun onViewAccount(id: String) {
super.viewAccount(id)
}
public override fun removeItem(position: Int) {
val status = adapter.currentList[position]
if (status.isDetailed) {
// the main status we are viewing is being removed, finish the activity
activity?.finish()
return
}
viewModel.removeStatus(status)
}
override fun onVoteInPoll(position: Int, choices: List<Int>) {
val status = adapter.currentList[position]
viewModel.voteInPoll(choices, status)
}
companion object {
private const val TAG = "ViewThreadFragment"
private const val ID_EXTRA = "id"
private const val URL_EXTRA = "url"
fun newInstance(id: String, url: String): ViewThreadFragment {
val arguments = Bundle(2)
val fragment = ViewThreadFragment()
arguments.putString(ID_EXTRA, id)
arguments.putString(URL_EXTRA, url)
fragment.arguments = arguments
return fragment
}
}
}

View File

@ -0,0 +1,426 @@
/* 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
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.BookmarkEvent
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.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
class ViewThreadViewModel @Inject constructor(
private val api: MastodonApi,
private val filterModel: FilterModel,
private val timelineCases: TimelineCases,
eventHub: EventHub,
accountManager: AccountManager
) : ViewModel() {
private val _uiState: MutableStateFlow<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
val uiState: Flow<ThreadUiState>
get() = _uiState
private val _errors = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val errors: Flow<Throwable>
get() = _errors
var isInitialLoad: Boolean = true
private val alwaysShowSensitiveMedia: Boolean
private val alwaysOpenSpoiler: Boolean
init {
val activeAccount = accountManager.activeAccount
alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
viewModelScope.launch {
eventHub.events
.asFlow()
.collect { event ->
when (event) {
is FavoriteEvent -> handleFavEvent(event)
is ReblogEvent -> handleReblogEvent(event)
is BookmarkEvent -> handleBookmarkEvent(event)
is PinEvent -> handlePinEvent(event)
is BlockEvent -> removeAllByAccountId(event.accountId)
is StatusComposedEvent -> handleStatusComposedEvent(event)
is StatusDeletedEvent -> handleStatusDeletedEvent(event)
}
}
}
loadFilters()
}
fun loadThread(id: String) {
viewModelScope.launch {
val contextCall = async { api.statusContext(id) }
val statusCall = async { api.statusAsync(id) }
val contextResult = contextCall.await()
val statusResult = statusCall.await()
val status = statusResult.getOrElse { exception ->
_uiState.value = ThreadUiState.Error(exception)
return@launch
}
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
)
}, { throwable ->
_errors.emit(throwable)
_uiState.value = ThreadUiState.Success(
statuses = listOf(status.toViewData(true)),
revealButton = RevealButtonState.NO_BUTTON,
refreshing = false
)
})
}
}
fun retry(id: String) {
_uiState.value = ThreadUiState.Loading
loadThread(id)
}
fun refresh(id: String) {
updateSuccess { uiState ->
uiState.copy(refreshing = true)
}
loadThread(id)
}
fun detailedStatus(): StatusViewData.Concrete? {
return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status ->
status.isDetailed
}
}
fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.reblog(status.actionableId, reblog).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to reblog status " + status.actionableId, t)
}
}
}
fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.favourite(status.actionableId, favorite).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch {
try {
timelineCases.bookmark(status.actionableId, bookmark).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to favourite status " + status.actionableId, t)
}
}
}
fun voteInPoll(choices: List<Int>, status: StatusViewData.Concrete): Job = viewModelScope.launch {
val poll = status.status.actionableStatus.poll ?: run {
Log.w(TAG, "No poll on status ${status.id}")
return@launch
}
val votedPoll = poll.votedCopy(choices)
updateStatus(status.id) { status ->
status.copy(poll = votedPoll)
}
try {
timelineCases.voteInPoll(status.actionableId, poll.id, choices).await()
} catch (t: Exception) {
ifExpected(t) {
Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t)
}
}
}
fun removeStatus(statusToRemove: StatusViewData.Concrete) {
updateSuccess { uiState ->
uiState.copy(
statuses = uiState.statuses.filterNot { status -> status == statusToRemove }
)
}
}
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
updateSuccess { uiState ->
val statuses = uiState.statuses.map { viewData ->
if (viewData.id == status.id) {
viewData.copy(isExpanded = expanded)
} else {
viewData
}
}
uiState.copy(
statuses = statuses,
revealButton = statuses.getRevealButtonState()
)
}
}
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
updateStatusViewData(status.id) { viewData ->
viewData.copy(isShowingContent = isShowing)
}
}
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
updateStatusViewData(status.id) { viewData ->
viewData.copy(isCollapsed = isCollapsed)
}
}
private fun handleFavEvent(event: FavoriteEvent) {
updateStatus(event.statusId) { status ->
status.copy(favourited = event.favourite)
}
}
private fun handleReblogEvent(event: ReblogEvent) {
updateStatus(event.statusId) { status ->
status.copy(reblogged = event.reblog)
}
}
private fun handleBookmarkEvent(event: BookmarkEvent) {
updateStatus(event.statusId) { status ->
status.copy(bookmarked = event.bookmark)
}
}
private fun handlePinEvent(event: PinEvent) {
updateStatus(event.statusId) { status ->
status.copy(pinned = event.pinned)
}
}
private fun removeAllByAccountId(accountId: String) {
updateSuccess { uiState ->
uiState.copy(
statuses = uiState.statuses.filter { viewData ->
viewData.status.account.id == accountId
}
)
}
}
private fun handleStatusComposedEvent(event: StatusComposedEvent) {
val eventStatus = event.status
updateSuccess { uiState ->
val statuses = uiState.statuses
val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed }
val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id }
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
// there is a new reply to the detailed status or below -> display it
val newStatuses = statuses.subList(0, repliedIndex + 1) +
eventStatus.toViewData() +
statuses.subList(repliedIndex + 1, statuses.size)
uiState.copy(statuses = newStatuses)
} else {
uiState
}
}
}
private fun handleStatusDeletedEvent(event: StatusDeletedEvent) {
updateSuccess { uiState ->
uiState.copy(
statuses = uiState.statuses.filter { status ->
status.id != event.statusId
}
)
}
}
fun toggleRevealButton() {
updateSuccess { uiState ->
when (uiState.revealButton) {
RevealButtonState.HIDE -> uiState.copy(
statuses = uiState.statuses.map { viewData ->
viewData.copy(isExpanded = false)
},
revealButton = RevealButtonState.REVEAL
)
RevealButtonState.REVEAL -> uiState.copy(
statuses = uiState.statuses.map { viewData ->
viewData.copy(isExpanded = true)
},
revealButton = RevealButtonState.HIDE
)
else -> uiState
}
}
}
private fun List<StatusViewData.Concrete>.getRevealButtonState(): RevealButtonState {
val hasWarnings = any { viewData ->
viewData.status.spoilerText.isNotEmpty()
}
return if (hasWarnings) {
val allExpanded = none { viewData ->
!viewData.isExpanded
}
if (allExpanded) {
RevealButtonState.HIDE
} else {
RevealButtonState.REVEAL
}
} else {
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)
return@launch
}
filterModel.initWithFilters(
filters.filter { filter ->
filter.context.contains(Filter.THREAD)
}
)
updateSuccess { uiState ->
val statuses = uiState.statuses.filter()
uiState.copy(
statuses = statuses,
revealButton = statuses.getRevealButtonState()
)
}
}
}
private fun List<StatusViewData.Concrete>.filter(): List<StatusViewData.Concrete> {
return filter { status ->
status.isDetailed || !filterModel.shouldFilterStatus(status.status)
}
}
private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete {
return toViewData(
isShowingContent = alwaysShowSensitiveMedia || !actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler,
isCollapsed = !detailed,
isDetailed = detailed
)
}
private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) {
_uiState.update { uiState ->
if (uiState is ThreadUiState.Success) {
updater(uiState)
} else {
uiState
}
}
}
private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) {
updateSuccess { uiState ->
uiState.copy(
statuses = uiState.statuses.map { viewData ->
if (viewData.id == statusId) {
updater(viewData)
} else {
viewData
}
}
)
}
}
private fun updateStatus(statusId: String, updater: (Status) -> Status) {
updateStatusViewData(statusId) { viewData ->
viewData.copy(
status = updater(viewData.status)
)
}
}
companion object {
private const val TAG = "ViewThreadViewModel"
}
}
sealed interface ThreadUiState {
object Loading : ThreadUiState
class Error(val throwable: Throwable) : ThreadUiState
data class Success(
val statuses: List<StatusViewData.Concrete>,
val revealButton: RevealButtonState,
val refreshing: Boolean
) : ThreadUiState
}
enum class RevealButtonState {
NO_BUTTON, REVEAL, HIDE
}

View File

@ -15,9 +15,12 @@
package com.keylesspalace.tusky.db
import android.content.Context
import android.util.Log
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@ -225,4 +228,18 @@ class AccountManager @Inject constructor(db: AppDatabase) {
identifier == it.identifier
}
}
/**
* @return true if the name of the currently-selected account should be displayed in UIs
*/
fun shouldDisplaySelfUsername(context: Context): Boolean {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val showUsernamePreference = sharedPreferences.getString(PrefKeys.SHOW_SELF_USERNAME, "disambiguate")
if (showUsernamePreference == "always")
return true
if (showUsernamePreference == "never")
return false
return accounts.size > 1 // "disambiguate"
}
}

View File

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 39)
}, version = 42)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -582,4 +582,33 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
}
};
public static final Migration MIGRATION_39_40 = new Migration(39, 40) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER");
}
};
public static final Migration MIGRATION_40_41 = new Migration(40, 41) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT");
}
};
public static final Migration MIGRATION_41_42 = new Migration(41, 42) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT");
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT");
}
};
}

View File

@ -22,6 +22,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import kotlinx.parcelize.Parcelize
@ -38,7 +39,9 @@ data class DraftEntity(
val visibility: Status.Visibility,
val attachments: List<DraftAttachment>,
val poll: NewPoll?,
val failedToSend: Boolean
val failedToSend: Boolean,
val scheduledAt: String?,
val language: String?,
)
/**
@ -50,6 +53,7 @@ data class DraftEntity(
data class DraftAttachment(
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
@SerializedName(value = "focus") val focus: Attachment.Focus?,
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type
) : Parcelable {
val uri: Uri

View File

@ -20,15 +20,37 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import androidx.room.Update
@Dao
interface InstanceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(instance: InstanceInfoEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class)
suspend fun insertOrIgnore(instance: InstanceInfoEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(emojis: EmojisEntity)
@Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class)
suspend fun updateOrIgnore(instance: InstanceInfoEntity)
@Transaction
suspend fun upsert(instance: InstanceInfoEntity) {
if (insertOrIgnore(instance) == -1L) {
updateOrIgnore(instance)
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class)
suspend fun insertOrIgnore(emojis: EmojisEntity): Long
@Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class)
suspend fun updateOrIgnore(emojis: EmojisEntity)
@Transaction
suspend fun upsert(emojis: EmojisEntity) {
if (insertOrIgnore(emojis) == -1L) {
updateOrIgnore(emojis)
}
}
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")

View File

@ -31,7 +31,14 @@ data class InstanceEntity(
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
val version: String?,
val videoSizeLimit: Int?,
val imageSizeLimit: Int?,
val imageMatrixLimit: Int?,
val maxMediaAttachments: Int?,
val maxFields: Int?,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
)
@TypeConverters(Converters::class)
@ -48,5 +55,12 @@ data class InstanceInfoEntity(
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
val version: String?,
val videoSizeLimit: Int?,
val imageSizeLimit: Int?,
val imageMatrixLimit: Int?,
val maxMediaAttachments: Int?,
val maxFields: Int?,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
)

View File

@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
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.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language,
s.quote,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',

View File

@ -81,6 +81,7 @@ data class TimelineStatusEntity(
val contentShowing: Boolean,
val pinned: Boolean,
val card: String?,
val language: String?,
val quote: String?,
)

View File

@ -27,7 +27,6 @@ import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewThreadActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -39,6 +38,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
import net.accelf.yuito.AccessTokenLoginActivity
@ -78,7 +78,7 @@ abstract class ActivitiesModule {
abstract fun contributesStatusListActivity(): StatusListActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesSearchAvtivity(): SearchActivity
abstract fun contributesSearchActivity(): SearchActivity
@ContributesAndroidInjector
abstract fun contributesAboutActivity(): AboutActivity

View File

@ -71,7 +71,8 @@ class AppModule {
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42,
)
.build()
}

View File

@ -30,9 +30,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragmen
import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
import com.keylesspalace.tusky.fragment.AccountListFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.ViewThreadFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector

View File

@ -5,15 +5,18 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.account.AccountViewModel
import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel
import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel
@ -109,6 +112,21 @@ abstract class ViewModelModule {
@ViewModelKey(NetworkTimelineViewModel::class)
internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ViewThreadViewModel::class)
internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(AccountMediaViewModel::class)
internal abstract fun accountMediaViewModel(viewModel: AccountMediaViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(LoginWebViewViewModel::class)
internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(QuickTootViewModel::class)

View File

@ -48,8 +48,8 @@ data class Announcement(
data class Reaction(
val name: String,
var count: Int,
var me: Boolean,
val count: Int,
val me: Boolean,
val url: String?,
@SerializedName("static_url") val staticUrl: String?
)

View File

@ -27,7 +27,7 @@ data class Card(
val width: Int,
val height: Int,
val blurhash: String?,
val embed_url: String?
@SerializedName("embed_url") val embedUrl: String?
) {
override fun hashCode() = url.hashCode()

View File

@ -20,14 +20,15 @@ import java.util.ArrayList
import java.util.Date
data class DeletedStatus(
var text: String?,
@SerializedName("in_reply_to_id") var inReplyToId: String?,
val text: String?,
@SerializedName("in_reply_to_id") val inReplyToId: String?,
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Status.Visibility,
val sensitive: Boolean,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>?,
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date
@SerializedName("created_at") val createdAt: Date,
val language: String?,
) {
fun isEmpty(): Boolean {
return text == null && attachments == null

View File

@ -16,12 +16,13 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.Date
data class Filter(
val id: String,
val phrase: String,
val context: List<String>,
@SerializedName("expires_at") val expiresAt: String?,
@SerializedName("expires_at") val expiresAt: Date?,
val irreversible: Boolean,
@SerializedName("whole_word") val wholeWord: Boolean
) {

View File

@ -1,3 +1,3 @@
package com.keylesspalace.tusky.entity
data class HashTag(val name: String, val url: String)
data class HashTag(val name: String, val url: String, val following: Boolean? = null)

View File

@ -20,18 +20,21 @@ import com.google.gson.annotations.SerializedName
data class Instance(
val uri: String,
val title: String,
val description: String,
val email: String,
// val description: String,
// val email: String,
val version: String,
val urls: Map<String, String>,
val stats: Map<String, Int>?,
val thumbnail: String?,
val languages: List<String>,
@SerializedName("contact_account") val contactAccount: Account,
// val urls: Map<String, String>,
// val stats: Map<String, Int>?,
// val thumbnail: String?,
// val languages: List<String>,
// @SerializedName("contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?,
val configuration: InstanceConfiguration?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?,
val pleroma: PleromaConfiguration?,
@SerializedName("upload_limit") val uploadLimit: Int?,
val rules: List<InstanceRules>?
) {
override fun hashCode(): Int {
return uri.hashCode()
@ -74,3 +77,22 @@ data class MediaAttachmentConfiguration(
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?,
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?,
)
data class PleromaConfiguration(
val metadata: PleromaMetadata?
)
data class PleromaMetadata(
@SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits
)
data class PleromaFieldLimits(
@SerializedName("max_fields") val maxFields: Int?,
@SerializedName("name_length") val nameLength: Int?,
@SerializedName("value_length") val valueLength: Int?
)
data class InstanceRules(
val id: String,
val text: String
)

View File

@ -28,6 +28,7 @@ data class NewStatus(
@SerializedName("media_ids") val mediaIds: List<String>?,
@SerializedName("scheduled_at") val scheduledAt: String?,
val poll: NewPoll?,
val language: String?,
@SerializedName("quote_id") val quoteId: String?,
)

View File

@ -25,7 +25,7 @@ data class Status(
val id: String,
val url: String?, // not present if it's reblog
val account: TimelineAccount,
@SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_id") val inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?,
val content: String,
@ -34,13 +34,13 @@ data class Status(
@SerializedName("reblogs_count") val reblogsCount: Int,
@SerializedName("favourites_count") val favouritesCount: Int,
@SerializedName("replies_count") val repliesCount: Int,
var reblogged: Boolean,
var favourited: Boolean,
var bookmarked: Boolean,
var sensitive: Boolean,
val reblogged: Boolean,
val favourited: Boolean,
val bookmarked: Boolean,
val sensitive: Boolean,
@SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String,
val visibility: Visibility,
@SerializedName("media_attachments", alternate = ["attachment"]) var attachments: ArrayList<Attachment>,
@SerializedName("media_attachments", alternate = ["attachment"]) val attachments: ArrayList<Attachment>,
@SerializedName("mentions", alternate = ["tag"]) val mentions: List<Mention>,
val tags: List<HashTag>?,
val application: Application?,
@ -48,6 +48,7 @@ data class Status(
val muted: Boolean?,
val poll: Poll?,
val card: Card?,
val language: String?,
val quote: Status?,
) {
@ -138,7 +139,8 @@ data class Status(
sensitive = sensitive,
attachments = attachments,
poll = poll,
createdAt = createdAt
createdAt = createdAt,
language = language,
)
}

View File

@ -78,8 +78,8 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = arguments?.getSerializable(ARG_TYPE) as Type
id = arguments?.getString(ARG_ID)
type = requireArguments().getSerializable(ARG_TYPE) as Type
id = requireArguments().getString(ARG_ID)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -100,7 +100,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis)
Type.FOLLOW_REQUESTS -> {
val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.get(ARG_ACCOUNT_LOCKED) == true)
val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true)
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis)
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
followRequestsAdapter

View File

@ -41,6 +41,7 @@ import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
@ -154,6 +155,7 @@ public abstract class SFragment extends Fragment implements Injectable {
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString());
composeOptions.setLanguage(actionableStatus.getLanguage());
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
getActivity().startActivity(intent);
@ -316,6 +318,14 @@ public abstract class SFragment extends Fragment implements Injectable {
}
case R.id.pin: {
timelineCases.pin(status.getId(), !status.isPinned())
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
String message = e.getMessage();
if (message == null) {
message = getString(status.isPinned() ? R.string.failed_to_unpin : R.string.failed_to_pin);
}
Snackbar.make(view, message, Snackbar.LENGTH_LONG).show();
})
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe();
return true;
@ -381,7 +391,7 @@ public abstract class SFragment extends Fragment implements Injectable {
urlIndex);
if (view != null) {
String url = active.getAttachment().getUrl();
ViewCompat.setTransitionName(view, url);
view.setTransitionName(url);
ActivityOptionsCompat options =
ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(),
view, url);
@ -452,6 +462,7 @@ public abstract class SFragment extends Fragment implements Injectable {
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
composeOptions.setSensitive(deletedStatus.getSensitive());
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(deletedStatus.getLanguage());
if (deletedStatus.getPoll() != null) {
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
}

View File

@ -214,7 +214,7 @@ class ViewImageFragment : ViewMediaFragment() {
.dontAnimate()
.onlyRetrieveFromCache(true)
.centerInside()
.addListener(ImageRequestListener(true, isThumnailRequest = true))
.addListener(ImageRequestListener(true, isThumbnailRequest = true))
)
else it
}
@ -222,10 +222,10 @@ class ViewImageFragment : ViewMediaFragment() {
.error(
glide.load(url)
.centerInside()
.addListener(ImageRequestListener(false, isThumnailRequest = false))
.addListener(ImageRequestListener(false, isThumbnailRequest = false))
)
.centerInside()
.addListener(ImageRequestListener(true, isThumnailRequest = false))
.addListener(ImageRequestListener(true, isThumbnailRequest = false))
.into(photoView)
}
@ -251,7 +251,7 @@ class ViewImageFragment : ViewMediaFragment() {
*/
private inner class ImageRequestListener(
private val isCacheRequest: Boolean,
private val isThumnailRequest: Boolean
private val isThumbnailRequest: Boolean
) : RequestListener<Drawable> {
override fun onLoadFailed(
@ -261,7 +261,7 @@ class ViewImageFragment : ViewMediaFragment() {
isFirstResource: Boolean
): Boolean {
// If cache for full image failed complete transition
if (isCacheRequest && !isThumnailRequest && shouldStartTransition &&
if (isCacheRequest && !isThumbnailRequest && shouldStartTransition &&
!startedTransition
) {
photoActionsListener.onBringUp()
@ -295,7 +295,7 @@ class ViewImageFragment : ViewMediaFragment() {
}
} else {
// This wait for transition. If there's no transition then we should hit
// another branch. take() will unsubscribe after we have it to not leak menmory
// another branch. take() will unsubscribe after we have it to not leak memory
transition
.take(1)
.subscribe {

View File

@ -22,7 +22,7 @@ import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
abstract class ViewMediaFragment : Fragment() {
private var toolbarVisibiltyDisposable: Function0<Boolean>? = null
private var toolbarVisibilityDisposable: Function0<Boolean>? = null
abstract fun setupMediaView(
url: String,
@ -83,14 +83,14 @@ abstract class ViewMediaFragment : Fragment() {
isDescriptionVisible = showingDescription
setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible)
toolbarVisibiltyDisposable = (activity as ViewMediaActivity)
toolbarVisibilityDisposable = (activity as ViewMediaActivity)
.addToolbarVisibilityListener { isVisible ->
onToolbarVisibilityChange(isVisible)
}
}
override fun onDestroyView() {
toolbarVisibiltyDisposable?.invoke()
toolbarVisibilityDisposable?.invoke()
super.onDestroyView()
}
}

View File

@ -1,693 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment;
import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.AccountListActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.adapter.ThreadAdapter;
import com.keylesspalace.tusky.appstore.BlockEvent;
import com.keylesspalace.tusky.appstore.BookmarkEvent;
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.appstore.StatusComposedEvent;
import com.keylesspalace.tusky.appstore.StatusDeletedEvent;
import com.keylesspalace.tusky.components.compose.ComposeViewModelKt;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.FilterModel;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import kotlin.collections.CollectionsKt;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
public final class ViewThreadFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable {
private static final String TAG = "ViewThreadFragment";
@Inject
public MastodonApi mastodonApi;
@Inject
public EventHub eventHub;
@Inject
public FilterModel filterModel;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ThreadAdapter adapter;
private String thisThreadsStatusId;
private boolean alwaysShowSensitiveMedia;
private boolean alwaysOpenSpoiler;
private int statusIndex = 0;
private final PairedList<Status, StatusViewData.Concrete> statuses =
new PairedList<>(new Function<Status, StatusViewData.Concrete>() {
@Override
public StatusViewData.Concrete apply(Status status) {
return ViewDataUtils.statusToViewData(
status,
alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(),
alwaysOpenSpoiler,
true
);
}
});
public static ViewThreadFragment newInstance(String id) {
Bundle arguments = new Bundle(1);
ViewThreadFragment fragment = new ViewThreadFragment();
arguments.putString("id", id);
fragment.setArguments(arguments);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
thisThreadsStatusId = getArguments().getString("id");
SharedPreferences preferences =
PreferenceManager.getDefaultSharedPreferences(getActivity());
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
preferences.getBoolean("animateGifAvatars", false),
accountManager.getActiveAccount().getMediaPreviewEnabled(),
preferences.getBoolean("absoluteTimeView", false),
preferences.getBoolean("showBotOverlay", true),
preferences.getBoolean("useBlurhash", true),
preferences.getBoolean("showCardsInTimelines", false) ?
CardViewMode.INDENTED :
CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true),
preferences.getBoolean("confirmFavourites", false),
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain())
);
adapter = new ThreadAdapter(statusDisplayOptions, this);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false);
Context context = getContext();
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout);
swipeRefreshLayout.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue);
recyclerView = rootView.findViewById(R.id.recyclerView);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setAccessibilityDelegateCompat(
new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull));
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
recyclerView.addItemDecoration(divider);
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
reloadFilters();
recyclerView.setAdapter(adapter);
statuses.clear();
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
return rootView;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
onRefresh();
eventHub.getEvents()
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(event -> {
if (event instanceof FavoriteEvent) {
handleFavEvent((FavoriteEvent) event);
} else if (event instanceof ReblogEvent) {
handleReblogEvent((ReblogEvent) event);
} else if (event instanceof BookmarkEvent) {
handleBookmarkEvent((BookmarkEvent) event);
} else if (event instanceof PinEvent) {
handlePinEvent(((PinEvent) event));
} else if (event instanceof BlockEvent) {
removeAllByAccountId(((BlockEvent) event).getAccountId());
} else if (event instanceof StatusComposedEvent) {
handleStatusComposedEvent((StatusComposedEvent) event);
} else if (event instanceof StatusDeletedEvent) {
handleStatusDeletedEvent((StatusDeletedEvent) event);
}
});
}
public void onRevealPressed() {
boolean allExpanded = allExpanded();
for (int i = 0; i < statuses.size(); i++) {
updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded));
}
updateRevealIcon();
}
private boolean allExpanded() {
boolean allExpanded = true;
for (int i = 0; i < statuses.size(); i++) {
if (!statuses.getPairedItem(i).isExpanded()) {
allExpanded = false;
break;
}
}
return allExpanded;
}
@Override
public void onRefresh() {
sendStatusRequest(thisThreadsStatusId);
sendThreadRequest(thisThreadsStatusId);
}
@Override
public void onReply(int position) {
super.reply(statuses.get(position));
}
@Override
public void onReblog(final boolean reblog, final int position) {
final Status status = statuses.get(position);
timelineCases.reblog(statuses.get(position).getId(), reblog)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to reblog status: " + status.getId(), t)
);
}
@Override
public void onFavourite(final boolean favourite, final int position) {
final Status status = statuses.get(position);
timelineCases.favourite(statuses.get(position).getId(), favourite)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to favourite status: " + status.getId(), t)
);
}
@Override
public void onQuote(int position) {
super.quote(statuses.get(position));
}
@Override
public void onBookmark(final boolean bookmark, final int position) {
final Status status = statuses.get(position);
timelineCases.bookmark(statuses.get(position).getId(), bookmark)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
this::replaceStatus,
(t) -> Log.d(TAG,
"Failed to bookmark status: " + status.getId(), t)
);
}
private void replaceStatus(Status status) {
updateStatus(status.getId(), (__) -> status);
}
private void updateStatus(String statusId, Function<Status, Status> mapper) {
int position = indexOfStatus(statusId);
if (position >= 0 && position < statuses.size()) {
Status oldStatus = statuses.get(position);
Status newStatus = mapper.apply(oldStatus);
StatusViewData.Concrete oldViewData = statuses.getPairedItem(position);
statuses.set(position, newStatus);
updateViewData(position, oldViewData.copyWithStatus(newStatus));
}
}
@Override
public void onMore(@NonNull View view, int position) {
super.more(statuses.get(position), view, position);
}
@Override
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Status status = statuses.get(position);
super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view);
}
@Override
public void onViewThread(int position) {
Status status = statuses.get(position);
if (thisThreadsStatusId.equals(status.getId())) {
// If already viewing this thread, don't reopen it.
return;
}
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
}
@Override
public void onViewUrl(String url, String text) {
Status status = null;
if (!statuses.isEmpty()) {
status = statuses.get(statusIndex);
}
if (status != null && status.getUrl().equals(url)) {
// already viewing the status with this url
// probably just a preview federated and the user is clicking again to view more -> open the browser
// this can happen with some friendica statuses
LinkHelper.openLink(requireContext(), url);
return;
}
super.onViewUrl(url, text);
}
@Override
public void onOpenReblog(int position) {
// there should be no reblogs in the thread but let's implement it to be sure
super.openReblog(statuses.get(position));
}
@Override
public void onExpandedChange(boolean expanded, int position) {
updateViewData(
position,
statuses.getPairedItem(position).copyWithExpanded(expanded)
);
updateRevealIcon();
}
@Override
public void onContentHiddenChange(boolean isShowing, int position) {
updateViewData(
position,
statuses.getPairedItem(position).copyWithShowingContent(isShowing)
);
}
private void updateViewData(int position, StatusViewData.Concrete newViewData) {
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override
public void onLoadMore(int position) {
}
@Override
public void onShowReblogs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onShowFavs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onContentCollapsedChange(boolean isCollapsed, int position) {
adapter.setItem(
position,
statuses.getPairedItem(position).copyWithCollapsed(isCollapsed),
true
);
}
@Override
public void onViewTag(String tag) {
super.viewTag(tag);
}
@Override
public void onViewAccount(String id) {
super.viewAccount(id);
}
@Override
public void removeItem(int position) {
if (position == statusIndex) {
//the status got removed, close the activity
getActivity().finish();
}
statuses.remove(position);
adapter.setStatuses(statuses.getPairedCopy());
}
public void onVoteInPoll(int position, @NonNull List<Integer> choices) {
final Status status = statuses.get(position).getActionableStatus();
setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices));
timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this)))
.subscribe(
(newPoll) -> setVoteForPoll(status.getId(), newPoll),
(t) -> Log.d(TAG,
"Failed to vote in poll: " + status.getId(), t)
);
}
private void setVoteForPoll(String statusId, Poll newPoll) {
updateStatus(statusId, s -> s.copyWithPoll(newPoll));
}
private void removeAllByAccountId(String accountId) {
Status status = null;
if (!statuses.isEmpty()) {
status = statuses.get(statusIndex);
}
// using iterator to safely remove items while iterating
Iterator<Status> iterator = statuses.iterator();
while (iterator.hasNext()) {
Status s = iterator.next();
if (s.getAccount().getId().equals(accountId) || s.getActionableStatus().getAccount().getId().equals(accountId)) {
iterator.remove();
}
}
statusIndex = statuses.indexOf(status);
if (statusIndex == -1) {
//the status got removed, close the activity
getActivity().finish();
return;
}
adapter.setDetailedStatusPosition(statusIndex);
adapter.setStatuses(statuses.getPairedCopy());
}
private void sendStatusRequest(final String id) {
mastodonApi.status(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
status -> {
int position = setStatus(status);
recyclerView.scrollToPosition(position);
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void sendThreadRequest(final String id) {
mastodonApi.statusContext(id)
.observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
context -> {
swipeRefreshLayout.setRefreshing(false);
setContext(context.getAncestors(), context.getDescendants());
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void onThreadRequestFailure(final String id, final Throwable throwable) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
if (view != null) {
Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, v -> {
sendThreadRequest(id);
sendStatusRequest(id);
})
.show();
} else {
Log.e(TAG, "Network request failed", throwable);
}
}
private int setStatus(Status status) {
if (statuses.size() > 0
&& statusIndex < statuses.size()
&& statuses.get(statusIndex).getId().equals(status.getId())) {
// Do not add this status on refresh, it's already in there.
statuses.set(statusIndex, status);
return statusIndex;
}
int i = statusIndex;
statuses.add(i, status);
adapter.setDetailedStatusPosition(i);
adapter.addItem(i, statuses.getPairedItem(i));
updateRevealIcon();
return i;
}
private void setContext(List<Status> unfilteredAncestors, List<Status> unfilteredDescendants) {
Status mainStatus = null;
// In case of refresh, remove old ancestors and descendants first. We'll remove all blindly,
// as we have no guarantee on their order to be the same as before
int oldSize = statuses.size();
if (oldSize > 1) {
mainStatus = statuses.get(statusIndex);
statuses.clear();
adapter.clearItems();
}
ArrayList<Status> ancestors = new ArrayList<>();
for (Status status : unfilteredAncestors)
if (!filterModel.shouldFilterStatus(status))
ancestors.add(status);
// Insert newly fetched ancestors
statusIndex = ancestors.size();
adapter.setDetailedStatusPosition(statusIndex);
statuses.addAll(0, ancestors);
List<StatusViewData.Concrete> ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex);
if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) {
String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." +
" ancestors.size == %d ancestorsViewDatas.size == %d," +
" statuses.size == %d",
ancestors.size(), ancestorsViewDatas.size(), statuses.size());
throw new AssertionError(error);
}
adapter.addAll(0, ancestorsViewDatas);
if (mainStatus != null) {
// In case we needed to delete everything (which is way easier than deleting
// everything except one), re-insert the remaining status here.
// Not filtering the main status, since the user explicitly chose to be here
statuses.add(statusIndex, mainStatus);
StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex);
adapter.addItem(statusIndex, viewData);
}
ArrayList<Status> descendants = new ArrayList<>();
for (Status status : unfilteredDescendants)
if (!filterModel.shouldFilterStatus(status))
descendants.add(status);
// Insert newly fetched descendants
statuses.addAll(descendants);
List<StatusViewData.Concrete> descendantsViewData;
descendantsViewData = statuses.getPairedCopy()
.subList(statuses.size() - descendants.size(), statuses.size());
if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) {
String error = String.format(Locale.getDefault(),
"Incorrectly got statusViewData sublist." +
" descendants.size == %d descendantsViewData.size == %d," +
" statuses.size == %d",
descendants.size(), descendantsViewData.size(), statuses.size());
throw new AssertionError(error);
}
adapter.addAll(descendantsViewData);
updateRevealIcon();
}
private void handleFavEvent(FavoriteEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setFavourited(event.getFavourite());
return s;
});
}
private void handleReblogEvent(ReblogEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setReblogged(event.getReblog());
return s;
});
}
private void handleBookmarkEvent(BookmarkEvent event) {
updateStatus(event.getStatusId(), (s) -> {
s.setBookmarked(event.getBookmark());
return s;
});
}
private void handlePinEvent(PinEvent event) {
updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned()));
}
private void handleStatusComposedEvent(StatusComposedEvent event) {
Status eventStatus = event.getStatus();
if (eventStatus.getInReplyToId() == null) return;
if (eventStatus.getInReplyToId().equals(thisThreadsStatusId)) {
insertStatus(eventStatus, statuses.size());
} else {
// If new status is a reply to some status in the thread, insert new status after it
// We only check statuses below main status, ones on top don't belong to this thread
for (int i = statusIndex; i < statuses.size(); i++) {
Status status = statuses.get(i);
if (eventStatus.getInReplyToId().equals(status.getId())) {
insertStatus(eventStatus, i + 1);
break;
}
}
}
}
private void insertStatus(Status status, int at) {
statuses.add(at, status);
adapter.addItem(at, statuses.getPairedItem(at));
}
private void handleStatusDeletedEvent(StatusDeletedEvent event) {
int index = this.indexOfStatus(event.getStatusId());
if (index != -1) {
statuses.remove(index);
adapter.removeItem(index);
}
}
private int indexOfStatus(String statusId) {
return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId));
}
private void updateRevealIcon() {
ViewThreadActivity activity = ((ViewThreadActivity) getActivity());
if (activity == null) return;
boolean hasAnyWarnings = false;
// Statuses are updated from the main thread so nothing should change while iterating
for (int i = 0; i < statuses.size(); i++) {
if (!TextUtils.isEmpty(statuses.get(i).getSpoilerText())) {
hasAnyWarnings = true;
break;
}
}
if (!hasAnyWarnings) {
activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN);
return;
}
activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE :
ViewThreadActivity.REVEAL_BUTTON_REVEAL);
}
private void reloadFilters() {
mastodonApi.getFilters()
.to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(
(filters) -> {
List<Filter> relevantFilters = CollectionsKt.filter(
filters,
(f) -> f.getContext().contains(Filter.THREAD)
);
filterModel.initWithFilters(relevantFilters);
recyclerView.post(this::applyFilters);
},
(t) -> Log.e(TAG, "Failed to load filters", t)
);
}
private void applyFilters() {
CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus);
adapter.setStatuses(this.statuses.getPairedCopy());
}
}

View File

@ -171,12 +171,11 @@ class ViewVideoFragment : ViewMediaFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val attachment = arguments?.getParcelable<Attachment>(ARG_ATTACHMENT)
val url: String
if (attachment == null) {
throw IllegalArgumentException("attachment has to be set")
}
url = attachment.url
val url = attachment.url
isAudio = attachment.type == Attachment.Type.AUDIO
finalizeViewSetup(url, attachment.previewUrl, attachment.description)
}

View File

@ -3,6 +3,8 @@ package com.keylesspalace.tusky.network
import android.text.TextUtils
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date
import java.util.regex.Pattern
import javax.inject.Inject
@ -33,11 +35,12 @@ class FilterModel @Inject constructor() {
.mapNotNull { it.description }
return (
matcher.reset(status.actionableStatus.content).find() ||
matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() ||
(spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) ||
(
attachmentsDescriptions.isNotEmpty() &&
matcher.reset(attachmentsDescriptions.joinToString("\n")).find()
matcher.reset(attachmentsDescriptions.joinToString("\n"))
.find()
)
)
}
@ -53,8 +56,11 @@ class FilterModel @Inject constructor() {
}
private fun makeFilter(filters: List<Filter>): Pattern? {
if (filters.isEmpty()) return null
val tokens = filters.map { filterToRegexToken(it) }
val now = Date()
val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true }
if (nonExpiredFilters.isEmpty()) return null
val tokens = nonExpiredFilters
.map { filterToRegexToken(it) }
return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE)
}

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