Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2021-07-03 23:59:57 +09:00
commit 2005b32dfa
356 changed files with 12939 additions and 11947 deletions

View File

@ -11,17 +11,23 @@
All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```.
### Translation ### Translation
Translations are done through https://weblate.tusky.app/projects/tusky/tusky/ . Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/).
To add a new language, clic on the 'Start a new translation' button on at the bottom of the page. To add a new language, click on the 'Start a new translation' button on at the bottom of the page.
### Kotlin ### Kotlin
This project is in the process of migrating to Kotlin, we prefer new code to be written in Kotlin. We try to follow the [Kotlin Style Guide](https://android.github.io/kotlin-guides/style.html) and make use of the [Kotlin Android Extensions](https://kotlinlang.org/docs/tutorials/android-plugin.html). This project is in the process of migrating to Kotlin, all new code must be written in Kotlin.
We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint).
You can check the codestyle by running `./gradlew ktlintCheck`.
### Java ### Java
Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Kotlin.
### Viewbinding
We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted.
There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier.
### Visuals ### Visuals
There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. For icons and drawables, use a white drawable and tint it at runtime using ```ThemeUtils``` and specify an attribute that references different colours depending on the theme. There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```.
### Saving ### Saving
Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands:

View File

@ -15,7 +15,7 @@ def getGitSha = {
} }
android { android {
compileSdkVersion 29 compileSdkVersion 30
defaultConfig { defaultConfig {
applicationId 'net.accelf.yuito' applicationId 'net.accelf.yuito'
minSdkVersion 21 minSdkVersion 21
@ -34,7 +34,6 @@ android {
kapt { kapt {
arguments { arguments {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
} }
} }
} }
@ -67,10 +66,6 @@ android {
lintOptions { lintOptions {
disable 'MissingTranslation' disable 'MissingTranslation'
} }
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
@ -97,19 +92,13 @@ android {
} }
} }
project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { ext.lifecycleVersion = "2.3.1"
kotlinOptions {
jvmTarget = "1.8"
}
}
ext.lifecycleVersion = "2.2.0"
ext.roomVersion = '2.3.0' ext.roomVersion = '2.3.0'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.0' ext.okhttpVersion = '4.9.1'
ext.glideVersion = '4.11.0' ext.glideVersion = '4.12.0'
ext.daggerVersion = '2.30.1' ext.daggerVersion = '2.37'
ext.materialdrawerVersion = '8.2.0' ext.materialdrawerVersion = '8.4.1'
repositories { repositories {
maven { maven {
@ -121,16 +110,19 @@ repositories {
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.2" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation "androidx.appcompat:appcompat:1.2.0" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.browser:browser:1.3.0" implementation "androidx.browser:browser:1.3.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.0" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.2" implementation "androidx.exifinterface:exifinterface:1.3.2"
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.0.0" implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
@ -138,33 +130,33 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.paging:paging-runtime-ktx:2.1.2" implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.4.0" implementation "androidx.work:work-runtime:2.5.0"
implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-rxjava2:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0" implementation "com.google.android.material:material:1.3.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
implementation "org.conscrypt:conscrypt-android:2.5.1" implementation "org.conscrypt:conscrypt-android:2.5.2"
implementation "com.github.bumptech.glide:glide:$glideVersion" implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
implementation "io.reactivex.rxjava2:rxjava:2.2.20" implementation "io.reactivex.rxjava3:rxjava:3.0.12"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1" implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
implementation "io.reactivex.rxjava2:rxkotlin:2.4.0" implementation "io.reactivex.rxjava3:rxkotlin:3.0.1"
implementation "com.uber.autodispose:autodispose-android-archcomponents:1.4.0" implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0"
implementation "com.uber.autodispose:autodispose:1.4.0" implementation "com.uber.autodispose2:autodispose:2.0.0"
implementation "com.google.dagger:dagger:$daggerVersion" implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion"
@ -180,9 +172,9 @@ dependencies {
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar' implementation 'com.mikepenz:google-material-typeface:3.0.1.4.original-kotlin@aar'
implementation "com.theartofdev.edmodo:android-image-cropper:2.8.0" implementation "com.github.CanHub:Android-Image-Cropper:3.1.0"
implementation "de.c1710:filemojicompat:1.0.17" implementation "de.c1710:filemojicompat:1.0.18"
testImplementation "androidx.test.ext:junit:1.1.2" testImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.robolectric:robolectric:4.4"
@ -192,6 +184,7 @@ dependencies {
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.2" androidTestImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "androidx.arch.core:core-testing:2.1.0"
implementation 'net.accelf:easter:1.0.2' implementation 'net.accelf:easter:1.0.2'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.13.1'

View File

@ -65,7 +65,14 @@
# remove some kotlin overhead # remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
static void checkNotNull(java.lang.Object);
static void checkNotNull(java.lang.Object, java.lang.String);
static void checkParameterIsNotNull(java.lang.Object, java.lang.String); static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullParameter(java.lang.Object, java.lang.String);
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String);
} }

View File

@ -0,0 +1,747 @@
{
"formatVersion": 1,
"database": {
"version": 26,
"identityHash": "14fb3d5743b7a89e8e62463e05f086ab",
"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, `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, `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)",
"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": "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": "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
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"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, `version` TEXT, 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": "version",
"columnName": "version",
"affinity": "TEXT",
"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, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "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": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"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
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"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, `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_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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` 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": "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.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.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"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, '14fb3d5743b7a89e8e62463e05f086ab')"
]
}
}

View File

@ -0,0 +1,753 @@
{
"formatVersion": 1,
"database": {
"version": 27,
"identityHash": "be914d4eb3f406b6970fef53a925afa1",
"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, `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, `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)",
"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": "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": "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
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"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, `version` TEXT, 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": "version",
"columnName": "version",
"affinity": "TEXT",
"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, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "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": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"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
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"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, `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_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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` 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": "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.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.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"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, 'be914d4eb3f406b6970fef53a925afa1')"
]
}
}

View File

@ -2,8 +2,8 @@ package com.keylesspalace.tusky
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
@ -18,9 +18,9 @@ class MigrationsTest {
@JvmField @JvmField
@Rule @Rule
var helper: MigrationTestHelper = MigrationTestHelper( var helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(), InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName, AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory() FrameworkSQLiteOpenHelperFactory()
) )
@Test @Test
@ -33,12 +33,15 @@ class MigrationsTest {
val active = true val active = true
val accountId = "accountId" val accountId = "accountId"
val username = "username" val username = "username"
val values = arrayOf(id, domain, token, active, accountId, username, "Display Name", val values = arrayOf(
"https://picture.url", true, true, true, true, true, true, true, id, domain, token, active, accountId, username, "Display Name",
true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false, "https://picture.url", true, true, true, true, true, true, true,
false, true) true, "1000", "[]", "[{\"shortcode\": \"emoji\", \"url\": \"yes\"}]", 0, false,
false, true
)
db.execSQL("INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," + db.execSQL(
"INSERT OR REPLACE INTO `AccountEntity`(`id`,`domain`,`accessToken`,`isActive`," +
"`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," + "`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`," +
"`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," + "`notificationsMentioned`,`notificationsFollowed`,`notificationsReblogged`," +
"`notificationsFavorited`,`notificationSound`,`notificationVibration`," + "`notificationsFavorited`,`notificationSound`,`notificationVibration`," +
@ -46,7 +49,8 @@ class MigrationsTest {
"`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," + "`defaultPostPrivacy`,`defaultMediaSensitivity`,`alwaysShowSensitiveMedia`," +
"`mediaPreviewEnabled`) " + "`mediaPreviewEnabled`) " +
"VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "VALUES (nullif(?, 0),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
values) values
)
db.close() db.close()
@ -61,4 +65,4 @@ class MigrationsTest {
assertEquals(accountId, cursor.getString(4)) assertEquals(accountId, cursor.getString(4))
assertEquals(username, cursor.getString(5)) assertEquals(username, cursor.getString(5))
} }
} }

View File

@ -3,9 +3,13 @@ package com.keylesspalace.tusky
import androidx.room.Room import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.keylesspalace.tusky.db.* import com.keylesspalace.tusky.components.timeline.TimelineRepository
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineDao
import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.repository.TimelineRepository
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@ -41,9 +45,11 @@ class TimelineDAOTest {
timelineDao.insertInTransaction(status, author, reblogger) timelineDao.insertInTransaction(status, author, reblogger)
} }
val resultsFromDb = timelineDao.getStatusesForAccount(setOne.first.timelineUserId, val resultsFromDb = timelineDao.getStatusesForAccount(
maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10) setOne.first.timelineUserId,
.blockingGet() maxId = "21", sinceId = ignoredOne.first.serverId, limit = 10
)
.blockingGet()
assertEquals(2, resultsFromDb.size) assertEquals(2, resultsFromDb.size)
for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) { for ((set, fromDb) in listOf(setTwo, setOne).zip(resultsFromDb)) {
@ -64,14 +70,13 @@ class TimelineDAOTest {
timelineDao.insertStatusIfNotThere(placeholder) timelineDao.insertStatusIfNotThere(placeholder)
val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10) val fromDb = timelineDao.getStatusesForAccount(status.timelineUserId, null, null, 10)
.blockingGet() .blockingGet()
val result = fromDb.first() val result = fromDb.first()
assertEquals(1, fromDb.size) assertEquals(1, fromDb.size)
assertEquals(author, result.account) assertEquals(author, result.account)
assertEquals(status, result.status) assertEquals(status, result.status)
assertNull(result.reblogAccount) assertNull(result.reblogAccount)
} }
@Test @Test
@ -79,22 +84,22 @@ class TimelineDAOTest {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000 val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000
val oldThisAccount = makeStatus( val oldThisAccount = makeStatus(
statusId = 5, statusId = 5,
createdAt = oldDate createdAt = oldDate
) )
val oldAnotherAccount = makeStatus( val oldAnotherAccount = makeStatus(
statusId = 10, statusId = 10,
createdAt = oldDate, createdAt = oldDate,
accountId = 2 accountId = 2
) )
val recentThisAccount = makeStatus( val recentThisAccount = makeStatus(
statusId = 30, statusId = 30,
createdAt = System.currentTimeMillis() createdAt = System.currentTimeMillis()
) )
val recentAnotherAccount = makeStatus( val recentAnotherAccount = makeStatus(
statusId = 60, statusId = 60,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
accountId = 2 accountId = 2
) )
for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) { for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) {
@ -104,15 +109,15 @@ class TimelineDAOTest {
timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL) timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL)
assertEquals( assertEquals(
listOf(recentThisAccount), listOf(recentThisAccount),
timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet()
.map { it.toTriple() } .map { it.toTriple() }
) )
assertEquals( assertEquals(
listOf(recentAnotherAccount), listOf(recentAnotherAccount),
timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet() timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet()
.map { it.toTriple() } .map { it.toTriple() }
) )
} }
@ -120,9 +125,9 @@ class TimelineDAOTest {
fun overwriteDeletedStatus() { fun overwriteDeletedStatus() {
val oldStatuses = listOf( val oldStatuses = listOf(
makeStatus(statusId = 3), makeStatus(statusId = 3),
makeStatus(statusId = 2), makeStatus(statusId = 2),
makeStatus(statusId = 1) makeStatus(statusId = 1)
) )
timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId) timelineDao.deleteRange(1, oldStatuses.last().first.serverId, oldStatuses.first().first.serverId)
@ -133,8 +138,8 @@ class TimelineDAOTest {
// status 2 gets deleted, newly loaded status contain only 1 + 3 // status 2 gets deleted, newly loaded status contain only 1 + 3
val newStatuses = listOf( val newStatuses = listOf(
makeStatus(statusId = 3), makeStatus(statusId = 3),
makeStatus(statusId = 1) makeStatus(statusId = 1)
) )
timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId) timelineDao.deleteRange(1, newStatuses.last().first.serverId, newStatuses.first().first.serverId)
@ -143,107 +148,106 @@ class TimelineDAOTest {
timelineDao.insertInTransaction(status, author, reblogAuthor) timelineDao.insertInTransaction(status, author, reblogAuthor)
} }
//make sure status 2 is no longer in db // make sure status 2 is no longer in db
assertEquals( assertEquals(
newStatuses, newStatuses,
timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet() timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet()
.map { it.toTriple() } .map { it.toTriple() }
) )
} }
private fun makeStatus( private fun makeStatus(
accountId: Long = 1, accountId: Long = 1,
statusId: Long = 10, statusId: Long = 10,
reblog: Boolean = false, reblog: Boolean = false,
createdAt: Long = statusId, createdAt: Long = statusId,
authorServerId: String = "20" authorServerId: String = "20"
): Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> { ): Triple<TimelineStatusEntity, TimelineAccountEntity, TimelineAccountEntity?> {
val author = TimelineAccountEntity( val author = TimelineAccountEntity(
authorServerId, authorServerId,
accountId, accountId,
"localUsername", "localUsername",
"username", "username",
"displayName", "displayName",
"blah", "blah",
"avatar", "avatar",
"[\"tusky\": \"http://tusky.cool/emoji.jpg\"]", "[\"tusky\": \"http://tusky.cool/emoji.jpg\"]",
false false
) )
val reblogAuthor = if (reblog) { val reblogAuthor = if (reblog) {
TimelineAccountEntity( TimelineAccountEntity(
"R$authorServerId", "R$authorServerId",
accountId, accountId,
"RlocalUsername", "RlocalUsername",
"Rusername", "Rusername",
"RdisplayName", "RdisplayName",
"Rblah", "Rblah",
"Ravatar", "Ravatar",
"[]", "[]",
false false
) )
} else null } else null
val even = accountId % 2 == 0L val even = accountId % 2 == 0L
val status = TimelineStatusEntity( val status = TimelineStatusEntity(
serverId = statusId.toString(), serverId = statusId.toString(),
url = "url$statusId", url = "url$statusId",
timelineUserId = accountId, timelineUserId = accountId,
authorServerId = authorServerId, authorServerId = authorServerId,
inReplyToId = "inReplyToId$statusId", inReplyToId = "inReplyToId$statusId",
inReplyToAccountId = "inReplyToAccountId$statusId", inReplyToAccountId = "inReplyToAccountId$statusId",
content = "Content!$statusId", content = "Content!$statusId",
createdAt = createdAt, createdAt = createdAt,
emojis = "emojis$statusId", emojis = "emojis$statusId",
reblogsCount = 1 * statusId.toInt(), reblogsCount = 1 * statusId.toInt(),
favouritesCount = 2 * statusId.toInt(), favouritesCount = 2 * statusId.toInt(),
reblogged = even, reblogged = even,
favourited = !even, favourited = !even,
bookmarked = false, bookmarked = false,
sensitive = even, sensitive = even,
spoilerText = "spoier$statusId", spoilerText = "spoier$statusId",
visibility = Status.Visibility.PRIVATE, visibility = Status.Visibility.PRIVATE,
attachments = "attachments$accountId", attachments = "attachments$accountId",
mentions = "mentions$accountId", mentions = "mentions$accountId",
application = "application$accountId", application = "application$accountId",
reblogServerId = if (reblog) (statusId * 100).toString() else null, reblogServerId = if (reblog) (statusId * 100).toString() else null,
reblogAccountId = reblogAuthor?.serverId, reblogAccountId = reblogAuthor?.serverId,
poll = null, poll = null,
muted = false muted = false
) )
return Triple(status, author, reblogAuthor) return Triple(status, author, reblogAuthor)
} }
private fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity { private fun createPlaceholder(serverId: String, timelineUserId: Long): TimelineStatusEntity {
return TimelineStatusEntity( return TimelineStatusEntity(
serverId = serverId, serverId = serverId,
url = null, url = null,
timelineUserId = timelineUserId, timelineUserId = timelineUserId,
authorServerId = null, authorServerId = null,
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
content = null, content = null,
createdAt = 0L, createdAt = 0L,
emojis = null, emojis = null,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = 0, favouritesCount = 0,
reblogged = false, reblogged = false,
favourited = false, favourited = false,
bookmarked = false, bookmarked = false,
sensitive = false, sensitive = false,
spoilerText = null, spoilerText = null,
visibility = null, visibility = null,
attachments = null, attachments = null,
mentions = null, mentions = null,
application = null, application = null,
reblogServerId = null, reblogServerId = null,
reblogAccountId = null, reblogAccountId = null,
poll = null, poll = null,
muted = false muted = false
) )
} }
private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount) private fun TimelineStatusWithAccount.toTriple() = Triple(status, account, reblogAccount)
} }

View File

@ -35,9 +35,6 @@
android:resource="@xml/share_shortcuts" /> android:resource="@xml/share_shortcuts" />
</activity> </activity>
<activity
android:name=".SavedTootActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity <activity
android:name=".LoginActivity" android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@ -124,7 +121,7 @@
<activity android:name=".AboutActivity" /> <activity android:name=".AboutActivity" />
<activity android:name=".TabPreferenceActivity" /> <activity android:name=".TabPreferenceActivity" />
<activity <activity
android:name="com.theartofdev.edmodo.cropper.CropImageActivity" android:name="com.canhub.cropper.CropImageActivity"
android:theme="@style/Base.Theme.AppCompat" /> android:theme="@style/Base.Theme.AppCompat" />
<activity <activity
android:name=".components.search.SearchActivity" android:name=".components.search.SearchActivity"

View File

@ -11,7 +11,7 @@ import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.keylesspalace.tusky.databinding.ActivityAboutBinding import com.keylesspalace.tusky.databinding.ActivityAboutBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.CustomURLSpan import com.keylesspalace.tusky.util.NoUnderlineURLSpan
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import net.accelf.yuito.AccessTokenLoginActivity import net.accelf.yuito.AccessTokenLoginActivity
@ -37,7 +37,7 @@ class AboutActivity : BottomSheetActivity(), Injectable {
binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME)
if(BuildConfig.CUSTOM_INSTANCE.isBlank()) { if (BuildConfig.CUSTOM_INSTANCE.isBlank()) {
binding.aboutPoweredByTusky.hide() binding.aboutPoweredByTusky.hide()
} }
@ -73,7 +73,7 @@ private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {
val end = builder.getSpanEnd(span) val end = builder.getSpanEnd(span)
val flags = builder.getSpanFlags(span) val flags = builder.getSpanFlags(span)
val customSpan = object : CustomURLSpan(span.url) {} val customSpan = NoUnderlineURLSpan(span.url)
builder.removeSpan(span) builder.removeSpan(span)
builder.setSpan(customSpan, start, end, flags) builder.setSpan(customSpan, start, end, flags)

View File

@ -34,13 +34,16 @@ import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
@ -59,7 +62,16 @@ import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.AccountPagerAdapter import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewmodel.AccountViewModel import com.keylesspalace.tusky.viewmodel.AccountViewModel
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
@ -175,7 +187,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFieldList.layoutManager = LinearLayoutManager(this) binding.accountFieldList.layoutManager = LinearLayoutManager(this)
binding.accountFieldList.adapter = accountFieldAdapter binding.accountFieldList.adapter = accountFieldAdapter
val accountListClickListener = { v: View -> val accountListClickListener = { v: View ->
val type = when (v.id) { val type = when (v.id) {
R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS
@ -236,19 +247,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onTabUnselected(tab: TabLayout.Tab?) {} override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabSelected(tab: TabLayout.Tab?) {} override fun onTabSelected(tab: TabLayout.Tab?) {}
}) })
} }
private fun setupToolbar() { private fun setupToolbar() {
// set toolbar top margin according to system window insets // set toolbar top margin according to system window insets
binding.accountCoordinatorLayout.setOnApplyWindowInsetsListener { _, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets ->
val top = insets.systemWindowInsetTop val top = insets.getInsets(systemBars()).top
val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
val toolbarParams = binding.accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams
toolbarParams.topMargin = top toolbarParams.topMargin = top
WindowInsetsCompat.CONSUMED
insets.consumeSystemWindowInsets()
} }
// Setup the toolbar. // Setup the toolbar.
@ -271,8 +279,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
fillColor = ColorStateList.valueOf(toolbarColor) fillColor = ColorStateList.valueOf(toolbarColor)
elevation = appBarElevation elevation = appBarElevation
shapeAppearanceModel = ShapeAppearanceModel.builder() shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius))
.build() .build()
} }
binding.accountAvatarImageView.background = avatarBackground binding.accountAvatarImageView.background = avatarBackground
@ -319,12 +327,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0 binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0
} }
}) })
} }
private fun makeNotificationBarTransparent() { private fun makeNotificationBarTransparent() {
val decorView = window.decorView WindowCompat.setDecorFitsSystemWindows(window, false)
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
window.statusBarColor = statusBarColorTransparent window.statusBarColor = statusBarColorTransparent
} }
@ -337,8 +343,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
is Success -> onAccountChanged(it.data) is Success -> onAccountChanged(it.data)
is Error -> { is Error -> {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.refresh() } .setAction(R.string.action_retry) { viewModel.refresh() }
.show() .show()
} }
} }
} }
@ -350,15 +356,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (it is Error) { if (it is Error) {
Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { viewModel.refresh() } .setAction(R.string.action_retry) { viewModel.refresh() }
.show() .show()
} }
} }
viewModel.accountFieldData.observe(this, { viewModel.accountFieldData.observe(
accountFieldAdapter.fields = it this,
accountFieldAdapter.notifyDataSetChanged() {
}) accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
@ -372,9 +380,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.refresh() viewModel.refresh()
adapter.refreshContent() adapter.refreshContent()
} }
viewModel.isRefreshing.observe(this, { isRefreshing -> viewModel.isRefreshing.observe(
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true this,
}) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
} }
@ -415,18 +426,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadedAccount?.let { account -> loadedAccount?.let { account ->
loadAvatar( loadAvatar(
account.avatar, account.avatar,
binding.accountAvatarImageView, binding.accountAvatarImageView,
resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp), resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp),
animateAvatar animateAvatar
) )
Glide.with(this) Glide.with(this)
.asBitmap() .asBitmap()
.load(account.header) .load(account.header)
.centerCrop() .centerCrop()
.into(binding.accountHeaderImageView) .into(binding.accountHeaderImageView)
binding.accountAvatarImageView.setOnClickListener { avatarView -> binding.accountAvatarImageView.setOnClickListener { avatarView ->
val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
@ -484,7 +494,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
} }
} }
/** /**
@ -560,8 +569,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
// because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field
// it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call
if (!viewModel.isSelf && followState == FollowState.FOLLOWING if (!viewModel.isSelf && followState == FollowState.FOLLOWING &&
&& (relation.subscribing != null || relation.notifying != null)) { (relation.subscribing != null || relation.notifying != null)
) {
binding.accountSubscribeButton.show() binding.accountSubscribeButton.show()
binding.accountSubscribeButton.setOnClickListener { binding.accountSubscribeButton.setOnClickListener {
viewModel.changeSubscribingState() viewModel.changeSubscribingState()
@ -695,11 +705,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} else { } else {
getString(R.string.action_show_reblogs) getString(R.string.action_show_reblogs)
} }
} else { } else {
menu.removeItem(R.id.action_show_reblogs) menu.removeItem(R.id.action_show_reblogs)
} }
} else { } else {
// It shouldn't be possible to block, mute or report yourself. // It shouldn't be possible to block, mute or report yourself.
menu.removeItem(R.id.action_block) menu.removeItem(R.id.action_block)
@ -714,18 +722,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun showFollowRequestPendingDialog() { private fun showFollowRequestPendingDialog() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(R.string.dialog_message_cancel_follow_request) .setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
private fun showUnfollowWarningDialog() { private fun showUnfollowWarningDialog() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(R.string.dialog_unfollow_warning) .setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
private fun toggleBlockDomain(instance: String) { private fun toggleBlockDomain(instance: String) {
@ -733,20 +741,20 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.unblockDomain(instance) viewModel.unblockDomain(instance)
} else { } else {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(getString(R.string.mute_domain_warning, instance)) .setMessage(getString(R.string.mute_domain_warning, instance))
.setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) } .setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
} }
private fun toggleBlock() { private fun toggleBlock() {
if (viewModel.relationshipData.value?.data?.blocking != true) { if (viewModel.relationshipData.value?.data?.blocking != true) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username))
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} else { } else {
viewModel.changeBlockState() viewModel.changeBlockState()
} }
@ -756,8 +764,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (viewModel.relationshipData.value?.data?.muting != true) { if (viewModel.relationshipData.value?.data?.muting != true) {
loadedAccount?.let { loadedAccount?.let {
showMuteAccountDialog( showMuteAccountDialog(
this, this,
it.username it.username
) { notifications, duration -> ) { notifications, duration ->
viewModel.muteAccount(notifications, duration) viewModel.muteAccount(notifications, duration)
} }
@ -769,8 +777,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun mention() { private fun mention() {
loadedAccount?.let { loadedAccount?.let {
val intent = ComposeActivity.startIntent(this, val intent = ComposeActivity.startIntent(
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))) this,
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username))
)
startActivity(intent) startActivity(intent)
} }
} }
@ -846,5 +856,4 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
return intent return intent
} }
} }
} }

View File

@ -64,9 +64,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
} }
supportFragmentManager supportFragmentManager
.beginTransaction() .beginTransaction()
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked))
.commit() .commit()
} }
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector

View File

@ -28,18 +28,24 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State import com.keylesspalace.tusky.viewmodel.State
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@ -93,19 +99,19 @@ class AccountsInListFragment : DialogFragment(), Injectable {
binding.accountsSearchRecycler.adapter = searchAdapter binding.accountsSearchRecycler.adapter = searchAdapter
viewModel.state viewModel.state
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this)) .autoDispose(from(this))
.subscribe { state -> .subscribe { state ->
adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) adapter.submitList(state.accounts.asRightOrNull() ?: listOf())
when (state.accounts) { when (state.accounts) {
is Either.Right -> binding.messageView.hide() is Either.Right -> binding.messageView.hide()
is Either.Left -> handleError(state.accounts.value) is Either.Left -> handleError(state.accounts.value)
}
setupSearchView(state)
} }
setupSearchView(state)
}
binding.searchView.isSubmitButtonEnabled = true binding.searchView.isSubmitButtonEnabled = true
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
@ -146,11 +152,15 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.load(listId) viewModel.load(listId)
} }
if (error is IOException) { if (error is IOException) {
binding.messageView.setup(R.drawable.elephant_offline, binding.messageView.setup(
R.string.error_network, retryAction) R.drawable.elephant_offline,
R.string.error_network, retryAction
)
} else { } else {
binding.messageView.setup(R.drawable.elephant_error, binding.messageView.setup(
R.string.error_generic, retryAction) R.drawable.elephant_error,
R.string.error_generic, retryAction
)
} }
} }
@ -184,7 +194,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
onRemoveFromList(getItem(holder.bindingAdapterPosition).id) onRemoveFromList(getItem(holder.bindingAdapterPosition).id)
} }
binding.rejectButton.contentDescription = binding.rejectButton.contentDescription =
binding.root.context.getString(R.string.action_remove_from_list) binding.root.context.getString(R.string.action_remove_from_list)
return holder return holder
} }
@ -203,8 +213,8 @@ class AccountsInListFragment : DialogFragment(), Injectable {
} }
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second return oldItem.second == newItem.second &&
&& oldItem.first.deepEquals(newItem.first) oldItem.first.deepEquals(newItem.first)
} }
} }
@ -260,4 +270,4 @@ class AccountsInListFragment : DialogFragment(), Injectable {
return AccountsInListFragment().apply { arguments = args } return AccountsInListFragment().apply { arguments = args }
} }
} }
} }

View File

@ -199,6 +199,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
@Override @Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requesters.containsKey(requestCode)) { if (requesters.containsKey(requestCode)) {
PermissionRequester requester = requesters.remove(requestCode); PermissionRequester requester = requesters.remove(requestCode);
requester.onRequestPermissionsResult(permissions, grantResults); requester.onRequestPermissionsResult(permissions, grantResults);

View File

@ -22,12 +22,12 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import javax.inject.Inject import javax.inject.Inject
@ -62,7 +62,6 @@ abstract class BottomSheetActivity : BaseActivity() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {} override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}) })
} }
open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER, text: String = "") { open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER, text: String = "") {
@ -77,11 +76,12 @@ abstract class BottomSheetActivity : BaseActivity() {
} }
mastodonApi.searchObservable( mastodonApi.searchObservable(
query = url, query = url,
resolve = true resolve = true
).observeOn(AndroidSchedulers.mainThread()) ).observeOn(AndroidSchedulers.mainThread())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ (accounts, statuses) -> .subscribe(
{ (accounts, statuses) ->
if (getCancelSearchRequested(url)) { if (getCancelSearchRequested(url)) {
return@subscribe return@subscribe
} }
@ -97,12 +97,14 @@ abstract class BottomSheetActivity : BaseActivity() {
} }
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
}, { },
{
if (!getCancelSearchRequested(url)) { if (!getCancelSearchRequested(url)) {
onEndSearch(url) onEndSearch(url)
performUrlFallbackAction(url, lookupFallbackBehavior) performUrlFallbackAction(url, lookupFallbackBehavior)
} }
}) }
)
onBeginSearch(url) onBeginSearch(url)
} }
@ -194,21 +196,22 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
} }
if (uri.query != null || if (uri.query != null ||
uri.fragment != null || uri.fragment != null ||
uri.path == null) { uri.path == null
) {
return false return false
} }
val path = uri.path val path = uri.path
return path.matches("^/@[^/]+$".toRegex()) || return path.matches("^/@[^/]+$".toRegex()) ||
path.matches("^/@[^/]+/\\d+$".toRegex()) || path.matches("^/@[^/]+/\\d+$".toRegex()) ||
path.matches("^/users/\\w+$".toRegex()) || path.matches("^/users/\\w+$".toRegex()) ||
path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || path.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) ||
path.matches("^/objects/[-a-f0-9]+$".toRegex()) || path.matches("^/objects/[-a-f0-9]+$".toRegex()) ||
path.matches("^/notes/[a-z0-9]+$".toRegex()) || path.matches("^/notes/[a-z0-9]+$".toRegex()) ||
path.matches("^/display/[-a-f0-9]+$".toRegex()) || path.matches("^/display/[-a-f0-9]+$".toRegex()) ||
path.matches("^/profile/\\w+$".toRegex()) || path.matches("^/profile/\\w+$".toRegex()) ||
path.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) path.matches("^/users/[^/]+/statuses/\\d+$".toRegex())
} }
enum class PostLookupFallbackBehavior { enum class PostLookupFallbackBehavior {

View File

@ -36,18 +36,24 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import com.theartofdev.edmodo.cropper.CropImage
import javax.inject.Inject import javax.inject.Inject
class EditProfileActivity : BaseActivity(), Injectable { class EditProfileActivity : BaseActivity(), Injectable {
@ -110,11 +116,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.addFieldButton.setOnClickListener { binding.addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField() accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) { if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isVisible = false it.isVisible = false
} }
binding.scrollView.post{ binding.scrollView.post {
binding.scrollView.smoothScrollTo(0, it.bottom) binding.scrollView.smoothScrollTo(0, it.bottom)
} }
} }
@ -134,23 +140,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS
if(viewModel.avatarData.value == null) { if (viewModel.avatarData.value == null) {
Glide.with(this) Glide.with(this)
.load(me.avatar) .load(me.avatar)
.placeholder(R.drawable.avatar_default) .placeholder(R.drawable.avatar_default)
.transform( .transform(
FitCenter(), FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
) )
.into(binding.avatarPreview) .into(binding.avatarPreview)
} }
if(viewModel.headerData.value == null) { if (viewModel.headerData.value == null) {
Glide.with(this) Glide.with(this)
.load(me.header) .load(me.header)
.into(binding.headerPreview) .into(binding.headerPreview)
} }
} }
} }
is Error -> { is Error -> {
@ -159,7 +164,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile() viewModel.obtainProfile()
} }
snackbar.show() snackbar.show()
} }
} }
} }
@ -179,20 +183,22 @@ class EditProfileActivity : BaseActivity(), Injectable {
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true) observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false) observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false)
viewModel.saveData.observe(this, { viewModel.saveData.observe(
when(it) { this,
is Success -> { {
finish() when (it) {
} is Success -> {
is Loading -> { finish()
binding.saveProgressBar.visibility = View.VISIBLE }
} is Loading -> {
is Error -> { binding.saveProgressBar.visibility = View.VISIBLE
onSaveFailure(it.errorMessage) }
is Error -> {
onSaveFailure(it.errorMessage)
}
} }
} }
}) )
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -202,50 +208,56 @@ class EditProfileActivity : BaseActivity(), Injectable {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if(!isFinishing) { if (!isFinishing) {
viewModel.updateProfile(binding.displayNameEditText.text.toString(), viewModel.updateProfile(
binding.noteEditText.text.toString(), binding.displayNameEditText.text.toString(),
binding.lockedCheckBox.isChecked, binding.noteEditText.text.toString(),
accountFieldEditAdapter.getFieldData()) binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData()
)
} }
} }
private fun observeImage(liveData: LiveData<Resource<Bitmap>>, private fun observeImage(
imageView: ImageView, liveData: LiveData<Resource<Bitmap>>,
progressBar: View, imageView: ImageView,
roundedCorners: Boolean) { progressBar: View,
liveData.observe(this, { roundedCorners: Boolean
) {
liveData.observe(
this,
{
when (it) { when (it) {
is Success -> { is Success -> {
val glide = Glide.with(imageView) val glide = Glide.with(imageView)
.load(it.data) .load(it.data)
if (roundedCorners) { if (roundedCorners) {
glide.transform( glide.transform(
FitCenter(), FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
) )
} }
glide.into(imageView) glide.into(imageView)
imageView.show() imageView.show()
progressBar.hide() progressBar.hide()
} }
is Loading -> { is Loading -> {
progressBar.show() progressBar.show()
} }
is Error -> { is Error -> {
progressBar.hide() progressBar.hide()
if(!it.consumed) { if (!it.consumed) {
onResizeFailure() onResizeFailure()
it.consumed = true it.consumed = true
}
} }
} }
} }
}) )
} }
private fun onMediaPick(pickType: PickType) { private fun onMediaPick(pickType: PickType) {
@ -261,8 +273,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, override fun onRequestPermissionsResult(
grantResults: IntArray) { requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
when (requestCode) { when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> { PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
@ -307,14 +322,16 @@ class EditProfileActivity : BaseActivity(), Injectable {
private fun save() { private fun save() {
if (currentlyPicking != PickType.NOTHING) { if (currentlyPicking != PickType.NOTHING) {
return return
} }
viewModel.save(binding.displayNameEditText.text.toString(), viewModel.save(
binding.noteEditText.text.toString(), binding.displayNameEditText.text.toString(),
binding.lockedCheckBox.isChecked, binding.noteEditText.text.toString(),
accountFieldEditAdapter.getFieldData(), binding.lockedCheckBox.isChecked,
this) accountFieldEditAdapter.getFieldData(),
this
)
} }
private fun onSaveFailure(msg: String?) { private fun onSaveFailure(msg: String?) {
@ -352,10 +369,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
AVATAR_PICK_RESULT -> { AVATAR_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) { if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data) CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f) .setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG) .setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) .setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this) .start(this)
} else { } else {
endMediaPicking() endMediaPicking()
} }
@ -363,10 +380,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
HEADER_PICK_RESULT -> { HEADER_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) { if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data) CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f) .setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG) .setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) .setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this) .start(this)
} else { } else {
endMediaPicking() endMediaPicking()
} }
@ -374,7 +391,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> { CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data) val result = CropImage.getActivityResult(data)
when (resultCode) { when (resultCode) {
Activity.RESULT_OK -> beginResize(result.uri) Activity.RESULT_OK -> beginResize(result?.uriContent)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure() CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking() else -> endMediaPicking()
} }
@ -382,7 +399,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
private fun beginResize(uri: Uri) { private fun beginResize(uri: Uri?) {
if (uri == null) {
currentlyPicking = PickType.NOTHING
return
}
beginMediaPicking() beginMediaPicking()
when (currentlyPicking) { when (currentlyPicking) {
@ -398,12 +420,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
currentlyPicking = PickType.NOTHING currentlyPicking = PickType.NOTHING
} }
private fun onResizeFailure() { private fun onResizeFailure() {
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking() endMediaPicking()
} }
} }

View File

@ -5,6 +5,7 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.databinding.ActivityFiltersBinding import com.keylesspalace.tusky.databinding.ActivityFiltersBinding
@ -14,6 +15,8 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -21,7 +24,7 @@ import retrofit2.Response
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class FiltersActivity: BaseActivity() { class FiltersActivity : BaseActivity() {
@Inject @Inject
lateinit var api: MastodonApi lateinit var api: MastodonApi
@ -30,7 +33,7 @@ class FiltersActivity: BaseActivity() {
private val binding by viewBinding(ActivityFiltersBinding::inflate) private val binding by viewBinding(ActivityFiltersBinding::inflate)
private lateinit var context : String private lateinit var context: String
private lateinit var filters: MutableList<Filter> private lateinit var filters: MutableList<Filter>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -54,7 +57,7 @@ class FiltersActivity: BaseActivity() {
private fun updateFilter(filter: Filter, itemIndex: Int) { private fun updateFilter(filter: Filter, itemIndex: Int) {
api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt) api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt)
.enqueue(object: Callback<Filter>{ .enqueue(object : Callback<Filter> {
override fun onFailure(call: Call<Filter>, t: Throwable) { override fun onFailure(call: Call<Filter>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show() Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show()
} }
@ -76,7 +79,7 @@ class FiltersActivity: BaseActivity() {
val filter = filters[itemIndex] val filter = filters[itemIndex]
if (filter.context.size == 1) { if (filter.context.size == 1) {
// This is the only context for this filter; delete it // This is the only context for this filter; delete it
api.deleteFilter(filters[itemIndex].id).enqueue(object: Callback<ResponseBody> { api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) { override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show()
} }
@ -90,17 +93,19 @@ class FiltersActivity: BaseActivity() {
} else { } else {
// Keep the filter, but remove it from this context // Keep the filter, but remove it from this context
val oldFilter = filters[itemIndex] val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, val newFilter = Filter(
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord) oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context },
oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord
)
updateFilter(newFilter, itemIndex) updateFilter(newFilter, itemIndex)
} }
} }
private fun createFilter(phrase: String, wholeWord: Boolean) { private fun createFilter(phrase: String, wholeWord: Boolean) {
api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object: Callback<Filter> { api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object : Callback<Filter> {
override fun onResponse(call: Call<Filter>, response: Response<Filter>) { override fun onResponse(call: Call<Filter>, response: Response<Filter>) {
val filterResponse = response.body() val filterResponse = response.body()
if(response.isSuccessful && filterResponse != null) { if (response.isSuccessful && filterResponse != null) {
filters.add(filterResponse) filters.add(filterResponse)
refreshFilterDisplay() refreshFilterDisplay()
eventHub.dispatch(PreferenceChangedEvent(context)) eventHub.dispatch(PreferenceChangedEvent(context))
@ -119,13 +124,13 @@ class FiltersActivity: BaseActivity() {
val binding = DialogFilterBinding.inflate(layoutInflater) val binding = DialogFilterBinding.inflate(layoutInflater)
binding.phraseWholeWord.isChecked = true binding.phraseWholeWord.isChecked = true
AlertDialog.Builder(this@FiltersActivity) AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_addition_dialog_title) .setTitle(R.string.filter_addition_dialog_title)
.setView(binding.root) .setView(binding.root)
.setPositiveButton(android.R.string.ok){ _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked) createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked)
} }
.setNeutralButton(android.R.string.cancel, null) .setNeutralButton(android.R.string.cancel, null)
.show() .show()
} }
private fun setupEditDialogForItem(itemIndex: Int) { private fun setupEditDialogForItem(itemIndex: Int) {
@ -135,19 +140,21 @@ class FiltersActivity: BaseActivity() {
binding.phraseWholeWord.isChecked = filter.wholeWord binding.phraseWholeWord.isChecked = filter.wholeWord
AlertDialog.Builder(this@FiltersActivity) AlertDialog.Builder(this@FiltersActivity)
.setTitle(R.string.filter_edit_dialog_title) .setTitle(R.string.filter_edit_dialog_title)
.setView(binding.root) .setView(binding.root)
.setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> .setPositiveButton(R.string.filter_dialog_update_button) { _, _ ->
val oldFilter = filters[itemIndex] val oldFilter = filters[itemIndex]
val newFilter = Filter(oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context, val newFilter = Filter(
oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked) oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context,
updateFilter(newFilter, itemIndex) oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked
} )
.setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> updateFilter(newFilter, itemIndex)
deleteFilter(itemIndex) }
} .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ ->
.setNeutralButton(android.R.string.cancel, null) deleteFilter(itemIndex)
.show() }
.setNeutralButton(android.R.string.cancel, null)
.show()
} }
private fun refreshFilterDisplay() { private fun refreshFilterDisplay() {
@ -162,41 +169,37 @@ class FiltersActivity: BaseActivity() {
binding.addFilterButton.hide() binding.addFilterButton.hide()
binding.filterProgressBar.show() binding.filterProgressBar.show()
api.getFilters().enqueue(object : Callback<List<Filter>> { lifecycleScope.launch {
override fun onResponse(call: Call<List<Filter>>, response: Response<List<Filter>>) { val newFilters = try {
val filterResponse = response.body() api.getFilters().await()
if(response.isSuccessful && filterResponse != null) { } catch (t: Exception) {
filters = filterResponse.filter { filter -> filter.context.contains(context) }.toMutableList()
refreshFilterDisplay()
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
} else {
binding.filterProgressBar.hide()
binding.filterMessageView.show()
binding.filterMessageView.setup(R.drawable.elephant_error,
R.string.error_generic) { loadFilters() }
}
}
override fun onFailure(call: Call<List<Filter>>, t: Throwable) {
binding.filterProgressBar.hide() binding.filterProgressBar.hide()
binding.filterMessageView.show() binding.filterMessageView.show()
if (t is IOException) { if (t is IOException) {
binding.filterMessageView.setup(R.drawable.elephant_offline, binding.filterMessageView.setup(
R.string.error_network) { loadFilters() } R.drawable.elephant_offline,
R.string.error_network
) { loadFilters() }
} else { } else {
binding.filterMessageView.setup(R.drawable.elephant_error, binding.filterMessageView.setup(
R.string.error_generic) { loadFilters() } R.drawable.elephant_error,
R.string.error_generic
) { loadFilters() }
} }
return@launch
} }
})
filters = newFilters.filter { it.context.contains(context) }.toMutableList()
refreshFilterDisplay()
binding.filtersView.show()
binding.addFilterButton.show()
binding.filterProgressBar.hide()
}
} }
companion object { companion object {
const val FILTERS_CONTEXT = "filters_context" const val FILTERS_CONTEXT = "filters_context"
const val FILTERS_TITLE = "filters_title" const val FILTERS_TITLE = "filters_title"
} }
} }

View File

@ -16,9 +16,9 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.os.Bundle import android.os.Bundle
import androidx.annotation.RawRes
import android.util.Log import android.util.Log
import android.widget.TextView import android.widget.TextView
import androidx.annotation.RawRes
import com.keylesspalace.tusky.databinding.ActivityLicenseBinding import com.keylesspalace.tusky.databinding.ActivityLicenseBinding
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import java.io.BufferedReader import java.io.BufferedReader
@ -42,7 +42,6 @@ class LicenseActivity : BaseActivity() {
loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView)
loadFileIntoTextView(R.raw.mit, binding.licenseMitTextView) loadFileIntoTextView(R.raw.mit, binding.licenseMitTextView)
} }
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {

View File

@ -23,32 +23,48 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.PopupMenu
import android.widget.TextView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.* import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityListsBinding import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.fragment.TimelineFragment import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.hide
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.viewmodel.ListsViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.* import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.* import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_NETWORK
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -84,12 +100,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
binding.listsRecycler.adapter = adapter binding.listsRecycler.adapter = adapter
binding.listsRecycler.layoutManager = LinearLayoutManager(this) binding.listsRecycler.layoutManager = LinearLayoutManager(this)
binding.listsRecycler.addItemDecoration( binding.listsRecycler.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
)
viewModel.state viewModel.state
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this)) .autoDispose(from(this))
.subscribe(this::update) .subscribe(this::update)
viewModel.retryLoading() viewModel.retryLoading()
binding.addListButton.setOnClickListener { binding.addListButton.setOnClickListener {
@ -97,15 +114,15 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
} }
viewModel.events.observeOn(AndroidSchedulers.mainThread()) viewModel.events.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this)) .autoDispose(from(this))
.subscribe { event -> .subscribe { event ->
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
when (event) { when (event) {
CREATE_ERROR -> showMessage(R.string.error_create_list) Event.CREATE_ERROR -> showMessage(R.string.error_create_list)
RENAME_ERROR -> showMessage(R.string.error_rename_list) Event.RENAME_ERROR -> showMessage(R.string.error_rename_list)
DELETE_ERROR -> showMessage(R.string.error_delete_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list)
}
} }
}
} }
private fun showlistNameDialog(list: MastoList?) { private fun showlistNameDialog(list: MastoList?) {
@ -115,17 +132,18 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
layout.addView(editText) layout.addView(editText)
val margin = Utils.dpToPx(this, 8) val margin = Utils.dpToPx(this, 8)
(editText.layoutParams as ViewGroup.MarginLayoutParams) (editText.layoutParams as ViewGroup.MarginLayoutParams)
.setMargins(margin, margin, margin, 0) .setMargins(margin, margin, margin, 0)
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setView(layout) .setView(layout)
.setPositiveButton( .setPositiveButton(
if (list == null) R.string.action_create_list if (list == null) R.string.action_create_list
else R.string.action_rename_list) { _, _ -> else R.string.action_rename_list
onPickedDialogName(editText.text, list?.id) ) { _, _ ->
} onPickedDialogName(editText.text, list?.id)
.setNegativeButton(android.R.string.cancel, null) }
.show() .setNegativeButton(android.R.string.cancel, null)
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.onTextChanged { s, _, _, _ -> editText.onTextChanged { s, _, _, _ ->
@ -137,15 +155,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun showListDeleteDialog(list: MastoList) { private fun showListDeleteDialog(list: MastoList) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_list_warning, list.title)) .setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete){ _, _ -> .setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteList(list.id) viewModel.deleteList(list.id)
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
private fun update(state: ListsViewModel.State) { private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists) adapter.submitList(state.lists)
binding.progressBar.visible(state.loadingState == LOADING) binding.progressBar.visible(state.loadingState == LOADING)
@ -166,8 +183,10 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
LOADED -> LOADED ->
if (state.lists.isEmpty()) { if (state.lists.isEmpty()) {
binding.messageView.show() binding.messageView.show()
binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, binding.messageView.setup(
null) R.drawable.elephant_friend_empty, R.string.message_empty,
null
)
} else { } else {
binding.messageView.hide() binding.messageView.hide()
} }
@ -176,13 +195,14 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun showMessage(@StringRes messageId: Int) { private fun showMessage(@StringRes messageId: Int) {
Snackbar.make( Snackbar.make(
binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT
).show() ).show()
} }
private fun onListSelected(listId: String) { private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation( startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineFragment.Kind.LIST, listId)) ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)
)
} }
private fun openListSettings(list: MastoList) { private fun openListSettings(list: MastoList) {
@ -219,27 +239,28 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
} }
} }
private inner class ListsAdapter private inner class ListsAdapter :
: ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) { ListAdapter<MastoList, ListsAdapter.ListViewHolder>(ListsDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false)
.let(this::ListViewHolder) .let(this::ListViewHolder)
.apply { .apply {
val context = nameTextView.context val context = nameTextView.context
val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary) val iconColor = ThemeUtils.getColor(context, android.R.attr.textColorTertiary)
val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor }
nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
} }
} }
override fun onBindViewHolder(holder: ListViewHolder, position: Int) { override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
holder.nameTextView.text = getItem(position).title holder.nameTextView.text = getItem(position).title
} }
private inner class ListViewHolder(view: View) : RecyclerView.ViewHolder(view), private inner class ListViewHolder(view: View) :
View.OnClickListener { RecyclerView.ViewHolder(view),
View.OnClickListener {
val nameTextView: TextView = view.findViewById(R.id.list_name_textview) val nameTextView: TextView = view.findViewById(R.id.list_name_textview)
val moreButton: ImageButton = view.findViewById(R.id.editListButton) val moreButton: ImageButton = view.findViewById(R.id.editListButton)
@ -271,4 +292,4 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
companion object { companion object {
fun newIntent(context: Context) = Intent(context, ListsActivity::class.java) fun newIntent(context: Context) = Intent(context, ListsActivity::class.java)
} }
} }

View File

@ -34,7 +34,9 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding
import okhttp3.HttpUrl import okhttp3.HttpUrl
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
@ -62,28 +64,29 @@ class LoginActivity : BaseActivity(), Injectable {
setContentView(binding.root) setContentView(binding.root)
if(savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) { if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
} }
if(BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo) Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL) .load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null) .placeholder(null)
.into(binding.loginLogo) .into(binding.loginLogo)
} }
preferences = getSharedPreferences( preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE) getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() } binding.loginButton.setOnClickListener { onButtonClick() }
binding.whatsAnInstanceTextView.setOnClickListener { binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance) .setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null) .setPositiveButton(R.string.action_close, null)
.show() .show()
val textView = dialog.findViewById<TextView>(android.R.id.message) val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance() textView?.movementMethod = LinkMovementMethod.getInstance()
} }
@ -95,7 +98,6 @@ class LoginActivity : BaseActivity(), Injectable {
} else { } else {
binding.toolbar.visibility = View.GONE binding.toolbar.visibility = View.GONE
} }
} }
override fun requiresLogin(): Boolean { override fun requiresLogin(): Boolean {
@ -104,7 +106,7 @@ class LoginActivity : BaseActivity(), Injectable {
override fun finish() { override fun finish() {
super.finish() super.finish()
if(isAdditionalLogin()) { if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
} }
} }
@ -129,8 +131,10 @@ class LoginActivity : BaseActivity(), Injectable {
} }
val callback = object : Callback<AppCredentials> { val callback = object : Callback<AppCredentials> {
override fun onResponse(call: Call<AppCredentials>, override fun onResponse(
response: Response<AppCredentials>) { call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) { if (!response.isSuccessful) {
binding.loginButton.isEnabled = true binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
@ -143,10 +147,10 @@ class LoginActivity : BaseActivity(), Injectable {
val clientSecret = credentials.clientSecret val clientSecret = credentials.clientSecret
preferences.edit() preferences.edit()
.putString("domain", domain) .putString("domain", domain)
.putString("clientId", clientId) .putString("clientId", clientId)
.putString("clientSecret", clientSecret) .putString("clientSecret", clientSecret)
.apply() .apply()
redirectUserToAuthorizeAndLogin(domain, clientId) redirectUserToAuthorizeAndLogin(domain, clientId)
} }
@ -160,11 +164,12 @@ class LoginActivity : BaseActivity(), Injectable {
} }
mastodonApi mastodonApi
.authenticateApp(domain, getString(R.string.app_name), oauthRedirectUri, .authenticateApp(
OAUTH_SCOPES, getString(R.string.tusky_website)) domain, getString(R.string.app_name), oauthRedirectUri,
.enqueue(callback) OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback)
setLoading(true) setLoading(true)
} }
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) { private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
@ -172,10 +177,10 @@ class LoginActivity : BaseActivity(), Injectable {
* login there, and the server will redirect back to the app with its response. */ * login there, and the server will redirect back to the app with its response. */
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
val parameters = mapOf( val parameters = mapOf(
"client_id" to clientId, "client_id" to clientId,
"redirect_uri" to oauthRedirectUri, "redirect_uri" to oauthRedirectUri,
"response_type" to "code", "response_type" to "code",
"scope" to OAUTH_SCOPES "scope" to OAUTH_SCOPES
) )
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters) val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
val uri = Uri.parse(url) val uri = Uri.parse(url)
@ -219,31 +224,27 @@ class LoginActivity : BaseActivity(), Injectable {
} else { } else {
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message()))
getString(R.string.error_retrieving_oauth_token),
response.message()))
} }
} }
override fun onFailure(call: Call<AccessToken>, t: Throwable) { override fun onFailure(call: Call<AccessToken>, t: Throwable) {
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message))
getString(R.string.error_retrieving_oauth_token),
t.message))
} }
} }
mastodonApi.fetchOAuthToken(domain, clientId, clientSecret, redirectUri, code, mastodonApi.fetchOAuthToken(
"authorization_code").enqueue(callback) domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) { } else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they /* Authorization failed. Put the error response where the user can read it and they
* can try again. */ * can try again. */
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied) binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, String.format("%s %s", Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
getString(R.string.error_authorization_denied),
error))
} else { } else {
// This case means a junk response was received somehow. // This case means a junk response was received somehow.
setLoading(false) setLoading(false)
@ -335,14 +336,14 @@ class LoginActivity : BaseActivity(), Injectable {
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor) val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder() val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor) .setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor) .setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor) .setNavigationBarDividerColor(navigationbarDividerColor)
.build() .build()
val customTabsIntent = CustomTabsIntent.Builder() val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams) .setDefaultColorSchemeParams(colorSchemeParams)
.build() .build()
try { try {
customTabsIntent.launchUrl(context, uri) customTabsIntent.launchUrl(context, uri)

View File

@ -40,20 +40,20 @@ import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@ -68,20 +68,25 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.removeShortcut
import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
@ -90,18 +95,27 @@ import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ColorHolder import com.mikepenz.materialdrawer.holder.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.* import com.mikepenz.materialdrawer.model.AbstractDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.* import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.model.PrimaryDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.SecondaryDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.IProfile
import com.mikepenz.materialdrawer.model.interfaces.descriptionRes
import com.mikepenz.materialdrawer.model.interfaces.descriptionText
import com.mikepenz.materialdrawer.model.interfaces.iconRes
import com.mikepenz.materialdrawer.model.interfaces.iconUrl
import com.mikepenz.materialdrawer.model.interfaces.nameRes
import com.mikepenz.materialdrawer.model.interfaces.nameText
import com.mikepenz.materialdrawer.util.* import com.mikepenz.materialdrawer.util.*
import com.mikepenz.materialdrawer.widget.AccountHeaderView import com.mikepenz.materialdrawer.widget.AccountHeaderView
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import com.uber.autodispose.android.lifecycle.autoDispose
import com.uber.autodispose.autoDispose
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.Single import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single
import io.reactivex.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import net.accelf.yuito.FooterDrawerItem import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.QuickTootViewModel
import javax.inject.Inject import javax.inject.Inject
@ -119,9 +133,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject @Inject
lateinit var conversationRepository: ConversationsRepository lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
@Inject @Inject
lateinit var draftHelper: DraftHelper lateinit var draftHelper: DraftHelper
@ -158,10 +169,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
if (activeAccount == null) { ?: return // will be redirected to LoginActivity by BaseActivity
// will be redirected to LoginActivity by BaseActivity
return
}
var showNotificationTab = false var showNotificationTab = false
if (intent != null) { if (intent != null) {
/** there are two possibilities the accountId can be passed to MainActivity: /** there are two possibilities the accountId can be passed to MainActivity:
@ -186,19 +195,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
forwardShare(intent) forwardShare(intent)
} else { } else {
// No account was provided, show the chooser // No account was provided, show the chooser
showAccountChooserDialog(getString(R.string.action_share_as), true, object : AccountSelectionListener { showAccountChooserDialog(
override fun onAccountSelected(account: AccountEntity) { getString(R.string.action_share_as), true,
val requestedId = account.id object : AccountSelectionListener {
if (requestedId == activeAccount.id) { override fun onAccountSelected(account: AccountEntity) {
// The correct account is already active val requestedId = account.id
forwardShare(intent) if (requestedId == activeAccount.id) {
} else { // The correct account is already active
// A different account was requested, restart the activity forwardShare(intent)
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) } else {
changeAccount(requestedId, intent) // A different account was requested, restart the activity
intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId)
changeAccount(requestedId, intent)
}
} }
} }
}) )
} }
} else if (accountRequested && savedInstanceState == null) { } else if (accountRequested && savedInstanceState == null) {
// user clicked a notification, show notification tab // user clicked a notification, show notification tab
@ -258,25 +270,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.disablePullNotifications(this) NotificationHelper.disablePullNotifications(this)
} }
eventHub.events eventHub.events
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? -> .subscribe { event: Event? ->
when (event) { when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false) is MainTabsChangedEvent -> setupTabs(false)
is AnnouncementReadEvent -> { is AnnouncementReadEvent -> {
unreadAnnouncementsCount-- unreadAnnouncementsCount--
updateAnnouncementsBadge() updateAnnouncementsBadge()
}
} }
binding.viewQuickToot.handleEvent(event)
} }
binding.viewQuickToot.handleEvent(event)
}
Schedulers.io().scheduleDirect { Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
} }
draftWarning()
} }
override fun onResume() { override fun onResume() {
@ -382,12 +393,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
currentHiddenInList = true currentHiddenInList = true
onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) } onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) }
addProfile(ProfileSettingDrawerItem().apply { addProfile(
identifier = DRAWER_ITEM_ADD_ACCOUNT ProfileSettingDrawerItem().apply {
nameRes = R.string.add_account_name identifier = DRAWER_ITEM_ADD_ACCOUNT
descriptionRes = R.string.add_account_description nameRes = R.string.add_account_name
iconicsIcon = GoogleMaterial.Icon.gmd_add descriptionRes = R.string.add_account_description
}, 0) iconicsIcon = GoogleMaterial.Icon.gmd_add
},
0
)
attachToSliderView(binding.mainDrawer) attachToSliderView(binding.mainDrawer)
dividerBelowHeader = false dividerBelowHeader = false
closeDrawerOnProfileListClick = true closeDrawerOnProfileListClick = true
@ -401,13 +415,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) { if (animateAvatars) {
glide.load(uri) glide.load(uri)
.placeholder(placeholder) .placeholder(placeholder)
.into(imageView) .into(imageView)
} else { } else {
glide.asBitmap() glide.asBitmap()
.load(uri) .load(uri)
.placeholder(placeholder) .placeholder(placeholder)
.into(imageView) .into(imageView)
} }
} }
@ -427,103 +441,103 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
binding.mainDrawer.apply { binding.mainDrawer.apply {
tintStatusBar = true tintStatusBar = true
addItems( addItems(
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_edit_profile nameRes = R.string.action_edit_profile
iconicsIcon = GoogleMaterial.Icon.gmd_person iconicsIcon = GoogleMaterial.Icon.gmd_person
onClick = { onClick = {
val intent = Intent(context, EditProfileActivity::class.java) val intent = Intent(context, EditProfileActivity::class.java)
startActivityWithSlideInAnimation(intent) startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_saved_toot
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
} }
},
primaryDrawerItem {
nameRes = R.string.action_view_favourites
isSelectable = false
iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = {
val intent = StatusListActivity.newFavouritesIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_bookmarks
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = {
val intent = StatusListActivity.newBookmarksIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_view_follow_requests
iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = {
val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_lists
iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = {
startActivityWithSlideInAnimation(ListsActivity.newIntent(context))
}
},
primaryDrawerItem {
nameRes = R.string.action_access_drafts
iconRes = R.drawable.ic_notebook
onClick = {
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot
iconRes = R.drawable.ic_access_time
onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
iconRes = R.drawable.ic_account_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_view_preferences
iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = {
val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.about_title_activity
iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = {
val intent = Intent(context, AboutActivity::class.java)
startActivityWithSlideInAnimation(intent)
}
},
secondaryDrawerItem {
nameRes = R.string.action_logout
iconRes = R.drawable.ic_logout
onClick = ::logout
}
) )
addStickyDrawerItems( addStickyDrawerItems(
FooterDrawerItem().apply { FooterDrawerItem().apply {
@ -536,14 +550,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
) )
if (addSearchButton) { if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition(4, binding.mainDrawer.addItemsAtPosition(
primaryDrawerItem { 4,
nameRes = R.string.action_search primaryDrawerItem {
iconicsIcon = GoogleMaterial.Icon.gmd_search nameRes = R.string.action_search
onClick = { iconicsIcon = GoogleMaterial.Icon.gmd_search
startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) onClick = {
} startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
}) }
}
)
} }
setSavedInstance(savedInstanceState) setSavedInstance(savedInstanceState)
@ -551,11 +567,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
binding.mainDrawer.addItems( binding.mainDrawer.addItems(
secondaryDrawerItem { secondaryDrawerItem {
nameText = "debug" nameText = "debug"
isEnabled = false isEnabled = false
textColor = ColorStateList.valueOf(Color.GREEN) textColor = ColorStateList.valueOf(Color.GREEN)
} }
) )
} }
@ -607,7 +623,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val popups = ArrayList<PopupMenu>() val popups = ArrayList<PopupMenu>()
for (i in tabs.indices) { for (i in tabs.indices) {
val tab = activeTabLayout.newTab() val tab = activeTabLayout.newTab()
.setIcon(tabs[i].icon) .setIcon(tabs[i].icon)
if (tabs[i].id == LIST) { if (tabs[i].id == LIST) {
tab.contentDescription = tabs[i].arguments[1] tab.contentDescription = tabs[i].arguments[1]
} else { } else {
@ -663,11 +679,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
(fragment as ReselectableFragment).onReselect() (fragment as ReselectableFragment).onReselect()
} }
} }
R.id.tabReset -> {
if (fragment is ReselectableFragment) {
(fragment as ReselectableFragment).onReset()
}
}
R.id.tabEditList -> { R.id.tabEditList -> {
AccountsInListFragment.newInstance( AccountsInListFragment.newInstance(
tabs[i].arguments.getOrNull(0).orEmpty(), tabs[i].arguments.getOrNull(0).orEmpty(),
@ -676,25 +687,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
R.id.tabToggleStreaming -> { R.id.tabToggleStreaming -> {
if (fragment is TimelineFragment) { if (fragment is TimelineFragment) {
fragment.isStreamingEnabled = !fragment.isStreamingEnabled val current = fragment.toggleStreaming()
item.isChecked = fragment.isStreamingEnabled item.isChecked = current
tintCheckIcon(item) tintCheckIcon(item)
if (fragment.isStreamingEnabled) { if (current) {
streamingTabsCount++ streamingTabsCount++
} else { } else {
streamingTabsCount-- streamingTabsCount--
} }
keepScreenOn() keepScreenOn()
tabs[i] = tabs[i].copy(enableStreaming = fragment.isStreamingEnabled) tabs[i] = tabs[i].copy(enableStreaming = current)
accountManager.activeAccount?.let { accountManager.activeAccount?.let {
Single.fromCallable { Single.fromCallable {
it.tabPreferences = tabs it.tabPreferences = tabs
accountManager.saveAccount(it) accountManager.saveAccount(it)
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe() .subscribe()
} }
} }
@ -767,25 +778,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
//open profile when active image was clicked // open profile when active image was clicked
if (current && activeAccount != null) { if (current && activeAccount != null) {
val intent = AccountActivity.getIntent(this, activeAccount.accountId) val intent = AccountActivity.getIntent(this, activeAccount.accountId)
startActivityWithSlideInAnimation(intent) startActivityWithSlideInAnimation(intent)
return false return false
} }
//open LoginActivity to add new account // open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true)) startActivityWithSlideInAnimation(LoginActivity.getIntent(this, true))
return false return false
} }
//change Account // change Account
changeAccount(profile.identifier, null) changeAccount(profile.identifier, null)
return false return false
} }
private fun changeAccount(newSelectedId: Long, forward: Intent?) { private fun changeAccount(newSelectedId: Long, forward: Intent?) {
cacheUpdater.stop() cacheUpdater.stop()
SFragment.flushFilters()
accountManager.setActiveAccount(newSelectedId) accountManager.setActiveAccount(newSelectedId)
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
@ -803,49 +813,55 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun logout() { private fun logout() {
accountManager.activeAccount?.let { activeAccount -> accountManager.activeAccount?.let { activeAccount ->
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.action_logout) .setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.yes) { _: DialogInterface?, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) lifecycleScope.launch {
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id) cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount) removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut() val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) { if (!NotificationHelper.areNotificationsEnabled(
NotificationHelper.disablePullNotifications(this) this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
} }
val intent = if (newAccount == null) { val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false) LoginActivity.getIntent(this@MainActivity, false)
} else { } else {
Intent(this, MainActivity::class.java) Intent(this@MainActivity, MainActivity::class.java)
} }
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
} }
.setNegativeButton(android.R.string.no, null) }
.show() .setNegativeButton(android.R.string.cancel, null)
.show()
} }
} }
private fun fetchUserInfo() { private fun fetchUserInfo() {
mastodonApi.accountVerifyCredentials() mastodonApi.accountVerifyCredentials()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe( .subscribe(
{ userInfo -> { userInfo ->
onFetchUserInfoSuccess(userInfo) onFetchUserInfoSuccess(userInfo)
}, },
{ throwable -> { throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message) Log.e(TAG, "Failed to fetch user info. " + throwable.message)
} }
) )
} }
private fun onFetchUserInfoSuccess(me: Account) { private fun onFetchUserInfoSuccess(me: Account) {
glide.asBitmap() glide.asBitmap()
.load(me.header) .load(me.header)
.into(header.accountHeaderBackground) .into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false) loadDrawerAvatar(me.avatar, false)
@ -864,7 +880,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
glide.asDrawable() glide.asDrawable()
.load(avatarUrl) .load(avatarUrl)
.transform( .transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
) )
.apply { .apply {
if (showPlaceholder) { if (showPlaceholder) {
@ -893,17 +909,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun fetchAnnouncements() { private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false) mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe( .subscribe(
{ announcements -> { announcements ->
unreadAnnouncementsCount = announcements.count { !it.read } unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge() updateAnnouncementsBadge()
}, },
{ {
Log.w(TAG, "Failed to fetch announcements.", it) Log.w(TAG, "Failed to fetch announcements.", it)
} }
) )
} }
private fun updateAnnouncementsBadge() { private fun updateAnnouncementsBadge() {
@ -937,30 +953,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id) header.setActiveProfile(accountManager.activeAccount!!.id)
} }
private fun draftWarning() { override fun getActionButton() = binding.composeButton
val sharedPrefsKey = "show_draft_warning"
appDb.tootDao().savedTootCount()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { draftCount ->
val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true)
if (draftCount > 0 && showDraftWarning) {
AlertDialog.Builder(this)
.setMessage(R.string.new_drafts_warning)
.setNegativeButton("Don't show again") { _, _ ->
preferences.edit(commit = true) {
putBoolean(sharedPrefsKey, false)
}
}
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
override fun getActionButton(): FloatingActionButton = binding.composeButton
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector
@ -974,20 +967,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem {
return PrimaryDrawerItem() return PrimaryDrawerItem()
.apply { .apply {
isSelectable = false isSelectable = false
isIconTinted = true isIconTinted = true
} }
.apply(block) .apply(block)
} }
private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem {
return SecondaryDrawerItem() return SecondaryDrawerItem()
.apply { .apply {
isSelectable = false isSelectable = false
isIconTinted = true isIconTinted = true
} }
.apply(block) .apply(block)
} }
private var AbstractDrawerItem<*, *>.onClick: () -> Unit private var AbstractDrawerItem<*, *>.onClick: () -> Unit

View File

@ -5,17 +5,17 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.uber.autodispose.AutoDispose.autoDisposable
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.QuickTootViewModel
import javax.inject.Inject import javax.inject.Inject
@ -43,20 +43,20 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
} }
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) { if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineFragment.Kind val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
?: TimelineFragment.Kind.HOME ?: TimelineViewModel.Kind.HOME
val argument = intent?.getStringExtra(ARG_ARG) val argument = intent?.getStringExtra(ARG_ARG)
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument)) .replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
.commit() .commit()
} }
binding.viewQuickToot.attachViewModel(quickTootViewModel, this) binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
eventHub.events eventHub.events
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.`as`(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(binding.viewQuickToot::handleEvent) .subscribe(binding.viewQuickToot::handleEvent)
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
} }
@ -69,13 +69,15 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
private const val ARG_ARG = "arg" private const val ARG_ARG = "arg"
@JvmStatic @JvmStatic
fun newIntent(context: Context, kind: TimelineFragment.Kind, fun newIntent(
argument: String?): Intent { context: Context,
kind: TimelineViewModel.Kind,
argument: String?
): Intent {
val intent = Intent(context, ModalTimelineActivity::class.java) val intent = Intent(context, ModalTimelineActivity::class.java)
intent.putExtra(ARG_KIND, kind) intent.putExtra(ARG_KIND, kind)
intent.putExtra(ARG_ARG, argument) intent.putExtra(ARG_ARG, argument)
return intent return intent
} }
} }
} }

View File

@ -1,213 +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.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AppDatabase;
import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.view.BackgroundMessageView;
import java.lang.ref.WeakReference;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction,
Injectable {
// ui
private SavedTootAdapter adapter;
private BackgroundMessageView errorMessageView;
private List<TootEntity> toots = new ArrayList<>();
@Nullable
private AsyncTask<?, ?, ?> asyncTask;
@Inject
EventHub eventHub;
@Inject
AppDatabase database;
@Inject
SaveTootHelper saveTootHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
eventHub.getEvents()
.observeOn(AndroidSchedulers.mainThread())
.ofType(StatusComposedEvent.class)
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe((__) -> this.fetchToots());
setContentView(R.layout.activity_saved_toot);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(getString(R.string.title_drafts));
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
RecyclerView recyclerView = findViewById(R.id.recyclerView);
errorMessageView = findViewById(R.id.errorMessageView);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
this, layoutManager.getOrientation());
recyclerView.addItemDecoration(divider);
adapter = new SavedTootAdapter(this);
recyclerView.setAdapter(adapter);
}
@Override
protected void onResume() {
super.onResume();
fetchToots();
}
@Override
protected void onPause() {
super.onPause();
if (asyncTask != null) asyncTask.cancel(true);
}
private void fetchToots() {
asyncTask = new FetchPojosTask(this, database.tootDao())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
private void setNoContent(int size) {
if (size == 0) {
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status, null);
errorMessageView.setVisibility(View.VISIBLE);
} else {
errorMessageView.setVisibility(View.GONE);
}
}
@Override
public void delete(int position, TootEntity item) {
saveTootHelper.deleteDraft(item);
toots.remove(position);
// update adapter
if (adapter != null) {
adapter.removeItem(position);
setNoContent(toots.size());
}
}
@Override
public void click(int position, TootEntity item) {
Gson gson = new Gson();
Type stringListType = new TypeToken<List<String>>() {}.getType();
List<String> jsonUrls = gson.fromJson(item.getUrls(), stringListType);
List<String> descriptions = gson.fromJson(item.getDescriptions(), stringListType);
ComposeOptions composeOptions = new ComposeOptions(
/*scheduledTootUid*/null,
item.getUid(),
/*drafId*/null,
item.getText(),
jsonUrls,
descriptions,
/*mentionedUsernames*/null,
item.getInReplyToId(),
/*quoteId*/null,
/*quoteStatusAuthor*/null,
/*quoteStatusContent*/null,
/*replyVisibility*/null,
item.getVisibility(),
item.getContentWarning(),
item.getInReplyToUsername(),
item.getInReplyToText(),
/*mediaAttachments*/null,
/*draftAttachments*/null,
/*scheduledAt*/null,
/*sensitive*/null,
/*poll*/null,
/* modifiedInitialState */ true,
false
);
Intent intent = ComposeActivity.startIntent(this, composeOptions);
startActivity(intent);
}
static final class FetchPojosTask extends AsyncTask<Void, Void, List<TootEntity>> {
private final WeakReference<SavedTootActivity> activityRef;
private final TootDao tootDao;
FetchPojosTask(SavedTootActivity activity, TootDao tootDao) {
this.activityRef = new WeakReference<>(activity);
this.tootDao = tootDao;
}
@Override
protected List<TootEntity> doInBackground(Void... voids) {
return tootDao.loadAll();
}
@Override
protected void onPostExecute(List<TootEntity> pojos) {
super.onPostExecute(pojos);
SavedTootActivity activity = activityRef.get();
if (activity == null) return;
activity.toots.clear();
activity.toots.addAll(pojos);
// set ui
activity.setNoContent(pojos.size());
activity.adapter.setItems(activity.toots);
activity.adapter.notifyDataSetChanged();
}
}
}

View File

@ -18,10 +18,9 @@ package com.keylesspalace.tusky
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import net.accelf.yuito.CustomUncaughtExceptionHandler import net.accelf.yuito.CustomUncaughtExceptionHandler
import javax.inject.Inject import javax.inject.Inject
@ -50,5 +49,4 @@ class SplashActivity : AppCompatActivity(), Injectable {
startActivity(intent) startActivity(intent)
finish() finish()
} }
} }

View File

@ -21,17 +21,15 @@ import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.TimelineViewModel.Kind
import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.fragment.TimelineFragment.Kind
import com.uber.autodispose.AutoDispose
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.QuickTootViewModel
import javax.inject.Inject import javax.inject.Inject
@ -56,7 +54,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
val title = if(kind == Kind.FAVOURITES) { val title = if (kind == Kind.FAVOURITES) {
R.string.title_favourites R.string.title_favourites
} else { } else {
R.string.title_bookmarks R.string.title_bookmarks
@ -77,7 +75,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
eventHub.events eventHub.events
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.`as`(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(binding.viewQuickToot::handleEvent) .subscribe(binding.viewQuickToot::handleEvent)
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
} }
@ -90,15 +88,14 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@JvmStatic @JvmStatic
fun newFavouritesIntent(context: Context) = fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply { Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name) putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
} }
@JvmStatic @JvmStatic
fun newBookmarksIntent(context: Context) = fun newBookmarksIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply { Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
} }
} }
} }

View File

@ -20,8 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
/** this would be a good case for a sealed class, but that does not work nice with Room */ /** this would be a good case for a sealed class, but that does not work nice with Room */
@ -35,66 +36,68 @@ const val LIST = "List"
const val STREAMING = "STR" const val STREAMING = "STR"
data class TabData(val id: String, data class TabData(
@StringRes val text: Int, val id: String,
@DrawableRes val icon: Int, @StringRes val text: Int,
val fragment: (List<String>) -> Fragment, @DrawableRes val icon: Int,
val arguments: List<String> = emptyList(), val fragment: (List<String>) -> Fragment,
val title: (Context) -> String = { context -> context.getString(text)}, val arguments: List<String> = emptyList(),
val enableStreaming: Boolean = false) val title: (Context) -> String = { context -> context.getString(text) },
val enableStreaming: Boolean = false,
)
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData { fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
val enableStreaming = id.endsWith(STREAMING) val enableStreaming = id.endsWith(STREAMING)
return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) { return when (if (enableStreaming) id.slice(IntRange(0, id.length - 4)) else id) {
HOME -> TabData( HOME -> TabData(
HOME, HOME,
R.string.title_home, R.string.title_home,
R.drawable.ic_home_24dp, R.drawable.ic_home_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.HOME, enableStreaming = enableStreaming) }, { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming enableStreaming = enableStreaming
) )
NOTIFICATIONS -> TabData( NOTIFICATIONS -> TabData(
NOTIFICATIONS, NOTIFICATIONS,
R.string.title_notifications, R.string.title_notifications,
R.drawable.ic_notifications_24dp, R.drawable.ic_notifications_24dp,
{ NotificationsFragment.newInstance() } { NotificationsFragment.newInstance() }
) )
LOCAL -> TabData( LOCAL -> TabData(
LOCAL, LOCAL,
R.string.title_public_local, R.string.title_public_local,
R.drawable.ic_local_24dp, R.drawable.ic_local_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) }, { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming enableStreaming = enableStreaming
) )
FEDERATED -> TabData( FEDERATED -> TabData(
FEDERATED, FEDERATED,
R.string.title_public_federated, R.string.title_public_federated,
R.drawable.ic_public_24dp, R.drawable.ic_public_24dp,
{ TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) }, { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED, enableStreaming = enableStreaming) },
enableStreaming = enableStreaming enableStreaming = enableStreaming
) )
DIRECT -> TabData( DIRECT -> TabData(
DIRECT, DIRECT,
R.string.title_direct_messages, R.string.title_direct_messages,
R.drawable.ic_reblog_direct_24dp, R.drawable.ic_reblog_direct_24dp,
{ ConversationsFragment.newInstance() } { ConversationsFragment.newInstance() }
) )
HASHTAG -> TabData( HASHTAG -> TabData(
HASHTAG, HASHTAG,
R.string.hashtags, R.string.hashtags,
R.drawable.ic_hashtag, R.drawable.ic_hashtag,
{ args -> TimelineFragment.newHashtagInstance(args) }, { args -> TimelineFragment.newHashtagInstance(args) },
arguments, arguments,
{ context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) }} { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } }
) )
LIST -> TabData( LIST -> TabData(
LIST, LIST,
R.string.list, R.string.list,
R.drawable.ic_list, R.drawable.ic_list,
{ args -> TimelineFragment.newInstance(TimelineFragment.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) }, { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty(), true, enableStreaming) },
arguments, arguments,
{ arguments.getOrNull(1).orEmpty() }, { arguments.getOrNull(1).orEmpty() },
enableStreaming enableStreaming
) )
else -> throw IllegalArgumentException("unknown tab type") else -> throw IllegalArgumentException("unknown tab type")
} }
@ -102,9 +105,9 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
fun defaultTabs(): List<TabData> { fun defaultTabs(): List<TabData> {
return listOf( return listOf(
createTabDataFromId(HOME), createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS), createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL), createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED) createTabDataFromId(FEDERATED)
) )
} }

View File

@ -31,6 +31,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.ItemInteractionListener
@ -44,11 +46,9 @@ import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.inject.Inject import javax.inject.Inject
@ -221,26 +221,26 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
frameLayout.addView(editText) frameLayout.addView(editText)
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setTitle(R.string.add_hashtag_title) .setTitle(R.string.add_hashtag_title)
.setView(frameLayout) .setView(frameLayout)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.action_save) { _, _ -> .setPositiveButton(R.string.action_save) { _, _ ->
val input = editText.text.toString().trim() val input = editText.text.toString().trim()
if (tab == null) { if (tab == null) {
val newTab = createTabDataFromId(HASHTAG, listOf(input)) val newTab = createTabDataFromId(HASHTAG, listOf(input))
currentTabs.add(newTab) currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else { } else {
val newTab = tab.copy(arguments = tab.arguments + input) val newTab = tab.copy(arguments = tab.arguments + input)
currentTabs[tabPosition] = newTab currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(tabPosition) currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
saveTabs()
} }
.create()
updateAvailableTabs()
saveTabs()
}
.create()
editText.onTextChanged { s, _, _, _ -> editText.onTextChanged { s, _, _, _ ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s) dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
@ -254,28 +254,28 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private fun showSelectListDialog() { private fun showSelectListDialog() {
val adapter = ListSelectionAdapter(this) val adapter = ListSelectionAdapter(this)
mastodonApi.getLists() mastodonApi.getLists()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe ( .subscribe(
{ lists -> { lists ->
adapter.addAll(lists) adapter.addAll(lists)
}, },
{ throwable -> { throwable ->
Log.e("TabPreferenceActivity", "failed to load lists", throwable) Log.e("TabPreferenceActivity", "failed to load lists", throwable)
} }
) )
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.select_list_title) .setTitle(R.string.select_list_title)
.setAdapter(adapter) { _, position -> .setAdapter(adapter) { _, position ->
val list = adapter.getItem(position) val list = adapter.getItem(position)
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title)) val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
currentTabs.add(newTab) currentTabs.add(newTab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
updateAvailableTabs() updateAvailableTabs()
saveTabs() saveTabs()
} }
.show() .show()
} }
private fun validateHashtag(input: CharSequence?): Boolean { private fun validateHashtag(input: CharSequence?): Boolean {
@ -330,10 +330,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
it.tabPreferences = currentTabs it.tabPreferences = currentTabs
accountManager.saveAccount(it) accountManager.saveAccount(it)
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe() .subscribe()
} }
tabsChanged = true tabsChanged = true
} }
@ -357,5 +356,4 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
private const val MIN_TAB_COUNT = 2 private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 9 private const val MAX_TAB_COUNT = 9
} }
} }

View File

@ -22,16 +22,16 @@ import android.util.Log
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.work.WorkManager import androidx.work.WorkManager
import autodispose2.AutoDisposePlugins
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.uber.autodispose.AutoDisposePlugins
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import io.reactivex.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.conscrypt.Conscrypt import org.conscrypt.Conscrypt
import java.security.Security import java.security.Security
import javax.inject.Inject import javax.inject.Inject
@ -68,8 +68,8 @@ class TuskyApplication : Application(), HasAndroidInjector {
// init the custom emoji fonts // init the custom emoji fonts
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0)
val emojiConfig = EmojiCompatFont.byId(emojiSelection) val emojiConfig = EmojiCompatFont.byId(emojiSelection)
.getConfig(this) .getConfig(this)
.setReplaceAll(true) .setReplaceAll(true)
EmojiCompat.init(emojiConfig) EmojiCompat.init(emojiConfig)
// init night mode // init night mode
@ -81,10 +81,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
} }
WorkManager.initialize( WorkManager.initialize(
this, this,
androidx.work.Configuration.Builder() androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory) .setWorkerFactory(notificationWorkerFactory)
.build() .build()
) )
} }
@ -104,4 +104,4 @@ class TuskyApplication : Application(), HasAndroidInjector {
@JvmStatic @JvmStatic
lateinit var localeManager: LocaleManager lateinit var localeManager: LocaleManager
} }
} }

View File

@ -41,27 +41,27 @@ import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.Locale
typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit
@ -102,17 +102,16 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val realAttachs = attachments!!.map(AttachmentViewData::attachment) val realAttachs = attachments!!.map(AttachmentViewData::attachment)
// Setup the view pager. // Setup the view pager.
ImagePagerAdapter(this, realAttachs, initialPosition) ImagePagerAdapter(this, realAttachs, initialPosition)
} else { } else {
imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL) imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL)
?: throw IllegalArgumentException("attachment list or image url has to be set") ?: throw IllegalArgumentException("attachment list or image url has to be set")
SingleImagePagerAdapter(this, imageUrl!!) SingleImagePagerAdapter(this, imageUrl!!)
} }
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
binding.viewPager.setCurrentItem(initialPosition, false) binding.viewPager.setCurrentItem(initialPosition, false)
binding.viewPager.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() { binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
binding.toolbar.title = getPageTitle(position) binding.toolbar.title = getPageTitle(position)
} }
@ -138,6 +137,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE
window.statusBarColor = Color.BLACK window.statusBarColor = Color.BLACK
window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { window.sharedElementEnterTransition.addListener(object : NoopTransitionListener {
override fun onTransitionEnd(transition: Transition) { override fun onTransitionEnd(transition: Transition) {
@ -182,17 +182,17 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
binding.toolbar.animate().alpha(alpha) binding.toolbar.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() { .setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
binding.toolbar.visibility = visibility binding.toolbar.visibility = visibility
animation.removeListener(this) animation.removeListener(this)
} }
}) })
.start() .start()
} }
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
if(attachments == null) { if (attachments == null) {
return "" return ""
} }
return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size) return String.format(Locale.getDefault(), "%d/%d", position + 1, attachments?.size)
@ -205,8 +205,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url)) val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, request.setDestinationInExternalPublicDir(
getString(R.string.app_name) + "/" + filename) Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
downloadManager.enqueue(request) downloadManager.enqueue(request)
} }
@ -260,7 +262,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to)))
} }
private var isCreating: Boolean = false private var isCreating: Boolean = false
private fun shareImage(directory: File, url: String) { private fun shareImage(directory: File, url: String) {
@ -269,7 +270,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
invalidateOptionsMenu() invalidateOptionsMenu()
val file = File(directory, getTemporaryMediaFilename("png")) val file = File(directory, getTemporaryMediaFilename("png"))
val futureTask: FutureTarget<Bitmap> = val futureTask: FutureTarget<Bitmap> =
Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit()
Single.fromCallable { Single.fromCallable {
val bitmap = futureTask.get() val bitmap = futureTask.get()
try { try {
@ -283,32 +284,30 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
Log.e(TAG, "Error writing temporary media.") Log.e(TAG, "Error writing temporary media.")
} }
return@fromCallable false return@fromCallable false
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnDispose { .doOnDispose {
futureTask.cancel(true) futureTask.cancel(true)
}
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY))
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result)
shareFile(file, "image/png")
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
} }
.autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) )
.subscribe(
{ result ->
Log.d(TAG, "Download image result: $result")
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
if (result)
shareFile(file, "image/png")
},
{ error ->
isCreating = false
invalidateOptionsMenu()
binding.progressBarShare.visibility = View.GONE
Log.e(TAG, "Failed to download image", error)
}
)
} }
private fun shareMediaFile(directory: File, url: String) { private fun shareMediaFile(directory: File, url: String) {
@ -351,7 +350,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
} }
abstract class ViewMediaAdapter(activity: FragmentActivity): FragmentStateAdapter(activity) { abstract class ViewMediaAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
abstract fun onTransitionEnd(position: Int) abstract fun onTransitionEnd(position: Int)
} }

View File

@ -24,14 +24,8 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction; import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.Lifecycle;
import com.keylesspalace.tusky.appstore.EventHub; import com.keylesspalace.tusky.components.timeline.TimelineFragment;
import com.keylesspalace.tusky.di.ViewModelFactory;
import com.keylesspalace.tusky.fragment.TimelineFragment;
import net.accelf.yuito.QuickTootView;
import net.accelf.yuito.QuickTootViewModel;
import java.util.Collections; import java.util.Collections;
@ -40,10 +34,6 @@ import javax.inject.Inject;
import dagger.android.AndroidInjector; import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector; import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector; import dagger.android.HasAndroidInjector;
import io.reactivex.android.schedulers.AndroidSchedulers;
import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector { public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector {
@ -51,10 +41,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn
@Inject @Inject
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector; public DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
@Inject
public EventHub eventHub;
@Inject
public ViewModelFactory viewModelFactory;
public static Intent getIntent(Context context, String tag){ public static Intent getIntent(Context context, String tag){
Intent intent = new Intent(context,ViewTagActivity.class); Intent intent = new Intent(context,ViewTagActivity.class);
@ -83,15 +69,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn
Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag)); Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag));
fragmentTransaction.replace(R.id.fragment_container, fragment); fragmentTransaction.replace(R.id.fragment_container, fragment);
fragmentTransaction.commit(); fragmentTransaction.commit();
QuickTootViewModel quickTootViewModel = viewModelFactory.create(QuickTootViewModel.class);
QuickTootView quickTootView = findViewById(R.id.viewQuickToot);
quickTootView.attachViewModel(quickTootViewModel, this);
eventHub.getEvents()
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(quickTootView::handleEvent);
} }
@Override @Override

View File

@ -1,116 +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.adapter;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.util.ListUtils;
import java.util.ArrayList;
import java.util.List;
public abstract class AccountAdapter extends RecyclerView.Adapter {
static final int VIEW_TYPE_ACCOUNT = 0;
static final int VIEW_TYPE_FOOTER = 1;
List<Account> accountList;
AccountActionListener accountActionListener;
private boolean bottomLoading;
protected final boolean animateEmojis;
protected final boolean animateAvatar;
AccountAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
this.accountList = new ArrayList<>();
this.accountActionListener = accountActionListener;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
bottomLoading = false;
}
@Override
public int getItemCount() {
return accountList.size() + (bottomLoading ? 1 : 0);
}
@Override
public int getItemViewType(int position) {
if (position == accountList.size() && bottomLoading) {
return VIEW_TYPE_FOOTER;
} else {
return VIEW_TYPE_ACCOUNT;
}
}
public void update(@NonNull List<Account> newAccounts) {
accountList = ListUtils.removeDuplicates(newAccounts);
notifyDataSetChanged();
}
public void addItems(@NonNull List<Account> newAccounts) {
int end = accountList.size();
Account last = accountList.get(end - 1);
if (last != null && !findAccount(newAccounts, last.getId())) {
accountList.addAll(newAccounts);
notifyItemRangeInserted(end, newAccounts.size());
}
}
public void setBottomLoading(boolean loading) {
boolean wasLoading = bottomLoading;
if(wasLoading == loading) {
return;
}
bottomLoading = loading;
if(loading) {
notifyItemInserted(accountList.size());
} else {
notifyItemRemoved(accountList.size());
}
}
private static boolean findAccount(@NonNull List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.getId().equals(id)) {
return true;
}
}
return false;
}
@Nullable
public Account removeItem(int position) {
if (position < 0 || position >= accountList.size()) {
return null;
}
Account account = accountList.remove(position);
notifyItemRemoved(position);
return account;
}
public void addItem(@NonNull Account account, int position) {
if (position < 0 || position > accountList.size()) {
return;
}
accountList.add(position, account);
notifyItemInserted(position);
}
}

View File

@ -0,0 +1,124 @@
/* 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.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.removeDuplicates
/** Generic adapter with bottom loading indicator. */
abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructor(
var accountActionListener: AccountActionListener,
protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>()
private var bottomLoading: Boolean = false
override fun getItemCount(): Int {
return accountList.size + if (bottomLoading) 1 else 0
}
abstract fun createAccountViewHolder(parent: ViewGroup): AVH
abstract fun onBindAccountViewHolder(viewHolder: AVH, position: Int)
final override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
@Suppress("UNCHECKED_CAST")
this.onBindAccountViewHolder(holder as AVH, position)
}
}
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_ACCOUNT -> this.createAccountViewHolder(parent)
VIEW_TYPE_FOOTER -> this.createFooterViewHolder(parent)
else -> error("Unknown item type: $viewType")
}
}
private fun createFooterViewHolder(
parent: ViewGroup,
): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_footer, parent, false)
return LoadingFooterViewHolder(view)
}
override fun getItemViewType(position: Int): Int {
return if (position == accountList.size && bottomLoading) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ACCOUNT
}
}
fun update(newAccounts: List<Account>) {
accountList = removeDuplicates(newAccounts)
notifyDataSetChanged()
}
fun addItems(newAccounts: List<Account>) {
val end = accountList.size
val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) {
accountList.addAll(newAccounts)
notifyItemRangeInserted(end, newAccounts.size)
}
}
fun setBottomLoading(loading: Boolean) {
val wasLoading = bottomLoading
if (wasLoading == loading) {
return
}
bottomLoading = loading
if (loading) {
notifyItemInserted(accountList.size)
} else {
notifyItemRemoved(accountList.size)
}
}
fun removeItem(position: Int): Account? {
if (position < 0 || position >= accountList.size) {
return null
}
val account = accountList.removeAt(position)
notifyItemRemoved(position)
return account
}
fun addItem(account: Account, position: Int) {
if (position < 0 || position > accountList.size) {
return
}
accountList.add(position, account)
notifyItemInserted(position)
}
companion object {
const val VIEW_TYPE_ACCOUNT = 0
const val VIEW_TYPE_FOOTER = 1
}
}

View File

@ -25,14 +25,14 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.*
class AccountFieldAdapter( class AccountFieldAdapter(
private val linkListener: LinkListener, private val linkListener: LinkListener,
private val animateEmojis: Boolean private val animateEmojis: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() {
var emojis: List<Emoji> = emptyList() var emojis: List<Emoji> = emptyList()
@ -50,7 +50,7 @@ class AccountFieldAdapter(
val nameTextView = holder.binding.accountFieldName val nameTextView = holder.binding.accountFieldName
val valueTextView = holder.binding.accountFieldValue val valueTextView = holder.binding.accountFieldValue
if(proofOrField.isLeft()) { if (proofOrField.isLeft()) {
val identityProof = proofOrField.asLeft() val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider nameTextView.text = identityProof.provider
@ -58,7 +58,7 @@ class AccountFieldAdapter(
valueTextView.movementMethod = LinkMovementMethod.getInstance() valueTextView.movementMethod = LinkMovementMethod.getInstance()
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else { } else {
val field = proofOrField.asRight() val field = proofOrField.asRight()
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
@ -67,12 +67,11 @@ class AccountFieldAdapter(
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener) LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener)
if(field.verifiedAt != null) { if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else { } else {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 ) valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
} }
} }
} }
} }

View File

@ -34,7 +34,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
fields.forEach { field -> fields.forEach { field ->
fieldData.add(MutableStringPair(field.name, field.value)) fieldData.add(MutableStringPair(field.name, field.value))
} }
if(fieldData.isEmpty()) { if (fieldData.isEmpty()) {
fieldData.add(MutableStringPair("", "")) fieldData.add(MutableStringPair("", ""))
} }
@ -63,7 +63,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldName.setText(fieldData[position].first) holder.binding.accountFieldName.setText(fieldData[position].first)
holder.binding.accountFieldValue.setText(fieldData[position].second) holder.binding.accountFieldValue.setText(fieldData[position].second)
holder.binding.accountFieldName.addTextChangedListener(object: TextWatcher { holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) { override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].first = newText.toString() fieldData[holder.bindingAdapterPosition].first = newText.toString()
} }
@ -73,7 +73,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
}) })
holder.binding.accountFieldValue.addTextChangedListener(object: TextWatcher { holder.binding.accountFieldValue.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) { override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].second = newText.toString() fieldData[holder.bindingAdapterPosition].second = newText.toString()
} }
@ -82,9 +82,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
}) })
} }
class MutableStringPair (var first: String, var second: String) class MutableStringPair(var first: String, var second: String)
} }

View File

@ -25,7 +25,8 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) { class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(context, R.layout.item_autocomplete_account) {
@ -48,9 +49,8 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
val animateAvatar = pm.getBoolean("animateGifAvatars", false) val animateAvatar = pm.getBoolean("animateGifAvatars", false)
loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar)
} }
return binding.root return binding.root
} }
} }

View File

@ -1,106 +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.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
public class BlocksAdapter extends AccountAdapter {
public BlocksAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_ACCOUNT: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_blocked_user, parent, false);
return new BlockedUserViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new LoadingFooterViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}
static class BlockedUserViewHolder extends RecyclerView.ViewHolder {
private ImageView avatar;
private TextView username;
private TextView displayName;
private ImageButton unblock;
private String id;
BlockedUserViewHolder(View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.blocked_user_avatar);
username = itemView.findViewById(R.id.blocked_user_username);
displayName = itemView.findViewById(R.id.blocked_user_display_name);
unblock = itemView.findViewById(R.id.blocked_user_unblock);
}
void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) {
id = account.getId();
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_48dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
}
void setupActionListener(final AccountActionListener listener) {
unblock.setOnClickListener(v -> {
int position = getBindingAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onBlock(false, id, position);
}
});
itemView.setOnClickListener(v -> listener.onViewAccount(id));
}
}
}

View File

@ -0,0 +1,80 @@
/* 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.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
/** Displays a list of blocked accounts. */
class BlocksAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
) : AccountAdapter<BlocksAdapter.BlockedUserViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis
) {
override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_blocked_user, parent, false)
return BlockedUserViewHolder(view)
}
override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
viewHolder.setupActionListener(accountActionListener)
}
class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar)
private val username: TextView = itemView.findViewById(R.id.blocked_user_username)
private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name)
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format)
val formattedUsername = String.format(format, account.username)
username.text = formattedUsername
val avatarRadius = avatar.context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
}
fun setupActionListener(listener: AccountActionListener) {
unblock.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onBlock(false, id, position)
}
}
itemView.setOnClickListener { listener.onViewAccount(id) }
}
}
}

View File

@ -22,15 +22,15 @@ import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import java.util.* import java.util.Locale
class EmojiAdapter( class EmojiAdapter(
emojiList: List<Emoji>, emojiList: List<Emoji>,
private val onEmojiSelectedListener: OnEmojiSelectedListener private val onEmojiSelectedListener: OnEmojiSelectedListener
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList : List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
.sortedBy { it.shortcode.toLowerCase(Locale.ROOT) } .sortedBy { it.shortcode.lowercase(Locale.ROOT) }
override fun getItemCount() = emojiList.size override fun getItemCount() = emojiList.size
@ -44,8 +44,8 @@ class EmojiAdapter(
val emojiImageView = holder.binding.root val emojiImageView = holder.binding.root
Glide.with(emojiImageView) Glide.with(emojiImageView)
.load(emoji.url) .load(emoji.url)
.into(emojiImageView) .into(emojiImageView)
emojiImageView.setOnClickListener { emojiImageView.setOnClickListener {
onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) onEmojiSelectedListener.onEmojiSelected(emoji.shortcode)

View File

@ -1,61 +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.adapter;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
/** Both for follows and following lists. */
public class FollowAdapter extends AccountAdapter {
public FollowAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_ACCOUNT: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_account, parent, false);
return new AccountViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new LoadingFooterViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
AccountViewHolder holder = (AccountViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}
}

View File

@ -0,0 +1,38 @@
/* 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 com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.AccountActionListener
/** Displays either a follows or following list. */
class FollowAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
) : AccountAdapter<AccountViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_account, parent, false)
return AccountViewHolder(view)
}
override fun onBindAccountViewHolder(viewHolder: AccountViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
viewHolder.setupActionListener(accountActionListener)
}
}

View File

@ -24,11 +24,14 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
class FollowRequestViewHolder( class FollowRequestViewHolder(
private val binding: ItemFollowRequestBinding, private val binding: ItemFollowRequestBinding,
private val showHeader: Boolean private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {

View File

@ -1,60 +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.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
public class FollowRequestsAdapter extends AccountAdapter {
public FollowRequestsAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_ACCOUNT: {
ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new FollowRequestViewHolder(binding, false);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new LoadingFooterViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener, accountList.get(position).getId());
}
}
}

View File

@ -0,0 +1,39 @@
/* 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 com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.interfaces.AccountActionListener
/** Displays a list of follow requests with accept/reject buttons. */
class FollowRequestsAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
) : AccountAdapter<FollowRequestViewHolder>(accountActionListener, animateAvatar, animateEmojis) {
override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder {
val binding = ItemFollowRequestBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return FollowRequestViewHolder(binding, false)
}
override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) {
viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis)
viewHolder.setupActionListener(accountActionListener, accountList[position].id)
}
}

View File

@ -25,7 +25,7 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_follow_requests_header, parent, false) as TextView .inflate(R.layout.item_follow_requests_header, parent, false) as TextView
return HeaderViewHolder(view) return HeaderViewHolder(view)
} }
@ -34,7 +34,6 @@ class FollowRequestsHeaderAdapter(private val instanceName: String, private val
} }
override fun getItemCount() = if (accountLocked) 0 else 1 override fun getItemCount() = if (accountLocked) 0 else 1
} }
class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView)

View File

@ -15,7 +15,7 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView
class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

View File

@ -1,131 +0,0 @@
package com.keylesspalace.tusky.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import java.util.HashMap;
public class MutesAdapter extends AccountAdapter {
private HashMap<String, Boolean> mutingNotificationsMap;
public MutesAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
mutingNotificationsMap = new HashMap<String, Boolean>();
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_ACCOUNT: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_muted_user, parent, false);
return new MutesAdapter.MutedUserViewHolder(view);
}
case VIEW_TYPE_FOOTER: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_footer, parent, false);
return new LoadingFooterViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder;
Account account = accountList.get(position);
holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId()), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}
public void updateMutingNotifications(String id, boolean mutingNotifications, int position) {
mutingNotificationsMap.put(id, mutingNotifications);
notifyItemChanged(position);
}
public void updateMutingNotificationsMap(HashMap<String, Boolean> newMutingNotificationsMap) {
mutingNotificationsMap.putAll(newMutingNotificationsMap);
notifyDataSetChanged();
}
static class MutedUserViewHolder extends RecyclerView.ViewHolder {
private ImageView avatar;
private TextView username;
private TextView displayName;
private ImageButton unmute;
private ImageButton muteNotifications;
private String id;
private boolean notifications;
MutedUserViewHolder(View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.muted_user_avatar);
username = itemView.findViewById(R.id.muted_user_username);
displayName = itemView.findViewById(R.id.muted_user_display_name);
unmute = itemView.findViewById(R.id.muted_user_unmute);
muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications);
}
void setupWithAccount(Account account, Boolean mutingNotifications, boolean animateAvatar, boolean animateEmojis) {
id = account.getId();
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_48dp);
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
String unmuteString = unmute.getContext().getString(R.string.action_unmute_desc, formattedUsername);
unmute.setContentDescription(unmuteString);
ViewCompat.setTooltipText(unmute, unmuteString);
if (mutingNotifications == null) {
muteNotifications.setEnabled(false);
notifications = true;
} else {
muteNotifications.setEnabled(true);
notifications = mutingNotifications;
}
if (notifications) {
muteNotifications.setImageResource(R.drawable.ic_notifications_24dp);
String unmuteNotificationsString = muteNotifications.getContext()
.getString(R.string.action_unmute_notifications_desc, formattedUsername);
muteNotifications.setContentDescription(unmuteNotificationsString);
ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString);
} else {
muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp);
String muteNotificationsString = muteNotifications.getContext()
.getString(R.string.action_mute_notifications_desc, formattedUsername);
muteNotifications.setContentDescription(muteNotificationsString);
ViewCompat.setTooltipText(muteNotifications, muteNotificationsString);
}
}
void setupActionListener(final AccountActionListener listener) {
unmute.setOnClickListener(v -> listener.onMute(false, id, getBindingAdapterPosition(), false));
muteNotifications.setOnClickListener(
v -> listener.onMute(true, id, getBindingAdapterPosition(), !notifications));
itemView.setOnClickListener(v -> listener.onViewAccount(id));
}
}
}

View File

@ -0,0 +1,132 @@
package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import java.util.HashMap
/**
* Displays a list of muted accounts with mute/unmute account and mute/unmute notifications
* buttons.
* */
class MutesAdapter(
accountActionListener: AccountActionListener,
animateAvatar: Boolean,
animateEmojis: Boolean
) : AccountAdapter<MutesAdapter.MutedUserViewHolder>(
accountActionListener,
animateAvatar,
animateEmojis
) {
private val mutingNotificationsMap = HashMap<String, Boolean>()
override fun createAccountViewHolder(parent: ViewGroup): MutedUserViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_muted_user, parent, false)
return MutedUserViewHolder(view)
}
override fun onBindAccountViewHolder(viewHolder: MutedUserViewHolder, position: Int) {
val account = accountList[position]
viewHolder.setupWithAccount(
account,
mutingNotificationsMap[account.id],
animateAvatar,
animateEmojis
)
viewHolder.setupActionListener(accountActionListener)
}
fun updateMutingNotifications(id: String, mutingNotifications: Boolean, position: Int) {
mutingNotificationsMap[id] = mutingNotifications
notifyItemChanged(position)
}
fun updateMutingNotificationsMap(newMutingNotificationsMap: HashMap<String, Boolean>?) {
mutingNotificationsMap.putAll(newMutingNotificationsMap!!)
notifyDataSetChanged()
}
class MutedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val avatar: ImageView = itemView.findViewById(R.id.muted_user_avatar)
private val username: TextView = itemView.findViewById(R.id.muted_user_username)
private val displayName: TextView = itemView.findViewById(R.id.muted_user_display_name)
private val unmute: ImageButton = itemView.findViewById(R.id.muted_user_unmute)
private val muteNotifications: ImageButton =
itemView.findViewById(R.id.muted_user_mute_notifications)
private var id: String? = null
private var notifications = false
fun setupWithAccount(
account: Account,
mutingNotifications: Boolean?,
animateAvatar: Boolean,
animateEmojis: Boolean
) {
id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format)
val formattedUsername = String.format(format, account.username)
username.text = formattedUsername
val avatarRadius = avatar.context.resources
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar)
val unmuteString =
unmute.context.getString(R.string.action_unmute_desc, formattedUsername)
unmute.contentDescription = unmuteString
ViewCompat.setTooltipText(unmute, unmuteString)
if (mutingNotifications == null) {
muteNotifications.isEnabled = false
notifications = true
} else {
muteNotifications.isEnabled = true
notifications = mutingNotifications
}
if (notifications) {
muteNotifications.setImageResource(R.drawable.ic_notifications_24dp)
val unmuteNotificationsString = muteNotifications.context
.getString(R.string.action_unmute_notifications_desc, formattedUsername)
muteNotifications.contentDescription = unmuteNotificationsString
ViewCompat.setTooltipText(muteNotifications, unmuteNotificationsString)
} else {
muteNotifications.setImageResource(R.drawable.ic_notifications_off_24dp)
val muteNotificationsString = muteNotifications.context
.getString(R.string.action_mute_notifications_desc, formattedUsername)
muteNotifications.contentDescription = muteNotificationsString
ViewCompat.setTooltipText(muteNotifications, muteNotificationsString)
}
}
fun setupActionListener(listener: AccountActionListener) {
unmute.setOnClickListener {
listener.onMute(
false,
id,
bindingAdapterPosition,
false
)
}
muteNotifications.setOnClickListener {
listener.onMute(
true,
id,
bindingAdapterPosition,
!notifications
)
}
itemView.setOnClickListener { listener.onViewAccount(id) }
}
}
}

View File

@ -15,30 +15,28 @@
package com.keylesspalace.tusky.adapter package com.keylesspalace.tusky.adapter
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import android.view.ViewGroup
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.Status
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
class NetworkStateViewHolder(private val binding: ItemNetworkStateBinding, class NetworkStateViewHolder(
private val retryCallback: () -> Unit) private val binding: ItemNetworkStateBinding,
: RecyclerView.ViewHolder(binding.root) { private val retryCallback: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { fun setUpWithNetworkState(state: LoadState) {
binding.progressBar.visible(state?.status == Status.RUNNING) binding.progressBar.visible(state == LoadState.Loading)
binding.retryButton.visible(state?.status == Status.FAILED) binding.retryButton.visible(state is LoadState.Error)
binding.errorMsg.visible(state?.msg != null) val msg = if (state is LoadState.Error) {
binding.errorMsg.text = state?.msg state.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener { binding.retryButton.setOnClickListener {
retryCallback() retryCallback()
} }
if(fullScreen) {
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
} else {
binding.root.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
} }
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2017 Andrew Dawson /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -199,14 +199,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} else { } else {
holder.showNotificationContent(true); holder.showNotificationContent(true);
holder.setDisplayName(statusViewData.getUserFullName(), statusViewData.getAccountEmojis()); Status status = statusViewData.getActionable();
holder.setUsername(statusViewData.getNickname()); holder.setDisplayName(status.getAccount().getName(), status.getAccount().getEmojis());
holder.setCreatedAt(statusViewData.getCreatedAt()); holder.setUsername(status.getAccount().getUsername());
holder.setCreatedAt(status.getCreatedAt());
if(concreteNotificaton.getType() == Notification.Type.STATUS) { if (concreteNotificaton.getType() == Notification.Type.STATUS) {
holder.setAvatar(statusViewData.getAvatar(), statusViewData.isBot()); holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
} else { } else {
holder.setAvatars(statusViewData.getAvatar(), holder.setAvatars(status.getAccount().getAvatar(),
concreteNotificaton.getAccount().getAvatar()); concreteNotificaton.getAccount().getAvatar());
} }
} }
@ -219,7 +220,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
if (payloadForHolder instanceof List) if (payloadForHolder instanceof List)
for (Object item : (List) payloadForHolder) { for (Object item : (List) payloadForHolder) {
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) {
holder.setCreatedAt(statusViewData.getCreatedAt()); holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt());
} }
} }
} }
@ -539,7 +540,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
message.setText(emojifiedText); message.setText(emojifiedText);
if (statusViewData != null) { if (statusViewData != null) {
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText());
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) { if (statusViewData.isExpanded()) {
@ -594,7 +595,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
notificationAvatar.setVisibility(View.VISIBLE); notificationAvatar.setVisibility(View.VISIBLE);
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
avatarRadius24dp, statusDisplayOptions.animateAvatars()); avatarRadius24dp, statusDisplayOptions.animateAvatars());
} }
private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
@ -627,7 +628,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private void setupContentAndSpoiler(final LinkListener listener) { private void setupContentAndSpoiler(final LinkListener listener) {
boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); boolean shouldShowContentIfSpoiler = statusViewData.isExpanded();
boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getSpoilerText()); boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText());
if (!shouldShowContentIfSpoiler && hasSpoiler) { if (!shouldShowContentIfSpoiler && hasSpoiler) {
statusContent.setVisibility(View.GONE); statusContent.setVisibility(View.GONE);
} else { } else {
@ -635,7 +636,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
Spanned content = statusViewData.getContent(); Spanned content = statusViewData.getContent();
List<Emoji> emojis = statusViewData.getStatusEmojis(); List<Emoji> emojis = statusViewData.getActionable().getEmojis();
if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) {
contentCollapseButton.setOnClickListener(view -> { contentCollapseButton.setOnClickListener(view -> {
@ -661,17 +662,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis() content, emojis, statusContent, statusDisplayOptions.animateEmojis()
); );
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( CharSequence emojifiedContentWarning;
statusViewData.getSpoilerText(), if (statusViewData.getSpoilerText() != null) {
statusViewData.getStatusEmojis(), emojifiedContentWarning = CustomEmojiHelper.emojify(
contentWarningDescriptionTextView, statusViewData.getSpoilerText(),
statusDisplayOptions.animateEmojis() statusViewData.getActionable().getEmojis(),
); contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);
} else {
emojifiedContentWarning = "";
}
contentWarningDescriptionTextView.setText(emojifiedContentWarning); contentWarningDescriptionTextView.setText(emojifiedContentWarning);
setQuoteContainer(statusViewData.getQuote(), listener, statusDisplayOptions); setQuoteContainer(statusViewData.getStatus().getQuote(), listener, statusDisplayOptions);
} }
} }

View File

@ -1,49 +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.adapter;
import androidx.recyclerview.widget.RecyclerView;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
private Button loadMoreButton;
private ProgressBar progressBar;
PlaceholderViewHolder(View itemView) {
super(itemView);
loadMoreButton = itemView.findViewById(R.id.button_load_more);
progressBar = itemView.findViewById(R.id.progressBar);
}
public void setup(final StatusActionListener listener, boolean progress) {
loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE);
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
loadMoreButton.setEnabled(true);
loadMoreButton.setOnClickListener(v -> {
loadMoreButton.setEnabled(false);
listener.onLoadMore(getBindingAdapterPosition());
});
}
}

View File

@ -0,0 +1,41 @@
/* 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.View
import android.widget.Button
import android.widget.ProgressBar
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.StatusActionListener
/**
* Placeholder for different timelines.
* Either displays "load more" button or a progress indicator.
**/
class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val loadMoreButton: Button = itemView.findViewById(R.id.button_load_more)
private val progressBar: ProgressBar = itemView.findViewById(R.id.progressBar)
fun setup(listener: StatusActionListener, progress: Boolean) {
loadMoreButton.visibility = if (progress) View.GONE else View.VISIBLE
progressBar.visibility = if (progress) View.VISIBLE else View.GONE
loadMoreButton.isEnabled = true
loadMoreButton.setOnClickListener { v: View? ->
loadMoreButton.isEnabled = false
listener.onLoadMore(bindingAdapterPosition)
}
}
}

View File

@ -29,7 +29,7 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData
import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.buildDescription
import com.keylesspalace.tusky.viewdata.calculatePercent import com.keylesspalace.tusky.viewdata.calculatePercent
class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() { class PollAdapter : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var pollOptions: List<PollOptionViewData> = emptyList() private var pollOptions: List<PollOptionViewData> = emptyList()
private var voteCount: Int = 0 private var voteCount: Int = 0
@ -40,13 +40,14 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
private var animateEmojis = false private var animateEmojis = false
fun setup( fun setup(
options: List<PollOptionViewData>, options: List<PollOptionViewData>,
voteCount: Int, voteCount: Int,
votersCount: Int?, votersCount: Int?,
emojis: List<Emoji>, emojis: List<Emoji>,
mode: Int, mode: Int,
resultClickListener: View.OnClickListener?, resultClickListener: View.OnClickListener?,
animateEmojis: Boolean) { animateEmojis: Boolean
) {
this.pollOptions = options this.pollOptions = options
this.voteCount = voteCount this.voteCount = voteCount
this.votersCount = votersCount this.votersCount = votersCount
@ -57,12 +58,11 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
notifyDataSetChanged() notifyDataSetChanged()
} }
fun getSelected() : List<Int> { fun getSelected(): List<Int> {
return pollOptions.filter { it.selected } return pollOptions.filter { it.selected }
.map { pollOptions.indexOf(it) } .map { pollOptions.indexOf(it) }
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemPollBinding> {
val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding) return BindingHolder(binding)
@ -82,12 +82,12 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
radioButton.visible(mode == SINGLE) radioButton.visible(mode == SINGLE)
checkBox.visible(mode == MULTIPLE) checkBox.visible(mode == MULTIPLE)
when(mode) { when (mode) {
RESULT -> { RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount) val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, resultTextView.context) val emojifiedPollOptionText = buildDescription(option.title, percent, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis) .emojify(emojis, resultTextView, animateEmojis)
resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val level = percent * 100 val level = percent * 100
@ -114,7 +114,6 @@ class PollAdapter: RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
} }
} }
} }
} }
companion object { companion object {

View File

@ -23,7 +23,7 @@ import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
class PreviewPollOptionsAdapter: RecyclerView.Adapter<PreviewViewHolder>() { class PreviewPollOptionsAdapter : RecyclerView.Adapter<PreviewViewHolder>() {
private var options: List<String> = emptyList() private var options: List<String> = emptyList()
private var multiple: Boolean = false private var multiple: Boolean = false
@ -60,7 +60,6 @@ class PreviewPollOptionsAdapter: RecyclerView.Adapter<PreviewViewHolder>() {
textView.setOnClickListener(clickListener) textView.setOnClickListener(clickListener)
} }
} }
class PreviewViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) class PreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

View File

@ -1,122 +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.adapter;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.db.TootEntity;
import java.util.ArrayList;
import java.util.List;
public class SavedTootAdapter extends RecyclerView.Adapter {
private List<TootEntity> list;
private SavedTootAction handler;
public SavedTootAdapter(Context context) {
super();
list = new ArrayList<>();
handler = (SavedTootAction) context;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_saved_toot, parent, false);
return new TootViewHolder(view);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
TootViewHolder holder = (TootViewHolder) viewHolder;
holder.bind(getItem(position));
}
@Override
public int getItemCount() {
return list.size();
}
public void setItems(List<TootEntity> newToot) {
list = new ArrayList<>();
list.addAll(newToot);
}
public void addItems(List<TootEntity> newToot) {
int end = list.size();
list.addAll(newToot);
notifyItemRangeInserted(end, newToot.size());
}
@Nullable
public TootEntity removeItem(int position) {
if (position < 0 || position >= list.size()) {
return null;
}
TootEntity toot = list.remove(position);
notifyItemRemoved(position);
return toot;
}
private TootEntity getItem(int position) {
if (position >= 0 && position < list.size()) {
return list.get(position);
}
return null;
}
// handler saved toot
public interface SavedTootAction {
void delete(int position, TootEntity item);
void click(int position, TootEntity item);
}
private class TootViewHolder extends RecyclerView.ViewHolder {
View view;
TextView content;
ImageButton suppr;
TootViewHolder(View view) {
super(view);
this.view = view;
this.content = view.findViewById(R.id.content);
this.suppr = view.findViewById(R.id.suppr);
}
void bind(final TootEntity item) {
suppr.setEnabled(true);
if (item != null) {
content.setText(item.getText());
suppr.setOnClickListener(v -> {
v.setEnabled(false);
handler.delete(getBindingAdapterPosition(), item);
});
view.setOnClickListener(v -> handler.click(getBindingAdapterPosition(), item));
}
}
}
}

View File

@ -210,7 +210,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setSpoilerAndContent(boolean expanded, protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content, @NonNull Spanned content,
@Nullable String spoilerText, @Nullable String spoilerText,
@Nullable Status.Mention[] mentions, @Nullable List<Status.Mention> mentions,
@NonNull List<Emoji> emojis, @NonNull List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@ -252,7 +252,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setTextVisible(boolean sensitive, private void setTextVisible(boolean sensitive,
boolean expanded, boolean expanded,
Spanned content, Spanned content,
Status.Mention[] mentions, List<Status.Mention> mentions,
List<Emoji> emojis, List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@ -814,23 +814,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
this.setupWithStatus(status, listener, statusDisplayOptions, null); this.setupWithStatus(status, listener, statusDisplayOptions, null);
} }
protected void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions); Status actionable = status.getActionable();
setUsername(status.getNickname()); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setUsername(status.getUsername());
setStatusVisibility(status.getVisibility()); setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null); setStatusVisibility(actionable.getVisibility());
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions); setIsReply(actionable.getInReplyToId() != null);
setReblogged(status.isReblogged()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
setFavourited(status.isFavourited()); actionable.getAccount().getBot(), statusDisplayOptions);
setQuoteContainer(status.getQuote(), listener, statusDisplayOptions); setReblogged(actionable.getReblogged());
setBookmarked(status.isBookmarked()); setFavourited(actionable.getFavourited());
List<Attachment> attachments = status.getAttachments(); setQuoteContainer(actionable.getQuote(), listener, statusDisplayOptions);
boolean sensitive = status.isSensitive(); setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
boolean sensitive = actionable.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
@ -855,12 +857,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions); setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
} }
setupButtons(listener, status.getSenderId(), status.getContent().toString(), setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
status.isNotestock(), statusDisplayOptions); actionable.isNotestock(), statusDisplayOptions);
setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
setQuoteEnabled(status.getRebloggingEnabled() && !status.isNotestock(), status.getVisibility()); setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility());
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), status.getPoll(), statusDisplayOptions, listener); setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
actionable.getMentions(), actionable.getEmojis(),
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
listener);
setDescriptionForStatus(status, statusDisplayOptions); setDescriptionForStatus(status, statusDisplayOptions);
@ -874,7 +879,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (payloads instanceof List) if (payloads instanceof List)
for (Object item : (List<?>) payloads) { for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) { if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setCreatedAt(status.getActionable().getCreatedAt(), statusDisplayOptions);
} }
} }
@ -893,21 +898,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
Context context = itemView.getContext(); Context context = itemView.getContext();
Status actionable = status.getActionable();
String description = context.getString(R.string.description_status, String description = context.getString(R.string.description_status,
status.getUserFullName(), actionable.getAccount().getName(),
getContentWarningDescription(context, status), getContentWarningDescription(context, status),
(TextUtils.isEmpty(status.getSpoilerText()) || !status.isSensitive() || status.isExpanded() ? status.getContent() : ""), (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""),
getCreatedAtDescription(status.getCreatedAt(), statusDisplayOptions), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status), getReblogDescription(context, status),
status.getNickname(), status.getUsername(),
status.isReblogged() ? context.getString(R.string.description_status_reblogged) : "", actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "",
status.isFavourited() ? context.getString(R.string.description_status_favourited) : "", actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "",
status.isBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "",
getMediaDescription(context, status), getMediaDescription(context, status),
getVisibilityDescription(context, status.getVisibility()), getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, status.getFavouritesCount()), getFavsText(context, actionable.getFavouritesCount()),
getReblogsText(context, status.getReblogsCount()), getReblogsText(context, actionable.getReblogsCount()),
getPollDescription(status, context, statusDisplayOptions) getPollDescription(status, context, statusDisplayOptions)
); );
itemView.setContentDescription(description); itemView.setContentDescription(description);
@ -915,10 +921,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getReblogDescription(Context context, private static CharSequence getReblogDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
String rebloggedUsername = status.getRebloggedByUsername(); Status reblog = status.getRebloggingStatus();
if (rebloggedUsername != null) { if (reblog != null) {
return context return context
.getString(R.string.status_boosted_format, rebloggedUsername); .getString(R.string.status_boosted_format, reblog.getAccount().getUsername());
} else { } else {
return ""; return "";
} }
@ -926,11 +932,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private static CharSequence getMediaDescription(Context context, private static CharSequence getMediaDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
if (status.getAttachments().isEmpty()) { if (status.getActionable().getAttachments().isEmpty()) {
return ""; return "";
} }
StringBuilder mediaDescriptions = CollectionsKt.fold( StringBuilder mediaDescriptions = CollectionsKt.fold(
status.getAttachments(), status.getActionable().getAttachments(),
new StringBuilder(), new StringBuilder(),
(builder, a) -> { (builder, a) -> {
if (a.getDescription() == null) { if (a.getDescription() == null) {
@ -983,7 +989,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status,
Context context, Context context,
StatusDisplayOptions statusDisplayOptions) { StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = status.getPoll(); PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
if (poll == null) { if (poll == null) {
return ""; return "";
} else { } else {
@ -1089,7 +1095,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
Context context) { Context context) {
String votesText; String votesText;
if(poll.getVotersCount() == null) { if (poll.getVotersCount() == null) {
String voters = numberFormat.format(poll.getVotesCount()); String voters = numberFormat.format(poll.getVotesCount());
votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters);
} else { } else {
@ -1113,12 +1119,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) { protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
final Card card = status.getActionable().getCard();
if (cardViewMode != CardViewMode.NONE && if (cardViewMode != CardViewMode.NONE &&
status.getAttachments().size() == 0 && status.getActionable().getAttachments().size() == 0 &&
status.getCard() != null && card != null &&
!TextUtils.isEmpty(status.getCard().getUrl()) && !TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) { (!status.isCollapsible() || !status.isCollapsed())) {
final Card card = status.getCard();
cardView.setVisibility(View.VISIBLE); cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle()); cardTitle.setText(card.getTitle());
if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) {
@ -1137,7 +1143,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Statuses from other activitypub sources can be marked sensitive even if there's no media, // Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case // so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well // If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) { if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0; int topLeftRadius = 0;
int topRightRadius = 0; int topRightRadius = 0;

View File

@ -105,7 +105,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
protected void setupWithStatus(final StatusViewData.Concrete status, public void setupWithStatus(final StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
@ -114,12 +114,13 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
if (payloads == null) { if (payloads == null) {
if (!statusDisplayOptions.hideStats()) { if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); setReblogAndFavCount(status.getActionable().getReblogsCount(),
status.getActionable().getFavouritesCount(), listener);
} else { } else {
hideQuantitativeStats(); hideQuantitativeStats();
} }
setApplication(status.getApplication()); setApplication(status.getActionable().getApplication());
View.OnLongClickListener longClickListener = view -> { View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView) view; TextView textView = (TextView) view;

View File

@ -26,6 +26,8 @@ import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.SmartLengthInputFilter;
@ -33,6 +35,8 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
public class StatusViewHolder extends StatusBaseViewHolder { public class StatusViewHolder extends StatusBaseViewHolder {
@ -54,19 +58,21 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
@Override @Override
protected void setupWithStatus(StatusViewData.Concrete status, public void setupWithStatus(StatusViewData.Concrete status,
final StatusActionListener listener, final StatusActionListener listener,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
setupCollapsedState(status, listener); setupCollapsedState(status, listener);
String rebloggedByDisplayName = status.getRebloggedByUsername(); Status reblogging = status.getRebloggingStatus();
if (rebloggedByDisplayName == null) { if (reblogging == null) {
hideStatusInfo(); hideStatusInfo();
} else { } else {
setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions); String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
} }
@ -76,13 +82,13 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
private void setRebloggedByDisplayName(final CharSequence name, private void setRebloggedByDisplayName(final CharSequence name,
final StatusViewData.Concrete status, final List<Emoji> accountEmoji,
final StatusDisplayOptions statusDisplayOptions) { final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis() boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
); );
statusInfo.setText(emojifiedText); statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE); statusInfo.setVisibility(View.VISIBLE);

View File

@ -43,10 +43,11 @@ interface ItemInteractionListener {
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
} }
class TabAdapter(private var data: List<TabData>, class TabAdapter(
private val small: Boolean, private var data: List<TabData>,
private val listener: ItemInteractionListener, private val small: Boolean,
private var removeButtonEnabled: Boolean = false private val listener: ItemInteractionListener,
private var removeButtonEnabled: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ViewBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ViewBinding>>() {
fun updateData(newData: List<TabData>) { fun updateData(newData: List<TabData>) {
@ -77,7 +78,6 @@ class TabAdapter(private var data: List<TabData>,
binding.textView.setOnClickListener { binding.textView.setOnClickListener {
listener.onTabAdded(tab) listener.onTabAdded(tab)
} }
} else { } else {
val binding = holder.binding as ItemTabPreferenceBinding val binding = holder.binding as ItemTabPreferenceBinding
@ -102,9 +102,9 @@ class TabAdapter(private var data: List<TabData>,
} }
binding.removeButton.isEnabled = removeButtonEnabled binding.removeButton.isEnabled = removeButtonEnabled
ThemeUtils.setDrawableTint( ThemeUtils.setDrawableTint(
holder.itemView.context, holder.itemView.context,
binding.removeButton.drawable, binding.removeButton.drawable,
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled)
) )
if (tab.id == HASHTAG) { if (tab.id == HASHTAG) {
@ -118,14 +118,14 @@ class TabAdapter(private var data: List<TabData>,
tab.arguments.forEachIndexed { i, arg -> tab.arguments.forEachIndexed { i, arg ->
val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply { ?: Chip(context).apply {
binding.chipGroup.addView(this, binding.chipGroup.size - 1) binding.chipGroup.addView(this, binding.chipGroup.size - 1)
chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary)) chipIconTint = ColorStateList.valueOf(ThemeUtils.getColor(context, android.R.attr.textColorPrimary))
} }
chip.text = arg chip.text = arg
if(tab.arguments.size <= 1) { if (tab.arguments.size <= 1) {
chip.chipIcon = null chip.chipIcon = null
chip.setOnClickListener(null) chip.setOnClickListener(null)
} else { } else {
@ -136,14 +136,13 @@ class TabAdapter(private var data: List<TabData>,
} }
} }
while(binding.chipGroup.size - 1 > tab.arguments.size) { while (binding.chipGroup.size - 1 > tab.arguments.size) {
binding.chipGroup.removeViewAt(tab.arguments.size) binding.chipGroup.removeViewAt(tab.arguments.size)
} }
binding.actionChip.setOnClickListener { binding.actionChip.setOnClickListener {
listener.onActionChipClicked(tab, holder.bindingAdapterPosition) listener.onActionChipClicked(tab, holder.bindingAdapterPosition)
} }
} else { } else {
binding.chipGroup.hide() binding.chipGroup.hide()
} }

View File

@ -1,164 +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.adapter;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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;
import java.util.ArrayList;
import java.util.List;
public class ThreadAdapter extends RecyclerView.Adapter {
private static final int VIEW_TYPE_STATUS = 0;
private static final int VIEW_TYPE_STATUS_DETAILED = 1;
private List<StatusViewData.Concrete> statuses;
private StatusDisplayOptions statusDisplayOptions;
private StatusActionListener statusActionListener;
private int detailedStatusPosition;
public ThreadAdapter(StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
this.statusDisplayOptions = statusDisplayOptions;
this.statusActionListener = listener;
this.statuses = new ArrayList<>();
detailedStatusPosition = RecyclerView.NO_POSITION;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
default:
case VIEW_TYPE_STATUS: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status, parent, false);
return new StatusViewHolder(view);
}
case VIEW_TYPE_STATUS_DETAILED: {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_status_detailed, parent, false);
return new StatusDetailedViewHolder(view);
}
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
StatusViewData.Concrete status = statuses.get(position);
if (position == detailedStatusPosition) {
StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder;
holder.setupWithStatus(status, statusActionListener, statusDisplayOptions);
} else {
StatusViewHolder holder = (StatusViewHolder) viewHolder;
holder.setupWithStatus(status, statusActionListener, statusDisplayOptions);
}
}
@Override
public int getItemViewType(int position) {
if (position == detailedStatusPosition) {
return VIEW_TYPE_STATUS_DETAILED;
} else {
return VIEW_TYPE_STATUS;
}
}
@Override
public int getItemCount() {
return statuses.size();
}
public void setStatuses(List<StatusViewData.Concrete> statuses) {
this.statuses.clear();
this.statuses.addAll(statuses);
notifyDataSetChanged();
}
public void addItem(int position, StatusViewData.Concrete statusViewData) {
statuses.add(position, statusViewData);
notifyItemInserted(position);
}
public void clearItems() {
int oldSize = statuses.size();
statuses.clear();
detailedStatusPosition = RecyclerView.NO_POSITION;
notifyItemRangeRemoved(0, oldSize);
}
public void addAll(int position, List<StatusViewData.Concrete> statuses) {
this.statuses.addAll(position, statuses);
notifyItemRangeInserted(position, statuses.size());
}
public void addAll(List<StatusViewData.Concrete> statuses) {
int end = statuses.size();
this.statuses.addAll(statuses);
notifyItemRangeInserted(end, statuses.size());
}
public void removeItem(int position) {
statuses.remove(position);
notifyItemRemoved(position);
}
public void clear() {
statuses.clear();
detailedStatusPosition = RecyclerView.NO_POSITION;
notifyDataSetChanged();
}
public void setItem(int position, StatusViewData.Concrete status, boolean notifyAdapter) {
statuses.set(position, status);
if (notifyAdapter) {
notifyItemChanged(position);
}
}
@Nullable
public StatusViewData.Concrete getItem(int position) {
if (position >= 0 && position < statuses.size()) {
return statuses.get(position);
} else {
return null;
}
}
public void setDetailedStatusPosition(int position) {
if (position != detailedStatusPosition
&& detailedStatusPosition != RecyclerView.NO_POSITION) {
int prior = detailedStatusPosition;
detailedStatusPosition = position;
notifyItemChanged(prior);
} else {
detailedStatusPosition = position;
}
}
public int getDetailedStatusPosition() {
return detailedStatusPosition;
}
}

View File

@ -0,0 +1,129 @@
/* 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

@ -3,16 +3,16 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject import javax.inject.Inject
class CacheUpdater @Inject constructor( class CacheUpdater @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager, accountManager: AccountManager,
private val appDatabase: AppDatabase, private val appDatabase: AppDatabase,
gson: Gson gson: Gson
) { ) {
private val disposable: Disposable private val disposable: Disposable
@ -27,7 +27,7 @@ class CacheUpdater @Inject constructor(
is ReblogEvent -> is ReblogEvent ->
timelineDao.setReblogged(accountId, event.statusId, event.reblog) timelineDao.setReblogged(accountId, event.statusId, event.reblog)
is BookmarkEvent -> is BookmarkEvent ->
timelineDao.setBookmarked(accountId, event.statusId, event.bookmark ) timelineDao.setBookmarked(accountId, event.statusId, event.bookmark)
is UnfollowEvent -> is UnfollowEvent ->
timelineDao.removeAllByUser(accountId, event.accountId) timelineDao.removeAllByUser(accountId, event.accountId)
is StatusDeletedEvent -> is StatusDeletedEvent ->
@ -49,7 +49,7 @@ class CacheUpdater @Inject constructor(
appDatabase.timelineDao().removeAllForAccount(accountId) appDatabase.timelineDao().removeAllForAccount(accountId)
appDatabase.timelineDao().removeAllUsersForAccount(accountId) appDatabase.timelineDao().removeAllUsersForAccount(accountId)
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
} }

View File

@ -1,10 +1,10 @@
package com.keylesspalace.tusky.appstore package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.timeline.TimelineViewModel
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.fragment.TimelineFragment
data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Dispatchable
data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable data class ReblogEvent(val statusId: String, val reblog: Boolean) : Dispatchable
@ -20,7 +20,8 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable data class DomainMuteEvent(val instance: String) : Dispatchable
data class AnnouncementReadEvent(val announcementId: String): Dispatchable data class AnnouncementReadEvent(val announcementId: String) : Dispatchable
data class PinEvent(val statusId: String, val pinned: Boolean) : Dispatchable
data class QuickReplyEvent(val status: Status) : Dispatchable data class QuickReplyEvent(val status: Status) : Dispatchable
data class StreamUpdateEvent(val status: Status, val targetKind: TimelineFragment.Kind, val targetIdentifier: String?, val first: Boolean) : Dispatchable data class StreamUpdateEvent(val status: Status, val targetKind: TimelineViewModel.Kind, val targetIdentifier: String?) : Dispatchable

View File

@ -1,7 +1,7 @@
package com.keylesspalace.tusky.appstore package com.keylesspalace.tusky.appstore
import io.reactivex.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
interface Event interface Event
interface Dispatchable : Event interface Dispatchable : Event
@ -19,4 +19,4 @@ object EventHubImpl : EventHub {
override fun dispatch(event: Dispatchable) { override fun dispatch(event: Dispatchable) {
eventsSubject.onNext(event) eventsSubject.onNext(event)
} }
} }

View File

@ -31,17 +31,17 @@ import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
interface AnnouncementActionListener: LinkListener { interface AnnouncementActionListener : LinkListener {
fun openReactionPicker(announcementId: String, target: View) fun openReactionPicker(announcementId: String, target: View)
fun addReaction(announcementId: String, name: String) fun addReaction(announcementId: String, name: String)
fun removeReaction(announcementId: String, name: String) fun removeReaction(announcementId: String, name: String)
} }
class AnnouncementAdapter( class AnnouncementAdapter(
private var items: List<Announcement> = emptyList(), private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener, private val listener: AnnouncementActionListener,
private val wellbeingEnabled: Boolean = false, private val wellbeingEnabled: Boolean = false,
private val animateEmojis: Boolean = false private val animateEmojis: Boolean = false
) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemAnnouncementBinding>>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemAnnouncementBinding> {
@ -67,12 +67,12 @@ class AnnouncementAdapter(
} }
item.reactions.forEachIndexed { i, reaction -> item.reactions.forEachIndexed { i, reaction ->
(chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true isCheckable = true
checkedIcon = null checkedIcon = null
chips.addView(this, i) chips.addView(this, i)
}) }
.apply { .apply {
val emojiText = if (reaction.url == null) { val emojiText = if (reaction.url == null) {
reaction.name reaction.name
@ -80,16 +80,18 @@ class AnnouncementAdapter(
context.getString(R.string.emoji_shortcode_format, reaction.name) context.getString(R.string.emoji_shortcode_format, reaction.name)
} }
this.text = ("$emojiText ${reaction.count}") this.text = ("$emojiText ${reaction.count}")
.emojify( .emojify(
listOf(Emoji( listOf(
reaction.name, Emoji(
reaction.url ?: "", reaction.name,
reaction.staticUrl ?: "", reaction.url ?: "",
null reaction.staticUrl ?: "",
)), null
this, )
animateEmojis ),
) this,
animateEmojis
)
isChecked = reaction.me isChecked = reaction.me

View File

@ -34,7 +34,12 @@ import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EmojiPicker import com.keylesspalace.tusky.view.EmojiPicker
import javax.inject.Inject import javax.inject.Inject
@ -52,13 +57,13 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
private val picker by lazy { EmojiPicker(this) } private val picker by lazy { EmojiPicker(this) }
private val pickerDialog by lazy { private val pickerDialog by lazy {
PopupWindow(this) PopupWindow(this)
.apply { .apply {
contentView = picker contentView = picker
isFocusable = true isFocusable = true
setOnDismissListener { setOnDismissListener {
currentAnnouncementId = null currentAnnouncementId = null
}
} }
}
} }
private var currentAnnouncementId: String? = null private var currentAnnouncementId: String? = null

View File

@ -27,15 +27,20 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either
import io.reactivex.rxkotlin.Singles import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single
import javax.inject.Inject import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor( class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager, accountManager: AccountManager,
private val appDatabase: AppDatabase, private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub private val eventHub: EventHub
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>() private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
@ -45,140 +50,153 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable val emojis: LiveData<List<Emoji>> = emojisMutable
init { init {
Singles.zip( Single.zip(
mastodonApi.getCustomEmojis(), mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) } .map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext( .onErrorResumeNext {
mastodonApi.getInstance() mastodonApi.getInstance()
.map { Either.Right(it) } .map { Either.Right(it) }
) },
) { emojis, either -> { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis) either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity( ?: InstanceEntity(
accountManager.activeAccount?.domain!!, accountManager.activeAccount?.domain!!,
emojis, emojis,
either.asRight().maxTootChars, either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions, either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars, either.asRight().pollLimits?.maxOptionChars,
either.asRight().version either.asRight().version
) )
} }
.doOnSuccess { )
appDatabase.instanceDao().insertOrReplace(it) .doOnSuccess {
} appDatabase.instanceDao().insertOrReplace(it)
.subscribe({ }
emojisMutable.postValue(it.emojiList) .subscribe(
}, { {
emojisMutable.postValue(it.emojiList.orEmpty())
},
{
Log.w(TAG, "Failed to get custom emojis.", it) Log.w(TAG, "Failed to get custom emojis.", it)
}) }
.autoDispose() )
.autoDispose()
} }
fun load() { fun load() {
announcementsMutable.postValue(Loading()) announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements() mastodonApi.listAnnouncements()
.subscribe({ .subscribe(
{
announcementsMutable.postValue(Success(it)) announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read } it.filter { announcement -> !announcement.read }
.forEach { announcement -> .forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id) mastodonApi.dismissAnnouncement(announcement.id)
.subscribe( .subscribe(
{ {
eventHub.dispatch(AnnouncementReadEvent(announcement.id)) eventHub.dispatch(AnnouncementReadEvent(announcement.id))
}, },
{ throwable -> { throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable) Log.d(TAG, "Failed to mark announcement as read.", throwable)
} }
) )
.autoDispose() .autoDispose()
} }
}, { },
{
announcementsMutable.postValue(Error(cause = it)) announcementsMutable.postValue(Error(cause = it))
}) }
.autoDispose() )
.autoDispose()
} }
fun addReaction(announcementId: String, name: String) { fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name) mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe({ .subscribe(
{
announcementsMutable.postValue( announcementsMutable.postValue(
Success( Success(
announcements.value!!.data!!.map { announcement -> announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) { if (announcement.id == announcementId) {
announcement.copy( announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction -> announcement.reactions.map { reaction ->
if (reaction.name == name) { if (reaction.name == name) {
reaction.copy( reaction.copy(
count = reaction.count + 1, count = reaction.count + 1,
me = true me = true
) )
} else { } else {
reaction reaction
} }
} }
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else { } else {
announcement listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
} }
} )
) } else {
announcement
}
}
)
) )
}, { },
{
Log.w(TAG, "Failed to add reaction to the announcement.", it) Log.w(TAG, "Failed to add reaction to the announcement.", it)
}) }
.autoDispose() )
.autoDispose()
} }
fun removeReaction(announcementId: String, name: String) { fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name) mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe({ .subscribe(
{
announcementsMutable.postValue( announcementsMutable.postValue(
Success( Success(
announcements.value!!.data!!.map { announcement -> announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) { if (announcement.id == announcementId) {
announcement.copy( announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction -> reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) { if (reaction.name == name) {
if (reaction.count > 1) { if (reaction.count > 1) {
reaction.copy( reaction.copy(
count = reaction.count - 1, count = reaction.count - 1,
me = false me = false
) )
} else { } else {
null null
} }
} else { } else {
reaction reaction
} }
}
)
} else {
announcement
} }
} )
) } else {
announcement
}
}
)
) )
}, { },
{
Log.w(TAG, "Failed to remove reaction from the announcement.", it) Log.w(TAG, "Failed to remove reaction from the announcement.", it)
}) }
.autoDispose() )
.autoDispose()
} }
companion object { companion object {

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -25,18 +24,20 @@ import android.content.pm.PackageManager
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -84,18 +85,19 @@ import com.mikepenz.iconics.utils.sizeDp
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Locale import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class ComposeActivity : BaseActivity(), class ComposeActivity :
ComposeOptionsListener, BaseActivity(),
ComposeAutoCompleteAdapter.AutocompletionProvider, ComposeOptionsListener,
OnEmojiSelectedListener, ComposeAutoCompleteAdapter.AutocompletionProvider,
Injectable, OnEmojiSelectedListener,
InputConnectionCompat.OnCommitContentListener, Injectable,
ComposeScheduleView.OnTimeSetListener { InputConnectionCompat.OnCommitContentListener,
ComposeScheduleView.OnTimeSetListener {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -121,6 +123,21 @@ class ComposeActivity : BaseActivity(),
private val maxUploadMediaNumber = 4 private val maxUploadMediaNumber = 4
private var mediaCount = 0 private var mediaCount = 0
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pickMedia(photoUploadUri!!)
}
}
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
if (mediaCount + 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 ->
pickMedia(uri)
}
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -137,16 +154,16 @@ class ComposeActivity : BaseActivity(),
setupAvatar(preferences, activeAccount) setupAvatar(preferences, activeAccount)
val mediaAdapter = MediaPreviewAdapter( val mediaAdapter = MediaPreviewAdapter(
this, this,
onAddCaption = { item -> onAddCaption = { item ->
makeCaptionDialog(item.description, item.uri) { newDescription -> makeCaptionDialog(item.description, item.uri) { newDescription ->
viewModel.updateDescription(item.localId, newDescription) viewModel.updateDescription(item.localId, newDescription)
} }
}, },
onRemove = this::removeMediaFromQueue onRemove = this::removeMediaFromQueue
) )
binding.composeMediaPreviewBar.layoutManager = binding.composeMediaPreviewBar.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null binding.composeMediaPreviewBar.itemAnimator = null
@ -168,11 +185,7 @@ class ComposeActivity : BaseActivity(),
binding.composeEditField.setText(tootText) binding.composeEditField.setText(tootText)
} }
if (loadInstanceData(preferences, composeOptions?.tootRightNow == true)) { viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true))
viewModel.loadInstanceDataFromNetwork()
} else {
viewModel.loadInstanceDataFromCache()
}
if (!composeOptions?.scheduledAt.isNullOrEmpty()) { if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
@ -314,11 +327,11 @@ class ComposeActivity : BaseActivity(),
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
binding.composeEditField.setAdapter( binding.composeEditField.setAdapter(
ComposeAutoCompleteAdapter( ComposeAutoCompleteAdapter(
this, this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
) )
) )
binding.composeEditField.setTokenizer(ComposeTokenizer()) binding.composeEditField.setTokenizer(ComposeTokenizer())
@ -333,8 +346,9 @@ class ComposeActivity : BaseActivity(),
} }
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093 // work around Android platform bug -> https://issuetracker.google.com/issues/67102093
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O ||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) { Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1
) {
binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
} }
} }
@ -375,9 +389,9 @@ class ComposeActivity : BaseActivity(),
updateScheduleButton() updateScheduleButton()
} }
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll -> combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
val active = poll == null val active = poll == null &&
&& media!!.size != 4 media!!.size != 4 &&
&& (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
enableButton(binding.composeAddMediaButton, active, active) enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty()) enablePollButton(media.isNullOrEmpty())
}.subscribe() }.subscribe()
@ -457,7 +471,6 @@ class ComposeActivity : BaseActivity(),
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
setHomeAsUpIndicator(R.drawable.ic_close_24dp) setHomeAsUpIndicator(R.drawable.ic_close_24dp)
} }
} }
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) { private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
@ -468,13 +481,15 @@ class ComposeActivity : BaseActivity(),
val animateAvatars = preferences.getBoolean("animateGifAvatars", false) val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
loadAvatar( loadAvatar(
activeAccount.profilePictureUrl, activeAccount.profilePictureUrl,
binding.composeAvatar, binding.composeAvatar,
avatarSize / 8, avatarSize / 8,
animateAvatars animateAvatars
)
binding.composeAvatar.contentDescription = getString(
R.string.compose_active_account_description,
activeAccount.fullName
) )
binding.composeAvatar.contentDescription = getString(R.string.compose_active_account_description,
activeAccount.fullName)
} }
private fun replaceTextAtCaret(text: CharSequence) { private fun replaceTextAtCaret(text: CharSequence) {
@ -532,7 +547,6 @@ class ComposeActivity : BaseActivity(),
} }
} }
private fun atButtonClicked() { private fun atButtonClicked() {
prependSelectedWordsWith("@") prependSelectedWordsWith("@")
} }
@ -548,7 +562,7 @@ class ComposeActivity : BaseActivity(),
private fun displayTransientError(@StringRes stringId: Int) { private fun displayTransientError(@StringRes stringId: Int) {
val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG) val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG)
//necessary so snackbar is shown over everything // necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show() bar.show()
} }
@ -566,7 +580,6 @@ class ComposeActivity : BaseActivity(),
binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
binding.composeHideMediaButton.isClickable = false binding.composeHideMediaButton.isClickable = false
ContextCompat.getColor(this, R.color.transparent_tusky_blue) ContextCompat.getColor(this, R.color.transparent_tusky_blue)
} else { } else {
binding.composeHideMediaButton.isClickable = true binding.composeHideMediaButton.isClickable = true
if (markMediaSensitive) { if (markMediaSensitive) {
@ -676,15 +689,17 @@ class ComposeActivity : BaseActivity(),
private fun onMediaPick() { private fun onMediaPick() {
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
//Wait until bottom sheet is not collapsed and show next screen after // Wait until bottom sheet is not collapsed and show next screen after
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
addMediaBehavior.removeBottomSheetCallback(this) addMediaBehavior.removeBottomSheetCallback(this)
if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this@ComposeActivity, ActivityCompat.requestPermissions(
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), this@ComposeActivity,
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE
)
} else { } else {
initiateMediaPicking() pickMediaFile.launch(true)
} }
} }
} }
@ -698,8 +713,10 @@ class ComposeActivity : BaseActivity(),
private fun openPollDialog() { private fun openPollDialog() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceParams.value!! val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions, showAddPollDialog(
instanceParams.pollMaxLength, viewModel::updatePoll) this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll
)
} }
private fun setupPollView() { private fun setupPollView() {
@ -826,35 +843,40 @@ class ComposeActivity : BaseActivity(),
if (viewModel.media.value!!.isNotEmpty()) { if (viewModel.media.value!!.isNotEmpty()) {
finishingUploadDialog = ProgressDialog.show( finishingUploadDialog = ProgressDialog.show(
this, getString(R.string.dialog_title_finishing_media_upload), this, getString(R.string.dialog_title_finishing_media_upload),
getString(R.string.dialog_message_uploading_media), true, true) getString(R.string.dialog_message_uploading_media), true, true
)
} }
viewModel.sendStatus(contentText, spoilerText).observe(this, { viewModel.sendStatus(contentText, spoilerText).observe(
finishingUploadDialog?.dismiss() this,
deleteDraftAndFinish() {
}) finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
}
)
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true)
} }
} }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking() pickMediaFile.launch(true)
} else { } else {
val bar = Snackbar.make(binding.activityCompose, R.string.error_media_upload_permission, Snackbar.make(
Snackbar.LENGTH_SHORT).apply { binding.activityCompose, R.string.error_media_upload_permission,
Snackbar.LENGTH_SHORT
).apply {
setAction(R.string.action_retry) { onMediaPick() }
// necessary so snackbar is shown over everything
view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
show()
} }
bar.setAction(R.string.action_retry) { onMediaPick() }
//necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.show()
} }
} }
} }
@ -862,50 +884,38 @@ class ComposeActivity : BaseActivity(),
private fun initiateCameraApp() { private fun initiateCameraApp() {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
// We don't need to ask for permission in this case, because the used calls require val photoFile: File = try {
// android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was createNewImageFile(this)
// way before permission dialogues have been introduced. } catch (ex: IOException) {
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) displayTransientError(R.string.error_media_upload_opening)
if (intent.resolveActivity(packageManager) != null) { return
val photoFile: File = try {
createNewImageFile(this)
} catch (ex: IOException) {
displayTransientError(R.string.error_media_upload_opening)
return
}
// Continue only if the File was successfully created
photoUploadUri = FileProvider.getUriForFile(this,
BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile)
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
} }
}
private fun initiateMediaPicking() { // Continue only if the File was successfully created
val intent = Intent(Intent.ACTION_GET_CONTENT) photoUploadUri = FileProvider.getUriForFile(
intent.addCategory(Intent.CATEGORY_OPENABLE) this,
BuildConfig.APPLICATION_ID + ".fileprovider",
val mimeTypes = arrayOf("image/*", "video/*", "audio/*") photoFile
intent.type = "*/*" )
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) takePicture.launch(photoUploadUri)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
startActivityForResult(intent, MEDIA_PICK_RESULT)
} }
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
button.isEnabled = clickable button.isEnabled = clickable
ThemeUtils.setDrawableTint(this, button.drawable, ThemeUtils.setDrawableTint(
if (colorActive) android.R.attr.textColorTertiary this, button.drawable,
else R.attr.textColorDisabled) if (colorActive) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
)
} }
private fun enablePollButton(enable: Boolean) { private fun enablePollButton(enable: Boolean) {
binding.addPollTextActionTextView.isEnabled = enable binding.addPollTextActionTextView.isEnabled = enable
val textColor = ThemeUtils.getColor(this, val textColor = ThemeUtils.getColor(
if (enable) android.R.attr.textColorTertiary this,
else R.attr.textColorDisabled) if (enable) android.R.attr.textColorTertiary
else R.attr.textColorDisabled
)
binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.setTextColor(textColor)
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
} }
@ -914,31 +924,6 @@ class ComposeActivity : BaseActivity(),
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
if (intent.data != null) {
// Single media, upload it and done.
pickMedia(intent.data!!)
} else if (intent.clipData != null) {
val clipData = intent.clipData!!
val count = clipData.itemCount
if (mediaCount + count > maxUploadMediaNumber) {
// check if exist media + upcoming media > 4, then prob error message.
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
} else {
// if not grater then 4, upload all multiple media.
for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
}
}
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!)
}
}
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) { private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) {
withLifecycleContext { withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).observe { exceptionOrItem ->
@ -962,7 +947,6 @@ class ComposeActivity : BaseActivity(),
} }
displayTransientError(errorId) displayTransientError(errorId)
} }
} }
} }
} }
@ -994,9 +978,10 @@ class ComposeActivity : BaseActivity(),
override fun onBackPressed() { override fun onBackPressed() {
// Acting like a teen: deliberately ignoring parent. // Acting like a teen: deliberately ignoring parent.
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) { scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED
) {
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
@ -1031,12 +1016,12 @@ class ComposeActivity : BaseActivity(),
val contentWarning = binding.composeContentWarningField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString()
if (viewModel.didChange(contentText, contentWarning)) { if (viewModel.didChange(contentText, contentWarning)) {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setMessage(R.string.compose_save_draft) .setMessage(R.string.compose_save_draft)
.setPositiveButton(R.string.action_save) { _, _ -> .setPositiveButton(R.string.action_save) { _, _ ->
saveDraftAndFinish(contentText, contentWarning) saveDraftAndFinish(contentText, contentWarning)
} }
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() } .setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
.show() .show()
} else { } else {
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
} }
@ -1068,13 +1053,13 @@ class ComposeActivity : BaseActivity(),
} }
data class QueuedMedia( data class QueuedMedia(
val localId: Long, val localId: Long,
val uri: Uri, val uri: Uri,
val type: Type, val type: Type,
val mediaSize: Long, val mediaSize: Long,
val uploadPercent: Int = 0, val uploadPercent: Int = 0,
val id: String? = null, val id: String? = null,
val description: String? = null val description: String? = null
) { ) {
enum class Type { enum class Type {
IMAGE, VIDEO, AUDIO; IMAGE, VIDEO, AUDIO;
@ -1099,7 +1084,6 @@ class ComposeActivity : BaseActivity(),
data class ComposeOptions( data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null, var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null, var draftId: Int? = null,
var tootText: String? = null, var tootText: String? = null,
var mediaUrls: List<String>? = null, var mediaUrls: List<String>? = null,
@ -1125,8 +1109,6 @@ class ComposeActivity : BaseActivity(),
companion object { companion object {
private const val TAG = "ComposeActivity" // logging tag private const val TAG = "ComposeActivity" // logging tag
private const val MEDIA_PICK_RESULT = 1
private const val MEDIA_TAKE_PHOTO_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"

View File

@ -21,38 +21,48 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.* 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.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.service.TootToSend
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.Either
import io.reactivex.Observable.just import com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.disposables.Disposable import com.keylesspalace.tusky.util.VersionUtils
import io.reactivex.rxkotlin.Singles import com.keylesspalace.tusky.util.combineLiveData
import java.util.* import com.keylesspalace.tusky.util.filter
import com.keylesspalace.tusky.util.map
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData
import com.keylesspalace.tusky.util.withoutFirstWhich
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
class ComposeViewModel @Inject constructor( class ComposeViewModel @Inject constructor(
private val api: MastodonApi, private val api: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper, private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper, private val db: AppDatabase
private val db: AppDatabase
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private var replyingStatusAuthor: String? = null private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null private var replyingStatusContent: String? = null
internal var startingText: String? = null internal var startingText: String? = null
private var savedTootUid: Int = 0
private var draftId: Int = 0 private var draftId: Int = 0
private var scheduledTootId: String? = null private var scheduledTootId: String? = null
private var startingContentWarning: String = "" private var startingContentWarning: String = ""
@ -69,15 +79,15 @@ class ComposeViewModel @Inject constructor(
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance -> val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams( ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
) )
} }
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData() val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive = val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false) val showContentWarning = mutableLiveData(false)
@ -94,44 +104,40 @@ class ComposeViewModel @Inject constructor(
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
fun loadInstanceDataFromNetwork() { fun loadInstanceDataFromNetwork(loadActually: Boolean) {
when (loadActually) {
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance -> true -> Single.zip(
InstanceEntity( api.getCustomEmojis(), api.getInstance(),
instance = domain, { emojis, instance ->
emojiList = emojis, InstanceEntity(
maximumTootCharacters = instance.maxTootChars, instance = domain,
maxPollOptions = instance.pollLimits?.maxOptions, emojiList = emojis,
maxPollOptionLength = instance.pollLimits?.maxOptionChars, maximumTootCharacters = instance.maxTootChars,
version = instance.version maxPollOptions = instance.pollLimits?.maxOptions,
) maxPollOptionLength = instance.pollLimits?.maxOptionChars,
} version = instance.version
.doOnSuccess { )
db.instanceDao().insertOrReplace(it)
} }
.onErrorResumeNext( )
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) false -> Single.error(Exception("skipped network access"))
) }
.subscribe ({ instanceEntity -> .doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
}
.subscribe(
{ instanceEntity ->
emoji.postValue(instanceEntity.emojiList) emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity) instance.postValue(instanceEntity)
}, { throwable -> },
{ throwable ->
// this can happen on network error when no cached data is available // this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable) Log.w(TAG, "error loading instance data", throwable)
}) }
.autoDispose() )
} .autoDispose()
fun loadInstanceDataFromCache() {
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.subscribe ({ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
}, { throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
})
.autoDispose()
} }
fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> { fun pickMedia(uri: Uri, description: String? = null): LiveData<Either<Throwable, QueuedMedia>> {
@ -139,44 +145,49 @@ class ComposeViewModel @Inject constructor(
// the Activity goes away temporarily (like on screen rotation). // the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>() val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
mediaUploader.prepareMedia(uri) mediaUploader.prepareMedia(uri)
.map { (type, uri, size) -> .map { (type, uri, size) ->
val mediaItems = media.value!! val mediaItems = media.value!!
if (type != QueuedMedia.Type.IMAGE if (type != QueuedMedia.Type.IMAGE &&
&& mediaItems.isNotEmpty() mediaItems.isNotEmpty() &&
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) { mediaItems[0].type == QueuedMedia.Type.IMAGE
throw VideoOrImageException() ) {
} else { throw VideoOrImageException()
addMediaToQueue(type, uri, size, description) } else {
} addMediaToQueue(type, uri, size, description)
} }
.subscribe({ queuedMedia -> }
.subscribe(
{ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia)) liveData.postValue(Either.Right(queuedMedia))
}, { error -> },
{ error ->
liveData.postValue(Either.Left(error)) liveData.postValue(Either.Left(error))
}) }
.autoDispose() )
.autoDispose()
return liveData return liveData
} }
private fun addMediaToQueue( private fun addMediaToQueue(
type: QueuedMedia.Type, type: QueuedMedia.Type,
uri: Uri, uri: Uri,
mediaSize: Long, mediaSize: Long,
description: String? = null description: String? = null
): QueuedMedia { ): QueuedMedia {
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = System.currentTimeMillis(), localId = System.currentTimeMillis(),
uri = uri, uri = uri,
type = type, type = type,
mediaSize = mediaSize, mediaSize = mediaSize,
description = description description = description
) )
media.value = media.value!! + mediaItem media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem)
.subscribe({ event -> .subscribe(
{ event ->
val item = media.value?.find { it.localId == mediaItem.localId } val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe ?: return@subscribe
val newMediaItem = when (event) { val newMediaItem = when (event) {
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
@ -186,16 +197,20 @@ class ComposeViewModel @Inject constructor(
synchronized(media) { synchronized(media) {
val mediaValue = media.value!! val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId } val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(if (index == -1) { media.postValue(
mediaValue + newMediaItem if (index == -1) {
} else { mediaValue + newMediaItem
mediaValue.toMutableList().also { it[index] = newMediaItem } } else {
}) mediaValue.toMutableList().also { it[index] = newMediaItem }
}
)
} }
}, { error -> },
{ error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error) uploadError.postValue(error)
}) }
)
return mediaItem return mediaItem
} }
@ -215,12 +230,14 @@ class ComposeViewModel @Inject constructor(
fun didChange(content: String?, contentWarning: String?): Boolean { fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = !(content.isNullOrEmpty() val textChanged = !(
|| startingText?.startsWith(content.toString()) ?: false) content.isNullOrEmpty() ||
startingText?.startsWith(content.toString()) ?: false
)
val contentWarningChanged = showContentWarning.value!! val contentWarningChanged = showContentWarning.value!! &&
&& !contentWarning.isNullOrEmpty() !contentWarning.isNullOrEmpty() &&
&& !startingContentWarning.startsWith(contentWarning.toString()) !startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = !media.value.isNullOrEmpty() val mediaChanged = !media.value.isNullOrEmpty()
val pollChanged = poll.value != null val pollChanged = poll.value != null
@ -233,25 +250,23 @@ class ComposeViewModel @Inject constructor(
} }
fun deleteDraft() { fun deleteDraft() {
if (savedTootUid != 0) { viewModelScope.launch {
saveTootHelper.deleteDraft(savedTootUid) if (draftId != 0) {
} draftHelper.deleteDraftAndAttachments(draftId)
if (draftId != 0) { }
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
} }
} }
fun saveDraft(content: String, contentWarning: String) { fun saveDraft(content: String, contentWarning: String) {
viewModelScope.launch {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
val mediaUris: MutableList<String> = mutableListOf() draftHelper.saveDraft(
val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
}
draftHelper.saveDraft(
draftId = draftId, draftId = draftId,
accountId = accountManager.activeAccount?.id!!, accountId = accountManager.activeAccount?.id!!,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -263,7 +278,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
poll = poll.value, poll = poll.value,
failedToSend = false failedToSend = false
).subscribe() )
}
} }
/** /**
@ -272,27 +288,27 @@ class ComposeViewModel @Inject constructor(
* @return LiveData which will signal once the screen can be closed or null if there are errors * @return LiveData which will signal once the screen can be closed or null if there are errors
*/ */
fun sendStatus( fun sendStatus(
content: String, content: String,
spoilerText: String spoilerText: String
): LiveData<Unit> { ): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) { val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else { } else {
just(Unit) Observable.just(Unit)
}.toLiveData() }.toLiveData()
val sendObservable = media val sendObservable = media
.filter { items -> items.all { it.uploadPercent == -1 } } .filter { items -> items.all { it.uploadPercent == -1 } }
.map { .map {
val mediaIds = ArrayList<String>() val mediaIds = ArrayList<String>()
val mediaUris = ArrayList<Uri>() val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>() val mediaDescriptions = ArrayList<String>()
for (item in media.value!!) { for (item in media.value!!) {
mediaIds.add(item.id!!) mediaIds.add(item.id!!)
mediaUris.add(item.uri) mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
} }
val tootToSend = TootToSend( val tootToSend = TootToSend(
text = content, text = content,
@ -309,14 +325,13 @@ class ComposeViewModel @Inject constructor(
replyingStatusAuthorUsername = null, replyingStatusAuthorUsername = null,
quoteId = quoteId, quoteId = quoteId,
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
savedTootUid = savedTootUid,
draftId = draftId, draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
} }
return combineLiveData(deletionObservable, sendObservable) { _, _ -> } return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
} }
@ -336,12 +351,15 @@ class ComposeViewModel @Inject constructor(
media.removeObserver(this) media.removeObserver(this)
} else if (updatedItem.id != null) { } else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description) api.updateMedia(updatedItem.id, description)
.subscribe({ .subscribe(
{
completedCaptioningLiveData.postValue(true) completedCaptioningLiveData.postValue(true)
}, { },
{
completedCaptioningLiveData.postValue(false) completedCaptioningLiveData.postValue(false)
}) }
.autoDispose() )
.autoDispose()
media.removeObserver(this) media.removeObserver(this)
} }
} }
@ -354,8 +372,8 @@ class ComposeViewModel @Inject constructor(
'@' -> { '@' -> {
return try { return try {
api.searchAccounts(query = token.substring(1), limit = 10) api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet() .blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) } .map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList() emptyList()
@ -364,9 +382,9 @@ class ComposeViewModel @Inject constructor(
'#' -> { '#' -> {
return try { return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet() .blockingGet()
.hashtags .hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) } .map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e) Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList() emptyList()
@ -375,11 +393,11 @@ class ComposeViewModel @Inject constructor(
':' -> { ':' -> {
val emojiList = emoji.value ?: return emptyList() val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1).toLowerCase(Locale.ROOT) val incomplete = token.substring(1).lowercase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>() val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>() val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) { for (emoji in emojiList) {
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT) val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) { if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji)) results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) { } else if (shortcode.indexOf(incomplete, 1) != -1) {
@ -409,7 +427,8 @@ class ComposeViewModel @Inject constructor(
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
startingVisibility = Status.Visibility.byNum( startingVisibility = Status.Visibility.byNum(
preferredVisibility.num.coerceAtLeast(replyVisibility.num)) preferredVisibility.num.coerceAtLeast(replyVisibility.num)
)
inReplyToId = composeOptions?.inReplyToId inReplyToId = composeOptions?.inReplyToId
@ -428,20 +447,8 @@ class ComposeViewModel @Inject constructor(
} }
// recreate media list // recreate media list
val loadedDraftMediaUris = composeOptions?.mediaUrls
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
val draftAttachments = composeOptions?.draftAttachments val draftAttachments = composeOptions?.draftAttachments
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { if (draftAttachments != null) {
// when coming from SavedTootActivity
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
.forEach { (uri, description) ->
pickMedia(uri.toUri()).observeForever { errorOrItem ->
if (errorOrItem.isRight() && description != null) {
updateDescription(errorOrItem.asRight().localId, description)
}
}
}
} else if (draftAttachments != null) {
// when coming from DraftActivity // when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
} else composeOptions?.mediaAttachments?.forEach { a -> } else composeOptions?.mediaAttachments?.forEach { a ->
@ -454,7 +461,6 @@ class ComposeViewModel @Inject constructor(
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
} }
savedTootUid = composeOptions?.savedTootUid ?: 0
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText startingText = composeOptions?.tootText
@ -508,7 +514,6 @@ class ComposeViewModel @Inject constructor(
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
} }
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default } fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
@ -522,10 +527,10 @@ val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "itabashi.0j0.jp", "biwakodon.com",
"pomdon.work", "obapom.work") "pomdon.work", "obapom.work")
data class ComposeInstanceParams( data class ComposeInstanceParams(
val maxChars: Int, val maxChars: Int,
val pollMaxOptions: Int, val pollMaxOptions: Int,
val pollMaxLength: Int, val pollMaxLength: Int,
val supportsScheduled: Boolean val supportsScheduled: Boolean
) )
/** /**

View File

@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.view.ProgressImageView import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter( class MediaPreviewAdapter(
context: Context, context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() { ) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
fun submitList(list: List<ComposeActivity.QueuedMedia>) { fun submitList(list: List<ComposeActivity.QueuedMedia>) {
@ -57,7 +57,7 @@ class MediaPreviewAdapter(
} }
private val thumbnailViewSize = private val thumbnailViewSize =
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
override fun getItemCount(): Int = differ.currentList.size override fun getItemCount(): Int = differ.currentList.size
@ -74,31 +74,34 @@ class MediaPreviewAdapter(
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp) holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else { } else {
Glide.with(holder.itemView.context) Glide.with(holder.itemView.context)
.load(item.uri) .load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate() .dontAnimate()
.into(holder.progressImageView) .into(holder.progressImageView)
} }
} }
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() { private val differ = AsyncListDiffer(
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { this,
return oldItem.localId == newItem.localId object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
} override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem.localId == newItem.localId
}
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
return oldItem == newItem return oldItem == newItem
}
} }
}) )
inner class PreviewViewHolder(val progressImageView: ProgressImageView) inner class PreviewViewHolder(val progressImageView: ProgressImageView) :
: RecyclerView.ViewHolder(progressImageView) { RecyclerView.ViewHolder(progressImageView) {
init { init {
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin) .getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom) layoutParams.setMargins(margin, 0, margin, marginBottom)
progressImageView.layoutParams = layoutParams progressImageView.layoutParams = layoutParams
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
@ -107,4 +110,4 @@ class MediaPreviewAdapter(
} }
} }
} }
} }

View File

@ -28,16 +28,19 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import io.reactivex.Observable import com.keylesspalace.tusky.util.getImageSquarePixels
import io.reactivex.Single import com.keylesspalace.tusky.util.getMediaSize
import io.reactivex.schedulers.Schedulers import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.Date
sealed class UploadEvent { sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent() data class ProgressEvent(val percentage: Int) : UploadEvent()
@ -50,9 +53,9 @@ fun createNewImageFile(context: Context): File {
val imageFileName = "Tusky_${randomId}_" val imageFileName = "Tusky_${randomId}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile( return File.createTempFile(
imageFileName, /* prefix */ imageFileName, /* prefix */
".jpg", /* suffix */ ".jpg", /* suffix */
storageDir /* directory */ storageDir /* directory */
) )
} }
@ -69,18 +72,18 @@ class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception() class CouldNotOpenFileException : Exception()
class MediaUploaderImpl( class MediaUploaderImpl(
private val context: Context, private val context: Context,
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
) : MediaUploader { ) : MediaUploader {
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> { override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable return Observable
.fromCallable { .fromCallable {
if (shouldResizeMedia(media)) { if (shouldResizeMedia(media)) {
downsize(media) downsize(media)
} else media } else media
} }
.switchMap { upload(it) } .switchMap { upload(it) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> { override fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
@ -101,12 +104,13 @@ class MediaUploaderImpl(
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out -> FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out) input.copyTo(out)
uri = FileProvider.getUriForFile(context, uri = FileProvider.getUriForFile(
BuildConfig.APPLICATION_ID + ".fileprovider", context,
file) BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri) mediaSize = getMediaSize(contentResolver, uri)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, e) Log.w(TAG, e)
@ -151,20 +155,22 @@ class MediaUploaderImpl(
var mimeType = contentResolver.getType(media.uri) var mimeType = contentResolver.getType(media.uri)
val map = MimeTypeMap.getSingleton() val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType) val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = String.format("%s_%s_%s.%s", val filename = "%s_%s_%s.%s".format(
context.getString(R.string.app_name), context.getString(R.string.app_name),
Date().time.toString(), Date().time.toString(),
randomAlphanumericString(10), randomAlphanumericString(10),
fileExtension) fileExtension
)
val stream = contentResolver.openInputStream(media.uri) val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data" if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1 var lastProgress = -1
val fileBody = ProgressRequestBody(stream, media.mediaSize, val fileBody = ProgressRequestBody(
mimeType.toMediaTypeOrNull()) { percentage -> stream, media.mediaSize,
mimeType.toMediaTypeOrNull()
) { percentage ->
if (percentage != lastProgress) { if (percentage != lastProgress) {
emitter.onNext(UploadEvent.ProgressEvent(percentage)) emitter.onNext(UploadEvent.ProgressEvent(percentage))
} }
@ -180,7 +186,8 @@ class MediaUploaderImpl(
} }
val uploadDisposable = mastodonApi.uploadMedia(body, description) val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe({ attachment -> .subscribe(
{ attachment ->
if (media.uri.scheme == "file") { if (media.uri.scheme == "file") {
media.uri.path?.let { media.uri.path?.let {
File(it).delete() File(it).delete()
@ -189,9 +196,11 @@ class MediaUploaderImpl(
emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onNext(UploadEvent.FinishedEvent(attachment))
emitter.onComplete() emitter.onComplete()
}, { e -> },
{ e ->
emitter.onError(e) emitter.onError(e)
}) }
)
// Cancel the request when our observable is cancelled // Cancel the request when our observable is cancelled
emitter.setDisposable(uploadDisposable) emitter.setDisposable(uploadDisposable)
@ -200,15 +209,16 @@ class MediaUploaderImpl(
private fun downsize(media: QueuedMedia): QueuedMedia { private fun downsize(media: QueuedMedia): QueuedMedia {
val file = createNewImageFile(context) val file = createNewImageFile(context)
DownsizeImageTask.resize(arrayOf(media.uri), DownsizeImageTask.resize(
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file) arrayOf(media.uri),
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
)
return media.copy(uri = file.toUri(), mediaSize = file.length()) return media.copy(uri = file.toUri(), mediaSize = file.length())
} }
private fun shouldResizeMedia(media: QueuedMedia): Boolean { private fun shouldResizeMedia(media: QueuedMedia): Boolean {
return media.type == QueuedMedia.Type.IMAGE return media.type == QueuedMedia.Type.IMAGE &&
&& (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|| getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
} }
private companion object { private companion object {
@ -217,6 +227,5 @@ class MediaUploaderImpl(
private const val STATUS_AUDIO_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_SIZE_LIMIT = 8388608 // 8MiB
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
} }
} }

View File

@ -26,33 +26,33 @@ import com.keylesspalace.tusky.databinding.DialogAddPollBinding
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
fun showAddPollDialog( fun showAddPollDialog(
context: Context, context: Context,
poll: NewPoll?, poll: NewPoll?,
maxOptionCount: Int, maxOptionCount: Int,
maxOptionLength: Int, maxOptionLength: Int,
onUpdatePoll: (NewPoll) -> Unit onUpdatePoll: (NewPoll) -> Unit
) { ) {
val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context))
val dialog = AlertDialog.Builder(context) val dialog = AlertDialog.Builder(context)
.setIcon(R.drawable.ic_poll_24dp) .setIcon(R.drawable.ic_poll_24dp)
.setTitle(R.string.create_poll_title) .setTitle(R.string.create_poll_title)
.setView(binding.root) .setView(binding.root)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok, null) .setPositiveButton(android.R.string.ok, null)
.create() .create()
val adapter = AddPollOptionsAdapter( val adapter = AddPollOptionsAdapter(
options = poll?.options?.toMutableList() ?: mutableListOf("", ""), options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
maxOptionLength = maxOptionLength, maxOptionLength = maxOptionLength,
onOptionRemoved = { valid -> onOptionRemoved = { valid ->
binding.addChoiceButton.isEnabled = true binding.addChoiceButton.isEnabled = true
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
}, },
onOptionChanged = { valid -> onOptionChanged = { valid ->
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
} }
) )
binding.pollChoices.adapter = adapter binding.pollChoices.adapter = adapter
@ -80,13 +80,15 @@ fun showAddPollDialog(
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
val pollDuration = context.resources val pollDuration = context.resources
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId] .getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
onUpdatePoll(NewPoll( onUpdatePoll(
NewPoll(
options = adapter.pollOptions, options = adapter.pollOptions,
expiresIn = pollDuration, expiresIn = pollDuration,
multiple = binding.multipleChoicesCheckBox.isChecked multiple = binding.multipleChoicesCheckBox.isChecked
)) )
)
dialog.dismiss() dialog.dismiss()
} }
@ -96,4 +98,4 @@ fun showAddPollDialog(
// make the dialog focusable so the keyboard does not stay behind it // make the dialog focusable so the keyboard does not stay behind it
dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
} }

View File

@ -27,11 +27,11 @@ import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
class AddPollOptionsAdapter( class AddPollOptionsAdapter(
private var options: MutableList<String>, private var options: MutableList<String>,
private val maxOptionLength: Int, private val maxOptionLength: Int,
private val onOptionRemoved: (Boolean) -> Unit, private val onOptionRemoved: (Boolean) -> Unit,
private val onOptionChanged: (Boolean) -> Unit private val onOptionChanged: (Boolean) -> Unit
): RecyclerView.Adapter<BindingHolder<ItemAddPollOptionBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemAddPollOptionBinding>>() {
val pollOptions: List<String> val pollOptions: List<String>
get() = options.toList() get() = options.toList()
@ -47,8 +47,8 @@ class AddPollOptionsAdapter(
binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength))
binding.optionEditText.onTextChanged { s, _, _, _ -> binding.optionEditText.onTextChanged { s, _, _, _ ->
val pos = holder.adapterPosition val pos = holder.bindingAdapterPosition
if(pos != RecyclerView.NO_POSITION) { if (pos != RecyclerView.NO_POSITION) {
options[pos] = s.toString() options[pos] = s.toString()
onOptionChanged(validateInput()) onOptionChanged(validateInput())
} }
@ -68,8 +68,8 @@ class AddPollOptionsAdapter(
holder.binding.deleteButton.setOnClickListener { holder.binding.deleteButton.setOnClickListener {
holder.binding.optionEditText.clearFocus() holder.binding.optionEditText.clearFocus()
options.removeAt(holder.adapterPosition) options.removeAt(holder.bindingAdapterPosition)
notifyItemRemoved(holder.adapterPosition) notifyItemRemoved(holder.bindingAdapterPosition)
onOptionRemoved(validateInput()) onOptionRemoved(validateInput())
} }
} }

View File

@ -21,7 +21,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.text.InputFilter import android.text.InputFilter
import android.text.InputType import android.text.InputType
import android.util.DisplayMetrics
import android.view.WindowManager import android.view.WindowManager
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
@ -31,6 +30,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.github.chrisbanes.photoview.PhotoView import com.github.chrisbanes.photoview.PhotoView
@ -40,9 +40,10 @@ import com.keylesspalace.tusky.util.withLifecycleContext
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(existingDescription: String?, fun <T> T.makeCaptionDialog(
previewUri: Uri, existingDescription: String?,
onUpdateDescription: (String) -> LiveData<Boolean> previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean>
) where T : Activity, T : LifecycleOwner { ) where T : Activity, T : LifecycleOwner {
val dialogLayout = LinearLayout(this) val dialogLayout = LinearLayout(this)
val padding = Utils.dpToPx(this, 8) val padding = Utils.dpToPx(this, 8)
@ -53,9 +54,6 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
maximumScale = 6f maximumScale = 6f
} }
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
val margin = Utils.dpToPx(this, 4) val margin = Utils.dpToPx(this, 4)
dialogLayout.addView(imageView) dialogLayout.addView(imageView)
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
@ -63,14 +61,18 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
val input = EditText(this) val input = EditText(this)
input.hint = resources.getQuantityString(R.plurals.hint_describe_for_visually_impaired, input.hint = resources.getQuantityString(
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT) R.plurals.hint_describe_for_visually_impaired,
MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT
)
dialogLayout.addView(input) dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2) input.setLines(2)
input.inputType = (InputType.TYPE_CLASS_TEXT input.inputType = (
or InputType.TYPE_TEXT_FLAG_MULTI_LINE InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
)
input.setText(existingDescription) input.setText(existingDescription)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
@ -78,39 +80,40 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
onUpdateDescription(input.text.toString()) onUpdateDescription(input.text.toString())
withLifecycleContext { withLifecycleContext {
onUpdateDescription(input.text.toString()) onUpdateDescription(input.text.toString())
.observe { success -> if (!success) showFailedCaptionMessage() } .observe { success -> if (!success) showFailedCaptionMessage() }
} }
dialog.dismiss() dialog.dismiss()
} }
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)
.setView(dialogLayout) .setView(dialogLayout)
.setPositiveButton(android.R.string.ok, okListener) .setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.create() .create()
val window = dialog.window val window = dialog.window
window?.setSoftInputMode( window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
dialog.show() dialog.show()
// Load the image and manually set it into the ImageView because it doesn't have a fixed // Load the image and manually set it into the ImageView because it doesn't have a fixed size.
// size. Maybe we should limit the size of CustomTarget
Glide.with(this) Glide.with(this)
.load(previewUri) .load(previewUri)
.into(object : CustomTarget<Drawable>() { .downsample(DownsampleStrategy.CENTER_INSIDE)
override fun onLoadCleared(placeholder: Drawable?) {} .into(object : CustomTarget<Drawable>(4096, 4096) {
override fun onLoadCleared(placeholder: Drawable?) {
imageView.setImageDrawable(placeholder)
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
imageView.setImageDrawable(resource) imageView.setImageDrawable(resource)
} }
}) })
} }
private fun Activity.showFailedCaptionMessage() { private fun Activity.showFailedCaptionMessage() {
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
} }

View File

@ -71,12 +71,10 @@ class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: Attr
R.id.directRadioButton R.id.directRadioButton
else -> else ->
R.id.directRadioButton R.id.directRadioButton
} }
check(selectedButton) check(selectedButton)
} }
} }
interface ComposeOptionsListener { interface ComposeOptionsListener {

View File

@ -16,25 +16,27 @@
package com.keylesspalace.tusky.components.compose.view package com.keylesspalace.tusky.components.compose.view
import android.content.Context import android.content.Context
import androidx.emoji.widget.EmojiEditTextHelper
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import android.text.InputType import android.text.InputType
import android.text.method.KeyListener import android.text.method.KeyListener
import android.util.AttributeSet import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper
class EditTextTyped @JvmOverloads constructor(context: Context, class EditTextTyped @JvmOverloads constructor(
attributeSet: AttributeSet? = null) context: Context,
: AppCompatMultiAutoCompleteTextView(context, attributeSet) { attributeSet: AttributeSet? = null
) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
init { init {
//fix a bug with autocomplete and some keyboards // fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType inputType = newInputType
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener))
@ -52,8 +54,13 @@ class EditTextTyped @JvmOverloads constructor(context: Context,
val connection = super.onCreateInputConnection(editorInfo) val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) { return if (onCommitContentListener != null) {
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
getEmojiEditTextHelper().onCreateInputConnection(InputConnectionCompat.createWrapper(connection, editorInfo, getEmojiEditTextHelper().onCreateInputConnection(
onCommitContentListener!!), editorInfo)!! InputConnectionCompat.createWrapper(
connection, editorInfo,
onCommitContentListener!!
),
editorInfo
)!!
} else { } else {
connection connection
} }

View File

@ -25,10 +25,11 @@ import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
class PollPreviewView @JvmOverloads constructor( class PollPreviewView @JvmOverloads constructor(
context: Context?, context: Context?,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) defStyleAttr: Int = 0
: LinearLayout(context, attrs, defStyleAttr) { ) :
LinearLayout(context, attrs, defStyleAttr) {
private val adapter = PreviewPollOptionsAdapter() private val adapter = PreviewPollOptionsAdapter()
@ -46,7 +47,7 @@ class PollPreviewView @JvmOverloads constructor(
binding.pollPreviewOptions.adapter = adapter binding.pollPreviewOptions.adapter = adapter
} }
fun setPoll(poll: NewPoll){ fun setPoll(poll: NewPoll) {
adapter.update(poll.options, poll.multiple) adapter.update(poll.options, poll.multiple)
val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast { val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast {
@ -59,4 +60,4 @@ class PollPreviewView @JvmOverloads constructor(
super.setOnClickListener(l) super.setOnClickListener(l)
adapter.setOnClickListener(l) adapter.setOnClickListener(l)
} }
} }

View File

@ -28,15 +28,15 @@ import com.mikepenz.iconics.utils.sizeDp
class TootButton class TootButton
@JvmOverloads constructor( @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 defStyleAttr: Int = 0
) : MaterialButton(context, attrs, defStyleAttr) { ) : MaterialButton(context, attrs, defStyleAttr) {
private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button) private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button)
init { init {
if(smallStyle) { if (smallStyle) {
setIconResource(R.drawable.ic_send_24dp) setIconResource(R.drawable.ic_send_24dp)
} else { } else {
setText(R.string.action_send) setText(R.string.action_send)
@ -47,7 +47,7 @@ class TootButton
} }
fun setStatusVisibility(visibility: Status.Visibility) { fun setStatusVisibility(visibility: Status.Visibility) {
if(!smallStyle) { if (!smallStyle) {
icon = when (visibility) { icon = when (visibility) {
Status.Visibility.PUBLIC -> { Status.Visibility.PUBLIC -> {
@ -69,8 +69,5 @@ class TootButton
} }
} }
} }
} }
} }

View File

@ -17,114 +17,39 @@ package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.AsyncPagedListDiffer import androidx.paging.PagingDataAdapter
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener, private val listener: StatusActionListener
private val topLoadedCallback: () -> Unit, ) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
private val retryCallback: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var networkState: NetworkState? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback { return ConversationViewHolder(view, statusDisplayOptions, listener)
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
if (position == 0) {
topLoadedCallback()
}
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position, count, payload)
}
}, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build())
fun submitList(list: PagedList<ConversationEntity>) {
differ.submitList(list)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
return when (viewType) { holder.setupWithConversation(getItem(position))
R.layout.item_network_state -> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
NetworkStateViewHolder(binding, retryCallback)
}
R.layout.item_conversation -> {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
ConversationViewHolder(view, statusDisplayOptions, listener)
}
else -> throw IllegalArgumentException("unknown view type $viewType")
}
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { fun item(position: Int): ConversationEntity? {
when (getItemViewType(position)) { return getItem(position)
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0)
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position))
}
}
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
override fun getItemViewType(position: Int): Int {
return if (hasExtraRow() && position == itemCount - 1) {
R.layout.item_network_state
} else {
R.layout.item_conversation
}
}
override fun getItemCount(): Int {
return differ.itemCount + if (hasExtraRow()) 1 else 0
}
fun setNetworkState(newNetworkState: NetworkState?) {
val previousState = this.networkState
val hadExtraRow = hasExtraRow()
this.networkState = newNetworkState
val hasExtraRow = hasExtraRow()
if (hadExtraRow != hasExtraRow) {
if (hadExtraRow) {
notifyItemRemoved(differ.itemCount)
} else {
notifyItemInserted(differ.itemCount)
}
} else if (hasExtraRow && previousState != newNetworkState) {
notifyItemChanged(itemCount - 1)
}
} }
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
oldItem == newItem return oldItem.id == newItem.id
}
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean {
oldItem.id == newItem.id return oldItem == newItem
}
} }
} }
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -21,65 +21,70 @@ import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.* import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.* import java.util.Date
@Entity(primaryKeys = ["id","accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class ConversationEntity( data class ConversationEntity(
val accountId: Long, val accountId: Long,
val id: String, val id: String,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) )
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
val username: String, val username: String,
val displayName: String, val displayName: String,
val avatar: String, val avatar: String,
val emojis: List<Emoji> val emojis: List<Emoji>
) { ) {
fun toAccount(): Account { fun toAccount(): Account {
return Account( return Account(
id = id, id = id,
username = username, username = username,
displayName = displayName, displayName = displayName,
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
url = "", url = "",
localUsername = "", localUsername = "",
note = SpannedString(""), note = SpannedString(""),
header = "" header = ""
) )
} }
} }
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
data class ConversationStatusEntity( data class ConversationStatusEntity(
val id: String, val id: String,
val url: String?, val url: String?,
val inReplyToId: String?, val inReplyToId: String?,
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: Spanned, val content: Spanned,
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
val favourited: Boolean, val favourited: Boolean,
val bookmarked: Boolean, val bookmarked: Boolean,
val sensitive: Boolean, val sensitive: Boolean,
val spoilerText: String, val spoilerText: String,
val attachments: ArrayList<Attachment>, val attachments: ArrayList<Attachment>,
val mentions: Array<Status.Mention>, val mentions: List<Status.Mention>,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean, val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val poll: Poll? val muted: Boolean,
val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */ /** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -93,7 +98,7 @@ data class ConversationStatusEntity(
if (inReplyToId != other.inReplyToId) return false if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false if (account != other.account) return false
if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false if (favouritesCount != other.favouritesCount) return false
@ -101,11 +106,12 @@ data class ConversationStatusEntity(
if (sensitive != other.sensitive) return false if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false if (attachments != other.attachments) return false
if (!mentions.contentEquals(other.mentions)) return false if (mentions != other.mentions) return false
if (showingHiddenContent != other.showingHiddenContent) return false if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false if (poll != other.poll) return false
return true return true
@ -125,72 +131,86 @@ data class ConversationStatusEntity(
result = 31 * result + sensitive.hashCode() result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode() result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode() result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.contentHashCode() result = 31 * result + mentions.hashCode()
result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode() result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode() result = 31 * result + poll.hashCode()
return result return result
} }
fun toStatus(): Status { fun toStatus(): Status {
return Status( return Status(
id = id, id = id,
url = url, url = url,
account = account.toAccount(), account = account.toAccount(),
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId, inReplyToAccountId = inReplyToAccountId,
content = content, content = content,
reblog = null, reblog = null,
createdAt = createdAt, createdAt = createdAt,
emojis = emojis, emojis = emojis,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
reblogged = false, reblogged = false,
favourited = favourited, favourited = favourited,
bookmarked = bookmarked, bookmarked = bookmarked,
sensitive= sensitive, sensitive = sensitive,
spoilerText = spoilerText, spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT, visibility = Status.Visibility.DIRECT,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
application = null, application = null,
pinned = false, pinned = false,
muted = false, muted = muted,
poll = poll, poll = poll,
card = null, card = null,
quote = null) quote = null,
)
} }
} }
fun Account.toEntity() = fun Account.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id, id = id,
username, username = username,
name, displayName = name,
avatar, avatar = avatar,
emojis ?: emptyList() emojis = emojis ?: emptyList()
) )
fun Status.toEntity() = fun Status.toEntity() =
ConversationStatusEntity( ConversationStatusEntity(
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, id = id,
createdAt, emojis, favouritesCount, favourited, bookmarked, sensitive, url = url,
spoilerText, attachments, mentions, inReplyToId = inReplyToId,
false, inReplyToAccountId = inReplyToAccountId,
false, account = account.toEntity(),
shouldTrimStatus(content), content = content,
true, createdAt = createdAt,
poll emojis = emojis,
) favouritesCount = favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
poll = poll
)
fun Conversation.toEntity(accountId: Long) = fun Conversation.toEntity(accountId: Long) =
ConversationEntity( ConversationEntity(
accountId, accountId = accountId,
id, id = id,
accounts.map { it.toEntity() }, accounts = accounts.map { it.toEntity() },
unread, unread = unread,
lastStatus!!.toEntity() lastStatus = lastStatus!!.toEntity()
) )

View File

@ -0,0 +1,40 @@
/* 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.conversation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
class ConversationLoadStateAdapter(
private val retryCallback: () -> Unit
) : LoadStateAdapter<NetworkStateViewHolder>() {
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) {
holder.setUpWithNetworkState(loadState)
}
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): NetworkStateViewHolder {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NetworkStateViewHolder(binding, retryCallback)
}
}

View File

@ -1,98 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.paging.PagedList
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.PagingRequestHelper
import com.keylesspalace.tusky.util.createStatusLiveData
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executor
/**
* This boundary callback gets notified when user reaches to the edges of the list such that the
* database cannot provide any more data.
* <p>
* The boundary callback might be called multiple times for the same direction so it does its own
* rate limiting using the PagingRequestHelper class.
*/
class ConversationsBoundaryCallback(
private val accountId: Long,
private val mastodonApi: MastodonApi,
private val handleResponse: (Long, List<Conversation>?) -> Unit,
private val ioExecutor: Executor,
private val networkPageSize: Int)
: PagedList.BoundaryCallback<ConversationEntity>() {
val helper = PagingRequestHelper(ioExecutor)
val networkState = helper.createStatusLiveData()
/**
* Database returned 0 items. We should query the backend for more items.
*/
@MainThread
override fun onZeroItemsLoaded() {
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) {
mastodonApi.getConversations(null, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* User reached to the end of the list.
*/
@MainThread
override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) {
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* every time it gets new items, boundary callback simply inserts them into the database and
* paging library takes care of refreshing the list if necessary.
*/
private fun insertItemsIntoDb(
response: Response<List<Conversation>>,
it: PagingRequestHelper.Request.Callback) {
ioExecutor.execute {
handleResponse(accountId, response.body())
it.recordSuccess()
}
}
override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) {
// ignored, since we only ever append to what's in the DB
}
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback<List<Conversation>> {
return object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
it.recordFailure(t)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
insertItemsIntoDb(response, it)
}
}
}
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Conny Duck /* Copyright 2021 Tusky Contributors
* *
* This file is a part of Tusky. * This file is a part of Tusky.
* *
@ -20,7 +20,12 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -37,10 +42,15 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@ -53,35 +63,40 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null private var layoutManager: LinearLayoutManager? = null
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false) return inflater.inflate(R.layout.fragment_timeline, container, false)
} }
@ExperimentalPagingApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false), animateAvatars = preferences.getBoolean("animateGifAvatars", false),
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
showBotOverlay = preferences.getBoolean("showBotOverlay", true), showBotOverlay = preferences.getBoolean("showBotOverlay", true),
useBlurhash = preferences.getBoolean("useBlurhash", true), useBlurhash = preferences.getBoolean("useBlurhash", true),
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID,
) )
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry) adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(view.context) layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide() binding.progressBar.hide()
@ -89,37 +104,60 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh() initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner) { lifecycleScope.launch {
adapter.submitList(it) viewModel.conversationFlow.collectLatest { pagingData ->
} adapter.submitData(pagingData)
viewModel.networkState.observe(viewLifecycleOwner) { }
adapter.setNetworkState(it)
} }
viewModel.load() adapter.addLoadStateListener { loadStates ->
loadStates.refresh.let { refreshState ->
if (refreshState is LoadState.Error) {
binding.statusView.show()
if (refreshState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
adapter.refresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
adapter.refresh()
}
}
} else {
binding.statusView.hide()
}
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
// jump to top after the initial refresh finished
binding.recyclerView.scrollToPosition(0)
initialRefreshDone = true
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
} }
private fun initSwipeToRefresh() { private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner) {
binding.swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
}
binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh() adapter.refresh()
} }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
} }
private fun onTopLoaded() {
binding.recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) { override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages // its impossible to reblog private messages
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favourite(favourite, position) adapter.item(position)?.let { conversation ->
viewModel.favourite(favourite, conversation)
}
} }
override fun onQuote(position: Int) { override fun onQuote(position: Int) {
@ -127,24 +165,44 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onBookmark(favourite: Boolean, position: Int) { override fun onBookmark(favourite: Boolean, position: Int) {
viewModel.bookmark(favourite, position) adapter.item(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation)
}
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
more(it.toStatus(), view, position)
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) {
popup.menu.removeItem(R.id.status_mute_conversation)
} else {
popup.menu.removeItem(R.id.status_unmute_conversation)
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_mute_conversation -> viewModel.muteConversation(conversation)
R.id.status_unmute_conversation -> viewModel.muteConversation(conversation)
R.id.conversation_delete -> deleteConversation(conversation)
}
true
}
popup.show()
} }
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
viewMedia(attachmentIndex, it.toStatus(), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
viewThread(it.toStatus()) viewThread(conversation.lastStatus.id, conversation.lastStatus.url)
} }
} }
@ -153,11 +211,15 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.expandHiddenStatus(expanded, position) adapter.item(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation)
}
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.showContent(isShowing, position) adapter.item(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation)
}
} }
override fun onLoadMore(position: Int) { override fun onLoadMore(position: Int) {
@ -165,7 +227,9 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.collapseLongStatus(isCollapsed, position) adapter.item(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation)
}
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
@ -180,15 +244,25 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun removeItem(position: Int) { override fun removeItem(position: Int) {
viewModel.remove(position) // not needed
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { adapter.item(position)?.let { conversation ->
reply(it.toStatus()) reply(conversation.lastStatus.toStatus())
} }
} }
private fun deleteConversation(conversation: ConversationEntity) {
AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(conversation)
}
.show()
}
private fun jumpToTop() { private fun jumpToTop() {
if (isAdded) { if (isAdded) {
layoutManager?.scrollToPosition(0) layoutManager?.scrollToPosition(0)
@ -200,12 +274,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
jumpToTop() jumpToTop()
} }
override fun onReset() {
viewModel.refresh()
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
viewModel.voteInPoll(position, choices) adapter.item(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
} }
companion object { companion object {

View File

@ -0,0 +1,51 @@
package com.keylesspalace.tusky.components.conversation
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
@ExperimentalPagingApi
class ConversationsRemoteMediator(
private val accountId: Long,
private val api: MastodonApi,
private val db: AppDatabase
) : RemoteMediator<Int, ConversationEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
try {
val conversationsResult = when (loadType) {
LoadType.REFRESH -> {
api.getConversations(limit = state.config.initialLoadSize)
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
api.getConversations(maxId = maxId, limit = state.config.pageSize)
}
}
if (loadType == LoadType.REFRESH) {
db.conversationDao().deleteForAccount(accountId)
}
db.conversationDao().insert(
conversationsResult
.filterNot { it.lastStatus == null }
.map { it.toEntity(accountId) }
)
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
} catch (e: Exception) {
return MediatorResult.Error(e)
}
}
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
}

View File

@ -1,111 +1,37 @@
/* 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.conversation package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing import io.reactivex.rxjava3.core.Single
import com.keylesspalace.tusky.util.NetworkState import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
private val ioExecutor = Executors.newSingleThreadExecutor() val db: AppDatabase
) {
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
if(showLoadingIndicator) {
networkState.value = NetworkState.LOADING
}
mastodonApi.getConversations(limit = DEFAULT_PAGE_SIZE).enqueue(
object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
// retrofit calls this on main thread so safe to call set value
networkState.value = NetworkState.error(t.message)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
ioExecutor.execute {
db.runInTransaction {
db.conversationDao().deleteForAccount(accountId)
insertResultIntoDb(accountId, response.body())
}
// since we are in bg thread now, post the result.
networkState.postValue(NetworkState.LOADED)
}
}
}
)
return networkState
}
@MainThread
fun conversations(accountId: Long): Listing<ConversationEntity> {
// create a boundary callback which will observe when the user reaches to the edges of
// the list and update the database with extra data.
val boundaryCallback = ConversationsBoundaryCallback(
accountId = accountId,
mastodonApi = mastodonApi,
handleResponse = this::insertResultIntoDb,
ioExecutor = ioExecutor,
networkPageSize = DEFAULT_PAGE_SIZE)
// we are using a mutable live data to trigger refresh requests which eventually calls
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
// dispatched data in refreshTrigger
val refreshTrigger = MutableLiveData<Unit>()
val refreshState = Transformations.switchMap(refreshTrigger) {
refresh(accountId, true)
}
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData(
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false),
boundaryCallback = boundaryCallback
)
return Listing(
pagedList = livePagedList,
networkState = boundaryCallback.networkState,
retry = {
boundaryCallback.helper.retryAllFailed()
},
refresh = {
refreshTrigger.value = null
},
refreshState = refreshState
)
}
fun deleteCacheForAccount(accountId: Long) { fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable { Single.fromCallable {
db.conversationDao().deleteForAccount(accountId) db.conversationDao().deleteForAccount(accountId)
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
}
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
result?.filter { it.lastStatus != null }
?.map{ it.toEntity(accountId) }
?.let { db.conversationDao().insert(it) }
}
}

View File

@ -1,142 +1,161 @@
/* 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.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope
import androidx.lifecycle.MutableLiveData import androidx.paging.ExperimentalPagingApi
import androidx.lifecycle.Transformations import androidx.paging.Pager
import androidx.paging.PagedList import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ConversationsViewModel @Inject constructor( class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository, private val timelineCases: TimelineCases,
private val timelineCases: TimelineCases, private val database: AppDatabase,
private val database: AppDatabase, private val accountManager: AccountManager,
private val accountManager: AccountManager private val api: MastodonApi
) : RxAwareViewModel() { ) : RxAwareViewModel() {
private val repoResult = MutableLiveData<Listing<ConversationEntity>>() @ExperimentalPagingApi
val conversationFlow = Pager(
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
)
.flow
.cachedIn(viewModelScope)
val conversations: LiveData<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList } fun favourite(favourite: Boolean, conversation: ConversationEntity) {
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState } viewModelScope.launch {
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState } try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await()
fun load() { val newConversation = conversation.copy(
val accountId = accountManager.activeAccount?.id ?: return lastStatus = conversation.lastStatus.copy(favourited = favourite)
if (repoResult.value == null) { )
repository.refresh(accountId, false)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e)
}
} }
repoResult.value = repository.conversations(accountId)
} }
fun refresh() { fun bookmark(bookmark: Boolean, conversation: ConversationEntity) {
repoResult.value?.refresh?.invoke() viewModelScope.launch {
} try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
fun retry() { val newConversation = conversation.copy(
repoResult.value?.retry?.invoke() lastStatus = conversation.lastStatus.copy(bookmarked = bookmark)
} )
fun favourite(favourite: Boolean, position: Int) { database.conversationDao().insert(newConversation)
conversations.value?.getOrNull(position)?.let { conversation -> } catch (e: Exception) {
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) Log.w(TAG, "failed to bookmark status", e)
.flatMap { }
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} }
fun bookmark(bookmark: Boolean, position: Int) { fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.toStatus(), bookmark) try {
.flatMap { val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await()
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) lastStatus = conversation.lastStatus.copy(poll = poll)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
} } catch (e: Exception) {
.subscribeOn(Schedulers.io()) Log.w(TAG, "failed to vote in poll", e)
.doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) } }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
} }
} }
fun voteInPoll(position: Int, choices: MutableList<Int>) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
timelineCases.voteInPoll(conversation.lastStatus.toStatus(), choices)
.flatMap { poll ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(poll = poll)
)
database.conversationDao().insert(newConversation)
}
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
.onErrorReturnItem(0)
.subscribe()
.autoDispose()
}
}
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded) lastStatus = conversation.lastStatus.copy(expanded = expanded)
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun collapseLongStatus(collapsed: Boolean, position: Int) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun showContent(showing: Boolean, position: Int) { fun showContent(showing: Boolean, conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { conversation -> viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun remove(position: Int) { fun remove(conversation: ConversationEntity) {
conversations.value?.getOrNull(position)?.let { viewModelScope.launch {
refresh() try {
api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
}
} }
} }
private fun saveConversationToDb(conversation: ConversationEntity) { fun muteConversation(conversation: ConversationEntity) {
viewModelScope.launch {
try {
val newStatus = timelineCases.muteConversation(
conversation.lastStatus.id,
!conversation.lastStatus.muted
).await()
val newConversation = conversation.copy(
lastStatus = newStatus.toEntity()
)
database.conversationDao().insert(newConversation)
} catch (e: Exception) {
Log.w(TAG, "failed to mute conversation", e)
}
}
}
suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation) database.conversationDao().insert(conversation)
.subscribeOn(Schedulers.io())
.subscribe()
} }
companion object {
private const val TAG = "ConversationsViewModel"
}
} }

View File

@ -28,128 +28,119 @@ import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.Completable import kotlinx.coroutines.Dispatchers
import io.reactivex.Observable import kotlinx.coroutines.withContext
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
class DraftHelper @Inject constructor( class DraftHelper @Inject constructor(
val context: Context, val context: Context,
db: AppDatabase db: AppDatabase
) { ) {
private val draftDao = db.draftDao() private val draftDao = db.draftDao()
fun saveDraft( suspend fun saveDraft(
draftId: Int, draftId: Int,
accountId: Long, accountId: Long,
inReplyToId: String?, inReplyToId: String?,
content: String?, content: String?,
contentWarning: String?, contentWarning: String?,
sensitive: Boolean, sensitive: Boolean,
visibility: Status.Visibility, visibility: Status.Visibility,
mediaUris: List<String>, mediaUris: List<String>,
mediaDescriptions: List<String?>, mediaDescriptions: List<String?>,
poll: NewPoll?, poll: NewPoll?,
failedToSend: Boolean failedToSend: Boolean
): Completable { ) = withContext(Dispatchers.IO) {
return Single.fromCallable { val externalFilesDir = context.getExternalFilesDir("Tusky")
val externalFilesDir = context.getExternalFilesDir("Tusky") if (externalFilesDir == null || !(externalFilesDir.exists())) {
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception()
}
if (externalFilesDir == null || !(externalFilesDir.exists())) { val draftDirectory = File(externalFilesDir, "Drafts")
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception() if (!draftDirectory.exists()) {
draftDirectory.mkdir()
}
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri
} }
}
val draftDirectory = File(externalFilesDir, "Drafts") val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
if (!draftDirectory.exists()) { when (mimeType?.substring(0, mimeType.indexOf('/'))) {
draftDirectory.mkdir() "video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
} }
}
val uris = mediaUris.map { uriString -> val attachments: MutableList<DraftAttachment> = mutableListOf()
uriString.toUri() for (i in mediaUris.indices) {
}.map { uri -> attachments.add(
if (uri.isNotInFolder(draftDirectory)) { DraftAttachment(
uri.copyToFolder(draftDirectory) uriString = uris[i].toString(),
} else { description = mediaDescriptions[i],
uri type = types[i]
}
}
val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
when (mimeType?.substring(0, mimeType.indexOf('/'))) {
"video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
}
}
val attachments: MutableList<DraftAttachment> = mutableListOf()
for (i in mediaUris.indices) {
attachments.add(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
type = types[i]
)
) )
}
DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
) )
}
}.flatMapCompletable { draft -> val draft = DraftEntity(
draftDao.insertOrReplace(draft) id = draftId,
}.subscribeOn(Schedulers.io()) accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
)
draftDao.insertOrReplace(draft)
} }
fun deleteDraftAndAttachments(draftId: Int): Completable { suspend fun deleteDraftAndAttachments(draftId: Int) {
return draftDao.find(draftId) draftDao.find(draftId)?.let { draft ->
.flatMapCompletable { draft -> deleteDraftAndAttachments(draft)
deleteDraftAndAttachments(draft) }
}
} }
fun deleteDraftAndAttachments(draft: DraftEntity): Completable { suspend fun deleteDraftAndAttachments(draft: DraftEntity) {
return deleteAttachments(draft) deleteAttachments(draft)
.andThen(draftDao.delete(draft.id)) draftDao.delete(draft.id)
} }
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId) draftDao.loadDrafts(accountId).forEach { draft ->
.flatMapObservable { Observable.fromIterable(it) } deleteDraftAndAttachments(draft)
.flatMapCompletable { draft -> }
deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io())
.subscribe()
} }
fun deleteAttachments(draft: DraftEntity): Completable { suspend fun deleteAttachments(draft: DraftEntity) {
return Completable.fromCallable { withContext(Dispatchers.IO) {
draft.attachments.forEach { attachment -> draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) { if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
} }
} }
}.subscribeOn(Schedulers.io()) }
} }
private fun Uri.isNotInFolder(folder: File): Boolean { private fun Uri.isNotInFolder(folder: File): Boolean {
@ -171,5 +162,4 @@ class DraftHelper @Inject constructor(
IOUtils.copyToFile(contentResolver, this, file) IOUtils.copyToFile(contentResolver, this, file)
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
} }
}
}

View File

@ -28,18 +28,17 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.db.DraftAttachment
class DraftMediaAdapter( class DraftMediaAdapter(
private val attachmentClick: () -> Unit private val attachmentClick: () -> Unit
) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>( ) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>(
object: DiffUtil.ItemCallback<DraftAttachment>() { object : DiffUtil.ItemCallback<DraftAttachment>() {
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
} }
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
}
) { ) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
@ -52,24 +51,24 @@ class DraftMediaAdapter(
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else { } else {
Glide.with(holder.itemView.context) Glide.with(holder.itemView.context)
.load(attachment.uri) .load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate() .dontAnimate()
.into(holder.imageView) .into(holder.imageView)
} }
} }
} }
inner class DraftMediaViewHolder(val imageView: ImageView) inner class DraftMediaViewHolder(val imageView: ImageView) :
: RecyclerView.ViewHolder(imageView) { RecyclerView.ViewHolder(imageView) {
init { init {
val thumbnailViewSize = val thumbnailViewSize =
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin) .getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom) layoutParams.setMargins(margin, 0, margin, marginBottom)
imageView.layoutParams = layoutParams imageView.layoutParams = layoutParams
imageView.scaleType = ImageView.ScaleType.CENTER_CROP imageView.scaleType = ImageView.ScaleType.CENTER_CROP
@ -78,4 +77,4 @@ class DraftMediaAdapter(
} }
} }
} }
} }

View File

@ -19,28 +19,26 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SavedTootActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.show import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.android.lifecycle.autoDispose import kotlinx.coroutines.flow.collectLatest
import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch
import io.reactivex.schedulers.Schedulers
import retrofit2.HttpException import retrofit2.HttpException
import javax.inject.Inject import javax.inject.Inject
@ -54,10 +52,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private lateinit var binding: ActivityDraftsBinding private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout> private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
private var oldDraftsButton: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater) binding = ActivityDraftsBinding.inflate(layoutInflater)
@ -70,7 +65,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status) binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts)
val adapter = DraftsAdapter(this) val adapter = DraftsAdapter(this)
@ -80,44 +75,15 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList -> lifecycleScope.launch {
if (draftList.isEmpty()) { viewModel.drafts.collectLatest { draftData ->
binding.draftsRecyclerView.hide() adapter.submitData(draftData)
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
} }
} }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { adapter.addLoadStateListener {
menuInflater.inflate(R.menu.drafts, menu) binding.draftsErrorMessageView.visible(adapter.itemCount == 0)
oldDraftsButton = menu.findItem(R.id.action_old_drafts)
viewModel.showOldDraftsButton()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { showOldDraftsButton ->
oldDraftsButton?.isVisible = showOldDraftsButton
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_old_drafts -> {
val intent = Intent(this, SavedTootActivity::class.java)
startActivityWithSlideInAnimation(intent)
return true
}
} }
return super.onOptionsItemSelected(item)
} }
override fun onOpenDraft(draft: DraftEntity) { override fun onOpenDraft(draft: DraftEntity) {
@ -125,27 +91,28 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (draft.inReplyToId != null) { if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId) viewModel.getToot(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this) .autoDispose(from(this))
.subscribe({ status -> .subscribe(
{ status ->
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, tootText = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId, inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(), replyingStatusContent = status.content.toString(),
replyingStatusAuthor = status.account.localUsername, replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments, draftAttachments = draft.attachments,
poll = draft.poll, poll = draft.poll,
sensitive = draft.sensitive, sensitive = draft.sensitive,
visibility = draft.visibility visibility = draft.visibility
) )
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(this, composeOptions))
},
}, { throwable -> { throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -158,9 +125,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
openDraftWithoutReply(draft) openDraftWithoutReply(draft)
} else { } else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
.show() .show()
} }
}) }
)
} else { } else {
openDraftWithoutReply(draft) openDraftWithoutReply(draft)
} }
@ -168,13 +136,13 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private fun openDraftWithoutReply(draft: DraftEntity) { private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, tootText = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
draftAttachments = draft.attachments, draftAttachments = draft.attachments,
poll = draft.poll, poll = draft.poll,
sensitive = draft.sensitive, sensitive = draft.sensitive,
visibility = draft.visibility visibility = draft.visibility
) )
startActivity(ComposeActivity.startIntent(this, composeOptions)) startActivity(ComposeActivity.startIntent(this, composeOptions))
@ -183,10 +151,10 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
override fun onDeleteDraft(draft: DraftEntity) { override fun onDeleteDraft(draft: DraftEntity) {
viewModel.deleteDraft(draft) viewModel.deleteDraft(draft)
Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) { .setAction(R.string.action_undo) {
viewModel.restoreDraft(draft) viewModel.restoreDraft(draft)
} }
.show() .show()
} }
companion object { companion object {

View File

@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.drafts
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagedListAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -34,17 +34,17 @@ interface DraftActionListener {
} }
class DraftsAdapter( class DraftsAdapter(
private val listener: DraftActionListener private val listener: DraftActionListener
) : PagedListAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>( ) : PagingDataAdapter<DraftEntity, BindingHolder<ItemDraftBinding>>(
object : DiffUtil.ItemCallback<DraftEntity>() { object : DiffUtil.ItemCallback<DraftEntity>() {
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem == newItem
}
} }
override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem == newItem
}
}
) { ) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemDraftBinding> {
@ -87,6 +87,5 @@ class DraftsAdapter(
holder.binding.draftPoll.hide() holder.binding.draftPoll.hide()
} }
} }
} }
} }

View File

@ -16,14 +16,17 @@
package com.keylesspalace.tusky.components.drafts package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.toLiveData import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.Observable import io.reactivex.rxjava3.core.Single
import io.reactivex.Single import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class DraftsViewModel @Inject constructor( class DraftsViewModel @Inject constructor(
@ -33,27 +36,28 @@ class DraftsViewModel @Inject constructor(
val draftHelper: DraftHelper val draftHelper: DraftHelper
) : ViewModel() { ) : ViewModel() {
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20) val drafts = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) }
).flow
.cachedIn(viewModelScope)
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf() private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun showOldDraftsButton(): Observable<Boolean> {
return database.tootDao().savedTootCount()
.map { count -> count > 0 }
}
fun deleteDraft(draft: DraftEntity) { fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations // this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft // in case the user decides to restore the draft
database.draftDao().delete(draft.id) viewModelScope.launch {
.subscribe() database.draftDao().delete(draft.id)
deletedDrafts.add(draft) deletedDrafts.add(draft)
}
} }
fun restoreDraft(draft: DraftEntity) { fun restoreDraft(draft: DraftEntity) {
database.draftDao().insertOrReplace(draft) viewModelScope.launch {
.subscribe() database.draftDao().insertOrReplace(draft)
deletedDrafts.remove(draft) deletedDrafts.remove(draft)
}
} }
fun getToot(tootId: String): Single<Status> { fun getToot(tootId: String): Single<Status> {
@ -61,9 +65,10 @@ class DraftsViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
deletedDrafts.forEach { viewModelScope.launch {
draftHelper.deleteAttachments(it).subscribe() deletedDrafts.forEach {
draftHelper.deleteAttachments(it)
}
} }
} }
} }

View File

@ -9,7 +9,7 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
import javax.inject.Inject import javax.inject.Inject
class InstanceListActivity: BaseActivity(), HasAndroidInjector { class InstanceListActivity : BaseActivity(), HasAndroidInjector {
@Inject @Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any> lateinit var androidInjector: DispatchingAndroidInjector<Any>
@ -27,11 +27,10 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
} }
supportFragmentManager supportFragmentManager
.beginTransaction() .beginTransaction()
.replace(R.id.fragment_container, InstanceListFragment()) .replace(R.id.fragment_container, InstanceListFragment())
.commit() .commit()
} }
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector
}
}

View File

@ -8,8 +8,8 @@ import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
class DomainMutesAdapter( class DomainMutesAdapter(
private val actionListener: InstanceActionListener private val actionListener: InstanceActionListener
): RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() { ) : RecyclerView.Adapter<BindingHolder<ItemMutedDomainBinding>>() {
var instances: MutableList<String> = mutableListOf() var instances: MutableList<String> = mutableListOf()
var bottomLoading: Boolean = false var bottomLoading: Boolean = false

View File

@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
@ -20,16 +22,14 @@ import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.EndlessOnScrollListener import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import com.uber.autodispose.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@Inject @Inject
lateinit var api: MastodonApi lateinit var api: MastodonApi
@ -65,7 +65,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
override fun mute(mute: Boolean, instance: String, position: Int) { override fun mute(mute: Boolean, instance: String, position: Int) {
if (mute) { if (mute) {
api.blockDomain(instance).enqueue(object: Callback<Any> { api.blockDomain(instance).enqueue(object : Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) { override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error muting domain $instance") Log.e(TAG, "Error muting domain $instance")
} }
@ -79,7 +79,7 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
} }
}) })
} else { } else {
api.unblockDomain(instance).enqueue(object: Callback<Any> { api.unblockDomain(instance).enqueue(object : Callback<Any> {
override fun onFailure(call: Call<Any>, t: Throwable) { override fun onFailure(call: Call<Any>, t: Throwable) {
Log.e(TAG, "Error unmuting domain $instance") Log.e(TAG, "Error unmuting domain $instance")
} }
@ -88,10 +88,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
if (response.isSuccessful) { if (response.isSuccessful) {
adapter.removeItem(position) adapter.removeItem(position)
Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) { .setAction(R.string.action_undo) {
mute(true, instance, position) mute(true, instance, position)
} }
.show() .show()
} else { } else {
Log.e(TAG, "Error unmuting domain $instance") Log.e(TAG, "Error unmuting domain $instance")
} }
@ -112,9 +112,10 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
} }
api.domainBlocks(id, bottomId) api.domainBlocks(id, bottomId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({ response -> .subscribe(
{ response ->
val instances = response.body() val instances = response.body()
if (response.isSuccessful && instances != null) { if (response.isSuccessful && instances != null) {
@ -122,9 +123,11 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
} else { } else {
onFetchInstancesFailure(Exception(response.message())) onFetchInstancesFailure(Exception(response.message()))
} }
}, {throwable -> },
{ throwable ->
onFetchInstancesFailure(throwable) onFetchInstancesFailure(throwable)
}) }
)
} }
private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) { private fun onFetchInstancesSuccess(instances: List<String>, linkHeader: String?) {
@ -141,9 +144,9 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
binding.messageView.show() binding.messageView.show()
binding.messageView.setup( binding.messageView.setup(
R.drawable.elephant_friend_empty, R.drawable.elephant_friend_empty,
R.string.message_empty, R.string.message_empty,
null null
) )
} else { } else {
binding.messageView.hide() binding.messageView.hide()
@ -174,4 +177,4 @@ class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectabl
companion object { companion object {
private const val TAG = "InstanceList" // logging tag private const val TAG = "InstanceList" // logging tag
} }
} }

View File

@ -2,4 +2,4 @@ package com.keylesspalace.tusky.components.instancemute.interfaces
interface InstanceActionListener { interface InstanceActionListener {
fun mute(mute: Boolean, instance: String, position: Int) fun mute(mute: Boolean, instance: String, position: Int)
} }

View File

@ -10,9 +10,9 @@ import com.keylesspalace.tusky.util.isLessThan
import javax.inject.Inject import javax.inject.Inject
class NotificationFetcher @Inject constructor( class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val notifier: Notifier private val notifier: Notifier
) { ) {
fun fetchAndShow() { fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) { for (account in accountManager.getAllAccountsOrderedByActive()) {
@ -39,9 +39,9 @@ class NotificationFetcher @Inject constructor(
} }
Log.d(TAG, "getting Notifications for " + account.fullName) Log.d(TAG, "getting Notifications for " + account.fullName)
val notifications = mastodonApi.notificationsWithAuth( val notifications = mastodonApi.notificationsWithAuth(
authHeader, authHeader,
account.domain, account.domain,
account.lastNotificationId account.lastNotificationId
).blockingGet() ).blockingGet()
val newId = account.lastNotificationId val newId = account.lastNotificationId
@ -63,9 +63,9 @@ class NotificationFetcher @Inject constructor(
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
return try { return try {
val allMarkers = mastodonApi.markersWithAuth( val allMarkers = mastodonApi.markersWithAuth(
authHeader, authHeader,
account.domain, account.domain,
listOf("notifications") listOf("notifications")
).blockingGet() ).blockingGet()
val notificationMarker = allMarkers["notifications"] val notificationMarker = allMarkers["notifications"]
Log.d(TAG, "Fetched marker: $notificationMarker") Log.d(TAG, "Fetched marker: $notificationMarker")
@ -79,4 +79,4 @@ class NotificationFetcher @Inject constructor(
companion object { companion object {
const val TAG = "NotificationFetcher" const val TAG = "NotificationFetcher"
} }
} }

View File

@ -70,8 +70,8 @@ import java.util.List;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import io.reactivex.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
@ -316,7 +316,7 @@ public class NotificationHelper {
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText(); String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions(); List<Status.Mention> mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>(); List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername()); mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) { for (Status.Mention mention : mentions) {
@ -381,7 +381,6 @@ public class NotificationHelper {
NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName()); NotificationChannelGroup channelGroup = new NotificationChannelGroup(account.getIdentifier(), account.getFullName());
//noinspection ConstantConditions
notificationManager.createNotificationChannelGroup(channelGroup); notificationManager.createNotificationChannelGroup(channelGroup);
for (int i = 0; i < channelIds.length; i++) { for (int i = 0; i < channelIds.length; i++) {

View File

@ -23,9 +23,9 @@ import androidx.work.WorkerParameters
import javax.inject.Inject import javax.inject.Inject
class NotificationWorker( class NotificationWorker(
context: Context, context: Context,
params: WorkerParameters, params: WorkerParameters,
private val notificationsFetcher: NotificationFetcher private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) { ) : Worker(context, params) {
override fun doWork(): Result { override fun doWork(): Result {
@ -35,13 +35,13 @@ class NotificationWorker(
} }
class NotificationWorkerFactory @Inject constructor( class NotificationWorkerFactory @Inject constructor(
private val notificationsFetcher: NotificationFetcher private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() { ) : WorkerFactory() {
override fun createWorker( override fun createWorker(
appContext: Context, appContext: Context,
workerClassName: String, workerClassName: String,
workerParameters: WorkerParameters workerParameters: WorkerParameters
): ListenableWorker? { ): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) { if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher) return NotificationWorker(appContext, workerParameters, notificationsFetcher)

View File

@ -12,9 +12,9 @@ interface Notifier {
} }
class SystemNotifier( class SystemNotifier(
private val context: Context private val context: Context
) : Notifier { ) : Notifier {
override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) { override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) {
NotificationHelper.make(context, notification, account, isFirstInBatch) NotificationHelper.make(context, notification, account, isFirstInBatch)
} }
} }

View File

@ -22,7 +22,11 @@ import android.util.Log
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.* import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
@ -33,7 +37,12 @@ import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.* import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.settings.listPreference
import com.keylesspalace.tusky.settings.makePreferenceScreen
import com.keylesspalace.tusky.settings.preference
import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -75,8 +84,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, TabPreferenceActivity::class.java) val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -88,8 +99,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES) intent.putExtra("type", AccountListActivity.Type.MUTES)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -104,8 +117,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
val intent = Intent(context, AccountListActivity::class.java) val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.BLOCKS) intent.putExtra("type", AccountListActivity.Type.BLOCKS)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -116,8 +131,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = Intent(context, InstanceListActivity::class.java) val intent = Intent(context, InstanceListActivity::class.java)
activity?.startActivity(intent) activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, activity?.overridePendingTransition(
R.anim.slide_to_left) R.anim.slide_from_right,
R.anim.slide_to_left
)
true true
} }
} }
@ -130,7 +147,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
key = PrefKeys.DEFAULT_POST_PRIVACY key = PrefKeys.DEFAULT_POST_PRIVACY
setSummaryProvider { entry } setSummaryProvider { entry }
val visibility = accountManager.activeAccount?.defaultPostPrivacy val visibility = accountManager.activeAccount?.defaultPostPrivacy
?: Status.Visibility.PUBLIC ?: Status.Visibility.PUBLIC
value = visibility.serverString() value = visibility.serverString()
setIcon(getIconForVisibility(visibility)) setIcon(getIconForVisibility(visibility))
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -147,7 +164,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY
isSingleLineTitle = false isSingleLineTitle = false
val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity
?: false ?: false
setDefaultValue(sensitivity) setDefaultValue(sensitivity)
setIcon(getIconForSensitivity(sensitivity)) setIcon(getIconForSensitivity(sensitivity))
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -201,8 +218,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preference { preference {
setTitle(R.string.pref_title_public_filter_keywords) setTitle(R.string.pref_title_public_filter_keywords)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity(Filter.PUBLIC, launchFilterActivity(
R.string.pref_title_public_filter_keywords) Filter.PUBLIC,
R.string.pref_title_public_filter_keywords
)
true true
} }
} }
@ -226,8 +245,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preference { preference {
setTitle(R.string.pref_title_thread_filter_keywords) setTitle(R.string.pref_title_thread_filter_keywords)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity(Filter.THREAD, launchFilterActivity(
R.string.pref_title_thread_filter_keywords) Filter.THREAD,
R.string.pref_title_thread_filter_keywords
)
true true
} }
} }
@ -255,7 +276,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
it.startActivity(intent) it.startActivity(intent)
it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
} }
} }
} }
@ -268,36 +288,35 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) { private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null) {
mastodonApi.accountUpdateSource(visibility, sensitive) mastodonApi.accountUpdateSource(visibility, sensitive)
.enqueue(object : Callback<Account> { .enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>, response: Response<Account>) { override fun onResponse(call: Call<Account>, response: Response<Account>) {
val account = response.body() val account = response.body()
if (response.isSuccessful && account != null) { if (response.isSuccessful && account != null) {
accountManager.activeAccount?.let { accountManager.activeAccount?.let {
it.defaultPostPrivacy = account.source?.privacy it.defaultPostPrivacy = account.source?.privacy
?: Status.Visibility.PUBLIC ?: Status.Visibility.PUBLIC
it.defaultMediaSensitivity = account.source?.sensitive ?: false it.defaultMediaSensitivity = account.source?.sensitive ?: false
accountManager.saveAccount(it) accountManager.saveAccount(it)
}
} else {
Log.e("AccountPreferences", "failed updating settings on server")
showErrorSnackbar(visibility, sensitive)
} }
} } else {
Log.e("AccountPreferences", "failed updating settings on server")
override fun onFailure(call: Call<Account>, t: Throwable) {
Log.e("AccountPreferences", "failed updating settings on server", t)
showErrorSnackbar(visibility, sensitive) showErrorSnackbar(visibility, sensitive)
} }
}
}) override fun onFailure(call: Call<Account>, t: Throwable) {
Log.e("AccountPreferences", "failed updating settings on server", t)
showErrorSnackbar(visibility, sensitive)
}
})
} }
private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) { private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) {
view?.let { view -> view?.let { view ->
Snackbar.make(view, R.string.pref_failed_to_sync, Snackbar.LENGTH_LONG) Snackbar.make(view, R.string.pref_failed_to_sync, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) } .setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) }
.show() .show()
} }
} }

View File

@ -25,8 +25,8 @@ import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -34,8 +34,8 @@ import kotlin.system.exitProcess
* This Preference lets the user select their preferred emoji font * This Preference lets the user select their preferred emoji font
*/ */
class EmojiPreference( class EmojiPreference(
context: Context, context: Context,
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient
) : Preference(context) { ) : Preference(context) {
private lateinit var selected: EmojiCompatFont private lateinit var selected: EmojiCompatFont
@ -51,7 +51,7 @@ class EmojiPreference(
// Find out which font is currently active // Find out which font is currently active
selected = EmojiCompatFont.byId( selected = EmojiCompatFont.byId(
PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0)
) )
// We'll use this later to determine if anything has changed // We'll use this later to determine if anything has changed
original = selected original = selected
@ -67,10 +67,10 @@ class EmojiPreference(
setupItem(SYSTEM_DEFAULT, binding.itemNomoji) setupItem(SYSTEM_DEFAULT, binding.itemNomoji)
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setView(binding.root) .setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
@ -100,32 +100,30 @@ class EmojiPreference(
binding.emojiProgress.progress = 0 binding.emojiProgress.progress = 0
binding.emojiDownloadCancel.show() binding.emojiDownloadCancel.show()
font.downloadFontFile(context, okHttpClient) font.downloadFontFile(context, okHttpClient)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ progress -> { progress ->
// The progress is returned as a float between 0 and 1, or -1 if it could not determined // The progress is returned as a float between 0 and 1, or -1 if it could not determined
if (progress >= 0) { if (progress >= 0) {
binding.emojiProgress.isIndeterminate = false binding.emojiProgress.isIndeterminate = false
val max = binding.emojiProgress.max.toFloat() val max = binding.emojiProgress.max.toFloat()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
binding.emojiProgress.setProgress((max * progress).toInt(), true) binding.emojiProgress.setProgress((max * progress).toInt(), true)
} else { } else {
binding.emojiProgress.progress = (max * progress).toInt() binding.emojiProgress.progress = (max * progress).toInt()
}
} else {
binding.emojiProgress.isIndeterminate = true
}
},
{
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
updateItem(font, binding)
},
{
finishDownload(font, binding)
} }
).also { downloadDisposables[font.id] = it } } else {
binding.emojiProgress.isIndeterminate = true
}
},
{
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
updateItem(font, binding)
},
{
finishDownload(font, binding)
}
).also { downloadDisposables[font.id] = it }
} }
private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
@ -197,10 +195,10 @@ class EmojiPreference(
val index = selected.id val index = selected.id
Log.i(TAG, "saveSelectedFont: Font ID: $index") Log.i(TAG, "saveSelectedFont: Font ID: $index")
PreferenceManager PreferenceManager
.getDefaultSharedPreferences(context) .getDefaultSharedPreferences(context)
.edit() .edit()
.putInt(key, index) .putInt(key, index)
.apply() .apply()
summary = selected.getDisplay(context) summary = selected.getDisplay(context)
} }
@ -211,29 +209,31 @@ class EmojiPreference(
saveSelectedFont() saveSelectedFont()
if (selected !== original || updated) { if (selected !== original || updated) {
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle(R.string.restart_required) .setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji) .setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null) .setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart) { _, _ -> .setPositiveButton(R.string.restart) { _, _ ->
// Restart the app // Restart the app
// From https://stackoverflow.com/a/17166729/5070653 // From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java) val launchIntent = Intent(context, SplashActivity::class.java)
val mPendingIntent = PendingIntent.getActivity( val mPendingIntent = PendingIntent.getActivity(
context, context,
0x1f973, // This is the codepoint of the party face emoji :D 0x1f973, // This is the codepoint of the party face emoji :D
launchIntent, launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT) PendingIntent.FLAG_CANCEL_CURRENT
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager )
mgr.set( val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
AlarmManager.RTC, mgr.set(
System.currentTimeMillis() + 100, AlarmManager.RTC,
mPendingIntent) System.currentTimeMillis() + 100,
exitProcess(0) mPendingIntent
}.show() )
exitProcess(0)
}.show()
} }
} }
companion object { companion object {
private const val TAG = "EmojiPreference" private const val TAG = "EmojiPreference"
} }
} }

View File

@ -111,7 +111,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true true
} }
} }
switchPreference { switchPreference {
setTitle(R.string.pref_title_notification_filter_subscriptions) setTitle(R.string.pref_title_notification_filter_subscriptions)
key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS key = PrefKeys.NOTIFICATION_FILTER_SUBSCRIPTIONS
@ -176,5 +176,4 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
return NotificationPreferencesFragment() return NotificationPreferencesFragment()
} }
} }
} }

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