Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2022-03-04 17:51:25 +09:00
commit ba005c769b
137 changed files with 3977 additions and 1918 deletions

View File

@ -90,15 +90,19 @@ android {
enableSplit = false
}
}
kotlinOptions {
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
}
ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1"
ext.roomVersion = '2.3.0'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.1'
ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0'
ext.daggerVersion = '2.37'
ext.materialdrawerVersion = '8.4.1'
ext.daggerVersion = '2.40.5'
ext.materialdrawerVersion = '8.4.5'
repositories {
maven {
@ -110,8 +114,8 @@ repositories {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-rx3:1.5.0'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.appcompat:appcompat:1.3.0"
@ -119,7 +123,7 @@ dependencies {
implementation "androidx.browser:browser:1.3.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.2"
implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.1.0"
@ -129,7 +133,7 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.paging:paging-runtime-ktx:3.0.0"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.0"
@ -137,7 +141,9 @@ dependencies {
implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.3.0"
implementation "com.google.android.material:material:1.4.0"
implementation "com.google.code.gson:gson:2.8.9"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
@ -152,7 +158,7 @@ dependencies {
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.12.0"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.17.0"
implementation "io.reactivex.rxjava3:rxjava:3.0.12"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
@ -173,23 +179,23 @@ dependencies {
implementation "com.mikepenz:materialdrawer:$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:4.0.0.2-kotlin@aar'
implementation "com.github.CanHub:Android-Image-Cropper:3.1.0"
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0"
implementation "de.c1710:filemojicompat:1.0.18"
testImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.2"
androidTestImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "androidx.arch.core:core-testing:2.1.0"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0'
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
implementation 'net.accelf:easter:1.0.2'
implementation 'org.jsoup:jsoup:1.13.1'

View File

@ -33,6 +33,10 @@
public static final ** CREATOR;
}
-keepclassmembers class **.R$* {
public static <fields>;
}
# TUSKY SPECIFIC OPTIONS
# keep members of our model classes, they are used in json de/serialization
@ -43,10 +47,37 @@
public *;
}
-keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; }
-keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; }
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *;
}
# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of classes used in MastodonApi so Retrofit works
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.collections.List
-keep,allowobfuscation,allowshrinking class kotlin.collections.Map
-keep,allowobfuscation,allowshrinking class retrofit2.Call
# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit
-keepattributes Signature
-keep class kotlin.coroutines.Continuation
# preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,789 @@
{
"formatVersion": 1,
"database": {
"version": 29,
"identityHash": "62c289344334da2db091ad4ba0a49c6a",
"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 NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, 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": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"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_tags` TEXT, `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.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.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, '62c289344334da2db091ad4ba0a49c6a')"
]
}
}

View File

@ -0,0 +1,807 @@
{
"formatVersion": 1,
"database": {
"version": 30,
"identityHash": "a75615171612bdfc9e3d4201ebf6071a",
"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, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` 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": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"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 NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, 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": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"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_tags` TEXT, `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.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.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, 'a75615171612bdfc9e3d4201ebf6071a')"
]
}
}

View File

@ -112,7 +112,7 @@
android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" />
<activity
android:name=".AccountActivity"
android:name=".components.account.AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" />
<activity android:name=".components.preference.PreferencesActivity" />

View File

@ -197,6 +197,33 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
.show();
}
public @Nullable String getOpenAsText() {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
switch (accounts.size()) {
case 0:
case 1:
return null;
case 2:
for (AccountEntity account : accounts) {
if (account != accountManager.getActiveAccount()) {
return String.format(getString(R.string.action_open_as), account.getFullName());
}
}
return null;
default:
return String.format(getString(R.string.action_open_as), "");
}
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account);
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.REDIRECT_URL, url);
startActivity(intent);
finishWithoutSlideOutAnimation();
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
@ -25,8 +26,9 @@ import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider
import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.openLink
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.net.URI
import java.net.URISyntaxException
@ -163,9 +165,9 @@ abstract class BottomSheetActivity : BaseActivity() {
}
}
@VisibleForTesting
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
open fun openLink(url: String) {
LinkHelper.openLink(url, this)
(this as Context).openLink(url)
}
private fun showQuerySheet() {
@ -186,6 +188,8 @@ abstract class BottomSheetActivity : BaseActivity() {
// https://friendica.foo.bar/profile/user
// https://friendica.foo.bar/display/d4643c42-3ae0-4b73-b8b0-c725f5819207
// https://misskey.foo.bar/notes/83w6r388br (always lowercase)
// https://pixelfed.social/p/connyduck/391263492998670833
// https://pixelfed.social/connyduck
// https://mastodon.foo.bar/users/User/statuses/000000000000000000
fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
@ -211,6 +215,8 @@ fun looksLikeMastodonUrl(urlString: String): Boolean {
path.matches("^/notes/[a-z0-9]+$".toRegex()) ||
path.matches("^/display/[-a-f0-9]+$".toRegex()) ||
path.matches("^/profile/\\w+$".toRegex()) ||
path.matches("^/p/\\w+/\\d+$".toRegex()) ||
path.matches("^/\\w+$".toRegex()) ||
path.matches("^/users/[^/]+/statuses/\\d+$".toRegex())
}

View File

@ -15,28 +15,26 @@
package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
@ -44,9 +42,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
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
@ -63,12 +59,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val HEADER_WIDTH = 1500
const val HEADER_HEIGHT = 500
private const val AVATAR_PICK_RESULT = 1
private const val HEADER_PICK_RESULT = 2
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
private const val MAX_ACCOUNT_FIELDS = 4
private const val BUNDLE_CURRENTLY_PICKING = "BUNDLE_CURRENTLY_PICKING"
}
@Inject
@ -78,23 +69,28 @@ class EditProfileActivity : BaseActivity(), Injectable {
private val binding by viewBinding(ActivityEditProfileBinding::inflate)
private var currentlyPicking: PickType = PickType.NOTHING
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private enum class PickType {
NOTHING,
AVATAR,
HEADER
}
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
if (result.isSuccessful) {
if (result.uriContent == viewModel.getAvatarUri()) {
viewModel.newAvatarPicked()
} else {
viewModel.newHeaderPicked()
}
} else {
onPickFailure(result.error)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
savedInstanceState?.getString(BUNDLE_CURRENTLY_PICKING)?.let {
currentlyPicking = PickType.valueOf(it)
}
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
@ -104,8 +100,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
setDisplayShowHomeEnabled(true)
}
binding.avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) }
binding.headerButton.setOnClickListener { onMediaPick(PickType.HEADER) }
binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) }
binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) }
binding.fieldList.layoutManager = LinearLayoutManager(this)
binding.fieldList.adapter = accountFieldEditAdapter
@ -159,51 +155,44 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
is Error -> {
val snackbar = Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
snackbar.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
snackbar.show()
Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) {
viewModel.obtainProfile()
}
.show()
}
is Loading -> { }
}
}
viewModel.obtainInstance()
viewModel.instanceData.observe(this) { result ->
when (result) {
is Success -> {
val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
}
if (result is Success) {
val instance = result.data
if (instance?.maxBioChars != null && instance.maxBioChars > 0) {
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars
}
}
}
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false)
observeImage(viewModel.avatarData, binding.avatarPreview, true)
observeImage(viewModel.headerData, binding.headerPreview, false)
viewModel.saveData.observe(
this,
{
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
this
) {
when (it) {
is Success -> {
finish()
}
is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE
}
is Error -> {
onSaveFailure(it.errorMessage)
}
}
)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(BUNDLE_CURRENTLY_PICKING, currentlyPicking.toString())
}
}
override fun onStop() {
@ -219,89 +208,60 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun observeImage(
liveData: LiveData<Resource<Bitmap>>,
liveData: LiveData<Uri>,
imageView: ImageView,
progressBar: View,
roundedCorners: Boolean
) {
liveData.observe(
this,
{
this
) { imageUri ->
when (it) {
is Success -> {
val glide = Glide.with(imageView)
.load(it.data)
// skipping all caches so we can always reuse the same uri
val glide = Glide.with(imageView)
.load(imageUri)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
)
}
glide.into(imageView)
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if (!it.consumed) {
onResizeFailure()
it.consumed = true
}
}
}
if (roundedCorners) {
glide.transform(
FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
).into(imageView)
} else {
glide.into(imageView)
}
)
}
private fun onMediaPick(pickType: PickType) {
if (currentlyPicking != PickType.NOTHING) {
// Ignore inputs if another pick operation is still occurring.
return
}
currentlyPicking = pickType
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
} else {
initiateMediaPicking()
imageView.show()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
when (requestCode) {
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
initiateMediaPicking()
} else {
endMediaPicking()
Snackbar.make(binding.avatarButton, R.string.error_media_upload_permission, Snackbar.LENGTH_LONG).show()
}
}
}
}
private fun initiateMediaPicking() {
private fun pickMedia(pickType: PickType) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
when (currentlyPicking) {
when (pickType) {
PickType.AVATAR -> {
startActivityForResult(intent, AVATAR_PICK_RESULT)
cropImage.launch(
options {
setRequestedSize(AVATAR_SIZE, AVATAR_SIZE)
setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
setImageSource(includeGallery = true, includeCamera = false)
setOutputUri(viewModel.getAvatarUri())
setOutputCompressFormat(Bitmap.CompressFormat.PNG)
}
)
}
PickType.HEADER -> {
startActivityForResult(intent, HEADER_PICK_RESULT)
cropImage.launch(
options {
setRequestedSize(HEADER_WIDTH, HEADER_HEIGHT)
setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
setImageSource(includeGallery = true, includeCamera = false)
setOutputUri(viewModel.getHeaderUri())
setOutputCompressFormat(Bitmap.CompressFormat.PNG)
}
)
}
PickType.NOTHING -> { /* do nothing */ }
}
}
@ -321,16 +281,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
private fun save() {
if (currentlyPicking != PickType.NOTHING) {
return
}
viewModel.save(
binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(),
this
accountFieldEditAdapter.getFieldData()
)
}
@ -340,90 +295,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.saveProgressBar.visibility = View.GONE
}
private fun beginMediaPicking() {
when (currentlyPicking) {
PickType.AVATAR -> {
binding.avatarProgressBar.visibility = View.VISIBLE
binding.avatarPreview.visibility = View.INVISIBLE
binding.avatarButton.setImageDrawable(null)
}
PickType.HEADER -> {
binding.headerProgressBar.visibility = View.VISIBLE
binding.headerPreview.visibility = View.INVISIBLE
binding.headerButton.setImageDrawable(null)
}
PickType.NOTHING -> { /* do nothing */ }
}
}
private fun endMediaPicking() {
binding.avatarProgressBar.visibility = View.GONE
binding.headerProgressBar.visibility = View.GONE
currentlyPicking = PickType.NOTHING
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
AVATAR_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(AVATAR_SIZE, AVATAR_SIZE)
.start(this)
} else {
endMediaPicking()
}
}
HEADER_PICK_RESULT -> {
if (resultCode == Activity.RESULT_OK && data != null) {
CropImage.activity(data.data)
.setInitialCropWindowPaddingRatio(0f)
.setOutputCompressFormat(Bitmap.CompressFormat.PNG)
.setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT)
.start(this)
} else {
endMediaPicking()
}
}
CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE -> {
val result = CropImage.getActivityResult(data)
when (resultCode) {
Activity.RESULT_OK -> beginResize(result?.uriContent)
CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE -> onResizeFailure()
else -> endMediaPicking()
}
}
}
}
private fun beginResize(uri: Uri?) {
if (uri == null) {
currentlyPicking = PickType.NOTHING
return
}
beginMediaPicking()
when (currentlyPicking) {
PickType.AVATAR -> {
viewModel.newAvatar(uri, this)
}
PickType.HEADER -> {
viewModel.newHeader(uri, this)
}
else -> {
throw AssertionError("PickType not set.")
}
}
currentlyPicking = PickType.NOTHING
}
private fun onResizeFailure() {
private fun onPickFailure(throwable: Throwable?) {
Log.w("EditProfileActivity", "failed to pick media", throwable)
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking()
}
}

View File

@ -40,7 +40,6 @@ import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityListsBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
@ -201,7 +200,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun onListSelected(listId: String) {
startActivityWithSlideInAnimation(
ModalTimelineActivity.newIntent(this, TimelineViewModel.Kind.LIST, listId)
StatusListActivity.newListIntent(this, listId)
)
}

View File

@ -60,7 +60,14 @@ import com.bumptech.glide.request.transition.Transition
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.Event
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
@ -83,7 +90,14 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.ResettableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.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.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -92,9 +106,25 @@ import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.*
import com.mikepenz.materialdrawer.model.interfaces.*
import com.mikepenz.materialdrawer.util.*
import com.mikepenz.materialdrawer.model.AbstractDrawerItem
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.AbstractDrawerImageLoader
import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.util.addItems
import com.mikepenz.materialdrawer.util.addItemsAtPosition
import com.mikepenz.materialdrawer.util.addStickyDrawerItems
import com.mikepenz.materialdrawer.util.updateBadge
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
@ -356,9 +386,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onPostCreate(savedInstanceState)
if (intent != null) {
val statusUrl = intent.getStringExtra(STATUS_URL)
if (statusUrl != null) {
viewUrl(statusUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
val redirectUrl = intent.getStringExtra(REDIRECT_URL)
if (redirectUrl != null) {
viewUrl(redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
}
}
}
@ -991,7 +1021,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl"
const val REDIRECT_URL = "redirectUrl"
}
}

View File

@ -1,83 +0,0 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import autodispose2.androidx.lifecycle.autoDispose
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.databinding.ActivityModalTimelineBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import net.accelf.yuito.QuickTootViewModel
import javax.inject.Inject
class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val quickTootViewModel: QuickTootViewModel by viewModels { viewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityModalTimelineBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
title = getString(R.string.title_list_timeline)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
if (supportFragmentManager.findFragmentById(R.id.contentFrame) == null) {
val kind = intent?.getSerializableExtra(ARG_KIND) as? TimelineViewModel.Kind
?: TimelineViewModel.Kind.HOME
val argument = intent?.getStringExtra(ARG_ARG)
supportFragmentManager.beginTransaction()
.replace(R.id.contentFrame, TimelineFragment.newInstance(kind, argument))
.commit()
}
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(binding.viewQuickToot::handleEvent)
binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked)
}
override fun getActionButton(): FloatingActionButton? = null
override fun androidInjector() = dispatchingAndroidInjector
companion object {
private const val ARG_KIND = "kind"
private const val ARG_ARG = "arg"
@JvmStatic
fun newIntent(
context: Context,
kind: TimelineViewModel.Kind,
argument: String?
): Intent {
val intent = Intent(context, ModalTimelineActivity::class.java)
intent.putExtra(ARG_KIND, kind)
intent.putExtra(ARG_ARG, argument)
return intent
}
}
}

View File

@ -44,9 +44,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
private val quickTootViewModel: QuickTootViewModel by viewModels{ viewModelFactory }
private val kind: Kind
get() = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityStatuslistBinding.inflate(layoutInflater)
@ -54,10 +51,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)
val title = if (kind == Kind.FAVOURITES) {
R.string.title_favourites
} else {
R.string.title_bookmarks
val kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
val listId = intent.getStringExtra(EXTRA_LIST_ID)
val hashtag = intent.getStringExtra(EXTRA_HASHTAG)
val title = when (kind) {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
else -> getString(R.string.title_list_timeline)
}
supportActionBar?.run {
@ -66,9 +68,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setDisplayShowHomeEnabled(true)
}
supportFragmentManager.commit {
val fragment = TimelineFragment.newInstance(kind)
replace(R.id.fragment_container, fragment)
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = if (kind == Kind.TAG) {
TimelineFragment.newHashtagInstance(listOf(hashtag!!))
} else {
TimelineFragment.newInstance(kind, listId)
}
replace(R.id.fragmentContainer, fragment)
}
}
binding.viewQuickToot.attachViewModel(quickTootViewModel, this)
@ -85,17 +93,30 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object {
private const val EXTRA_KIND = "kind"
private const val EXTRA_LIST_ID = "id"
private const val EXTRA_HASHTAG = "tag"
@JvmStatic
fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
}
@JvmStatic
fun newBookmarksIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
}
fun newListIntent(context: Context, listId: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.LIST.name)
putExtra(EXTRA_LIST_ID, listId)
}
@JvmStatic
fun newHashtagIntent(context: Context, hashtag: String) =
Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.TAG.name)
putExtra(EXTRA_HASHTAG, hashtag)
}
}
}

View File

@ -1,79 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import com.keylesspalace.tusky.components.timeline.TimelineFragment;
import java.util.Collections;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.HasAndroidInjector;
public class ViewTagActivity extends BottomSheetActivity implements HasAndroidInjector {
private static final String HASHTAG = "hashtag";
@Inject
public DispatchingAndroidInjector<Object> dispatchingAndroidInjector;
public static Intent getIntent(Context context, String tag){
Intent intent = new Intent(context,ViewTagActivity.class);
intent.putExtra(HASHTAG,tag);
return intent;
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_view_tag);
String hashtag = getIntent().getStringExtra(HASHTAG);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
bar.setTitle(String.format(getString(R.string.title_tag), hashtag));
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
Fragment fragment = TimelineFragment.newHashtagInstance(Collections.singletonList(hashtag));
fragmentTransaction.replace(R.id.fragment_container, fragment);
fragmentTransaction.commit();
}
@Override
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;
}
}

View File

@ -111,7 +111,7 @@ public class ViewThreadActivity extends BottomSheetActivity implements HasAndroi
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_open_in_web: {
LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this);
openLink(getIntent().getStringExtra(URL_EXTRA));
return true;
}
case R.id.action_reveal: {

View File

@ -663,7 +663,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) {

View File

@ -38,6 +38,7 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode;
@ -211,6 +212,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable List<Status.Mention> mentions,
@Nullable List<HashTag> tags,
@NonNull List<Emoji> emojis,
@Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions,
@ -231,13 +233,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
setContentWarningButtonText(!expanded);
this.setTextVisible(sensitive, !expanded, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
});
this.setTextVisible(sensitive, expanded, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
} else {
contentWarningDescription.setVisibility(View.GONE);
contentWarningButton.setVisibility(View.GONE);
this.setTextVisible(sensitive, true, content, mentions, emojis, poll, statusDisplayOptions, listener);
this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener);
}
}
@ -253,13 +255,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
boolean expanded,
Spanned content,
List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis,
@Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener);
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded);
}
@ -879,7 +882,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
if (cardView != null) {
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener);
}
setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(),
@ -888,7 +891,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setQuoteEnabled(actionable.rebloggingAllowed() && !actionable.isNotestock(), actionable.getVisibility());
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
actionable.getMentions(), actionable.getEmojis(),
actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
listener);
@ -1143,7 +1146,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo);
}
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
protected void setupCard(
StatusViewData.Concrete status,
CardViewMode cardViewMode,
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
) {
final Card card = status.getActionable().getCard();
if (cardViewMode != CardViewMode.NONE &&
status.getActionable().getAttachments().size() == 0 &&
@ -1234,7 +1242,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setImageResource(R.drawable.card_image_placeholder);
}
View.OnClickListener visitLink = v -> LinkHelper.openLink(card.getUrl(), v.getContext());
View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl(), "");
View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url()));
cardInfo.setOnClickListener(visitLink);

View File

@ -110,7 +110,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions); // Always show card for detailed status
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) {
if (!statusDisplayOptions.hideStats()) {

View File

@ -3,15 +3,12 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
private val accountManager: AccountManager,
private val appDatabase: AppDatabase,
gson: Gson
) {
@ -21,11 +18,6 @@ class CacheUpdater @Inject constructor(
init {
val timelineDao = appDatabase.timelineDao()
Schedulers.io().scheduleDirect {
val olderThan = System.currentTimeMillis() - CLEANUP_INTERVAL
appDatabase.timelineDao().cleanup(olderThan)
}
disposable = eventHub.events.subscribe { event ->
val accountId = accountManager.activeAccount?.id ?: return@subscribe
when (event) {
@ -53,16 +45,7 @@ class CacheUpdater @Inject constructor(
this.disposable.dispose()
}
fun clearForUser(accountId: Long) {
Single.fromCallable {
appDatabase.timelineDao().removeAllForAccount(accountId)
appDatabase.timelineDao().removeAllUsersForAccount(accountId)
}
.subscribeOn(Schedulers.io())
.subscribe()
}
companion object {
val CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(14)
suspend fun clearForUser(accountId: Long) {
appDatabase.timelineDao().removeAll(accountId)
}
}

View File

@ -2,21 +2,19 @@ package com.keylesspalace.tusky.appstore
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import javax.inject.Inject
import javax.inject.Singleton
interface Event
interface Dispatchable : Event
interface EventHub {
val events: Observable<Event>
fun dispatch(event: Dispatchable)
}
object EventHubImpl : EventHub {
@Singleton
class EventHub @Inject constructor() {
private val eventsSubject = PublishSubject.create<Event>()
override val events: Observable<Event> = eventsSubject
val events: Observable<Event> = eventsSubject
override fun dispatch(event: Dispatchable) {
fun dispatch(event: Dispatchable) {
eventsSubject.onNext(event)
}
}

View File

@ -13,7 +13,7 @@
* 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
package com.keylesspalace.tusky.components.account
import android.animation.ArgbEvaluator
import android.content.Context
@ -51,30 +51,39 @@ import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.setClickableText
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.viewmodel.AccountViewModel
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import java.text.NumberFormat
@ -258,8 +267,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val toolbarParams = binding.accountToolbar.layoutParams as ViewGroup.MarginLayoutParams
toolbarParams.topMargin = top
val right = insets.getInsets(systemBars()).right
val bottom = insets.getInsets(systemBars()).bottom
binding.accountCoordinatorLayout.updatePadding(bottom = bottom)
val left = insets.getInsets(systemBars()).left
binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left)
WindowInsetsCompat.CONSUMED
}
@ -353,6 +364,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.setAction(R.string.action_retry) { viewModel.refresh() }
.show()
}
is Loading -> { }
}
}
viewModel.relationshipData.observe(this) {
@ -404,7 +416,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList()
@ -446,7 +458,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.into(binding.accountHeaderImageView)
binding.accountAvatarImageView.setOnClickListener { avatarView ->
val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
val intent =
ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
avatarView.transitionName = account.avatar
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
@ -511,7 +524,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (account.isRemote()) {
binding.accountRemoveView.show()
binding.accountRemoveView.setOnClickListener {
LinkHelper.openLink(account.url, this)
openLink(account.url)
}
}
}
@ -674,6 +687,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.account_toolbar, menu)
val openAsItem = menu.findItem(R.id.action_open_as)
val title = openAsText
if (title == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = title
}
if (!viewModel.isSelf) {
val block = menu.findItem(R.id.action_block)
@ -692,7 +713,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (loadedAccount != null) {
val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = LinkHelper.getDomain(loadedAccount?.url)
domain = getDomain(loadedAccount?.url)
if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain)
@ -793,8 +814,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
override fun onViewTag(tag: String) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
val intent = StatusListActivity.newHashtagIntent(this, tag)
startActivityWithSlideInAnimation(intent)
}
@ -812,11 +832,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
when (item.itemId) {
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
if (loadedAccount != null) {
LinkHelper.openLink(loadedAccount?.url, this)
if (loadedAccount?.url != null) {
openLink(loadedAccount!!.url)
}
return true
}
R.id.action_open_as -> {
if (loadedAccount != null) {
showAccountChooserDialog(
item.title, false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(loadedAccount!!.url, account)
}
}
)
}
}
R.id.action_block -> {
toggleBlock()
return true

View File

@ -13,7 +13,7 @@
* 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
package com.keylesspalace.tusky.components.account
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@ -27,8 +27,9 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
private val linkListener: LinkListener,
@ -54,7 +55,7 @@ class AccountFieldAdapter(
val identityProof = proofOrField.asLeft()
nameTextView.text = identityProof.provider
valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl)
valueTextView.movementMethod = LinkMovementMethod.getInstance()
@ -65,7 +66,7 @@ class AccountFieldAdapter(
nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis)
LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) {
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)

View File

@ -13,13 +13,13 @@
* 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.pager
package com.keylesspalace.tusky.components.account
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.fragment.AccountMediaFragment
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.util.CustomFragmentStateAdapter

View File

@ -1,4 +1,4 @@
package com.keylesspalace.tusky.viewmodel
package com.keylesspalace.tusky.components.account
import android.util.Log
import androidx.lifecycle.MutableLiveData

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
package com.keylesspalace.tusky.components.account.media
import android.graphics.Color
import android.os.Bundle
@ -37,9 +37,9 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView
@ -252,7 +252,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(items[currentIndex].attachment.url, context)
context?.openLink(items[currentIndex].attachment.url)
}
}
}

View File

@ -15,21 +15,25 @@
package com.keylesspalace.tusky.components.announcements
import android.os.Build
import android.text.SpannableStringBuilder
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.android.material.chip.Chip
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener {
fun openReactionPicker(announcementId: String, target: View)
@ -56,7 +60,9 @@ class AnnouncementAdapter(
val chips = holder.binding.chipGroup
val addReactionChip = holder.binding.addReactionChip
LinkHelper.setClickableText(text, item.content, null, listener)
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
// If wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) {
@ -67,42 +73,43 @@ class AnnouncementAdapter(
}
item.reactions.forEachIndexed { i, reaction ->
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)
}
.apply {
val emojiText = if (reaction.url == null) {
reaction.name
} else {
context.getString(R.string.emoji_shortcode_format, reaction.name)
(
chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)
}
)
.apply {
if (reaction.url == null) {
this.text = "${reaction.name} ${reaction.count}"
} else {
// we set the EmojiSpan on a space, because otherwise the Chip won't have the right size
// https://github.com/tuskyapp/Tusky/issues/2308
val spanBuilder = SpannableStringBuilder(" ${reaction.count}")
val span = EmojiSpan(WeakReference(this))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
span.contentDescription = reaction.name
}
this.text = ("$emojiText ${reaction.count}")
.emojify(
listOf(
Emoji(
reaction.name,
reaction.url ?: "",
reaction.staticUrl ?: "",
null
)
),
this,
animateEmojis
)
spanBuilder.setSpan(span, 0, 1, 0)
Glide.with(this)
.asDrawable()
.load(if (animateEmojis) { reaction.url } else { reaction.staticUrl })
.into(span.getTarget(animateEmojis))
this.text = spanBuilder
}
isChecked = reaction.me
isChecked = reaction.me
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
}
}
}
while (chips.size - 1 > item.reactions.size) {

View File

@ -27,7 +27,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding
@ -152,22 +152,17 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
viewModel.removeReaction(announcementId, name)
}
override fun onViewTag(tag: String?) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
override fun onViewTag(tag: String) {
val intent = StatusListActivity.newHashtagIntent(this, tag)
startActivityWithSlideInAnimation(intent)
}
override fun onViewAccount(id: String?) {
if (id != null) {
viewAccount(id)
}
override fun onViewAccount(id: String) {
viewAccount(id)
}
override fun onViewUrl(url: String?, text: String?) {
if (url != null) {
viewUrl(url)
}
override fun onViewUrl(url: String, text: String) {
viewUrl(url)
}
companion object {

View File

@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
@ -57,19 +58,21 @@ class AnnouncementsViewModel @Inject constructor(
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
},
{ emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
)
}
)
}
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars,
either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions,
either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars,
either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration,
either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration,
either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
either.asRight().version
)
}
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}

View File

@ -77,7 +77,20 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.withLifecycleContext
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
@ -115,6 +128,7 @@ class ComposeActivity :
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -357,6 +371,7 @@ class ComposeActivity :
withLifecycleContext {
viewModel.instanceParams.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft()
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
}
@ -715,7 +730,8 @@ class ComposeActivity :
val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
viewModel::updatePoll
)
}
@ -760,7 +776,7 @@ class ComposeActivity :
val urlSpans = binding.composeEditField.urls
if (urlSpans != null) {
for (span in urlSpans) {
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
offset += max(0, span.url.length - charactersReservedPerUrl)
}
}
var length = binding.composeEditField.length() - offset
@ -1114,10 +1130,6 @@ class ComposeActivity :
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
// Mastodon only counts URLs as this long in terms of status character limits
@VisibleForTesting
const val MAXIMUM_URL_LENGTH = 23
@JvmField
val CAN_USE_UNLEAKABLE = arrayOf("itabashi.0j0.jp", "odakyu.app")

View File

@ -48,7 +48,7 @@ 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 java.util.*
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
@ -82,6 +82,9 @@ class ComposeViewModel @Inject constructor(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
@ -107,18 +110,20 @@ class ComposeViewModel @Inject constructor(
fun loadInstanceDataFromNetwork(loadActually: Boolean) {
when (loadActually) {
true -> Single.zip(
api.getCustomEmojis(), api.getInstance(),
{ emojis, instance ->
InstanceEntity(
instance = domain,
emojiList = emojis,
maximumTootCharacters = instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
version = instance.version
)
}
)
api.getCustomEmojis(), api.getInstance()
) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version
)
}
false -> Single.error(Exception("skipped network access"))
}
.doOnSuccess {
@ -192,7 +197,7 @@ class ComposeViewModel @Inject constructor(
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(id = event.attachment.id, uploadPercent = -1)
item.copy(id = event.mediaId, uploadPercent = -1)
}
synchronized(media) {
val mediaValue = media.value!!
@ -520,7 +525,12 @@ fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = defau
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 25
private const val DEFAULT_MAX_OPTION_LENGTH = 50
private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800
// Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_MAXIMUM_URL_LENGTH = 23
val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "itabashi.0j0.jp", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com",
"comm.cx", "fedibird.com", "qoto.org", "kurage.cc", "m.eula.dev", "otogamer.me", "sgp.hostdon.ne.jp",
@ -530,6 +540,9 @@ data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean
)

View File

@ -25,7 +25,6 @@ import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
@ -41,10 +40,11 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Date
import javax.inject.Inject
sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent()
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
data class FinishedEvent(val mediaId: String) : UploadEvent()
}
fun createNewImageFile(context: Context): File {
@ -61,21 +61,16 @@ fun createNewImageFile(context: Context): File {
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
interface MediaUploader {
fun prepareMedia(inUri: Uri): Single<PreparedMedia>
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent>
}
class AudioSizeException : Exception()
class VideoSizeException : Exception()
class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception()
class MediaUploaderImpl(
class MediaUploader @Inject constructor(
private val context: Context,
private val mastodonApi: MastodonApi
) : MediaUploader {
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
) {
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
return Observable
.fromCallable {
if (shouldResizeMedia(media)) {
@ -86,7 +81,7 @@ class MediaUploaderImpl(
.subscribeOn(Schedulers.io())
}
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
return Single.fromCallable {
var mediaSize = getMediaSize(contentResolver, inUri)
var uri = inUri
@ -187,14 +182,14 @@ class MediaUploaderImpl(
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe(
{ attachment ->
{ result ->
if (media.uri.scheme == "file") {
media.uri.path?.let {
File(it).delete()
}
}
emitter.onNext(UploadEvent.FinishedEvent(attachment))
emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete()
},
{ e ->

View File

@ -20,6 +20,7 @@ package com.keylesspalace.tusky.components.compose.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.WindowManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogAddPollBinding
@ -30,6 +31,8 @@ fun showAddPollDialog(
poll: NewPoll?,
maxOptionCount: Int,
maxOptionLength: Int,
minDuration: Int,
maxDuration: Int,
onUpdatePoll: (NewPoll) -> Unit
) {
@ -57,6 +60,13 @@ fun showAddPollDialog(
binding.pollChoices.adapter = adapter
var durations = context.resources.getIntArray(R.array.poll_duration_values).toList()
val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration }
binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply {
setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item)
}
durations = durations.filter { it in minDuration..maxDuration }
binding.addChoiceButton.setOnClickListener {
if (adapter.itemCount < maxOptionCount) {
adapter.addChoice()
@ -66,7 +76,7 @@ fun showAddPollDialog(
}
}
val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
val pollDurationId = durations.indexOfLast {
it <= poll?.expiresIn ?: 0
}
@ -79,13 +89,10 @@ fun showAddPollDialog(
button.setOnClickListener {
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
val pollDuration = context.resources
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
onUpdatePoll(
NewPoll(
options = adapter.pollOptions,
expiresIn = pollDuration,
expiresIn = durations[selectedPollDurationId],
multiple = binding.multipleChoicesCheckBox.isChecked
)
)

View File

@ -25,6 +25,7 @@ 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.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
@ -79,6 +80,7 @@ data class ConversationStatusEntity(
val spoilerText: String,
val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>,
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
@ -107,6 +109,7 @@ data class ConversationStatusEntity(
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
@ -132,6 +135,7 @@ data class ConversationStatusEntity(
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
@ -162,6 +166,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
@ -198,6 +203,7 @@ fun Status.toEntity() =
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),

View File

@ -108,7 +108,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
false, statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
status.getMentions(), status.getEmojis(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -30,9 +29,9 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable
@ -53,6 +52,7 @@ import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
@OptIn(ExperimentalPagingApi::class)
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@Inject
@ -73,7 +73,6 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
@ExperimentalPagingApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
@ -239,8 +238,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
val intent = StatusListActivity.newHashtagIntent(requireContext(), tag)
startActivity(intent)
}

View File

@ -7,7 +7,7 @@ import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
class ConversationsRemoteMediator(
private val accountId: Long,
private val api: MastodonApi,

View File

@ -37,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
private val api: MastodonApi
) : RxAwareViewModel() {
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager(
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20),
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.util.Log
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
@ -12,7 +13,7 @@ import javax.inject.Inject
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val notifier: Notifier
private val context: Context
) {
fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
@ -20,7 +21,7 @@ class NotificationFetcher @Inject constructor(
try {
val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification ->
notifier.show(notification, account, index == 0)
NotificationHelper.make(context, notification, account, index == 0)
}
accountManager.saveAccount(account)
} catch (e: Exception) {

View File

@ -1,20 +0,0 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
/**
* Shows notifications.
*/
interface Notifier {
fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean)
}
class SystemNotifier(
private val context: Context
) : Notifier {
override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) {
NotificationHelper.make(context, notification, account, isFirstInBatch)
}
}

View File

@ -23,9 +23,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusViewHelper
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
@ -33,6 +33,8 @@ import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.toViewData
@ -96,7 +98,7 @@ class StatusViewHolder(
)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler, status.quote != null)
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} else {
@ -110,13 +112,11 @@ class StatusViewHolder(
val contentShown = viewState.isContentShow(status.id, true)
binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler,
status.quote != null)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null)
setContentWarningButtonText(!contentShown)
}
}
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler,
status.quote != null)
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null)
}
}
}
@ -132,16 +132,17 @@ class StatusViewHolder(
private fun setTextVisible(
expanded: Boolean,
content: Spanned,
mentions: List<Status.Mention>?,
mentions: List<Status.Mention>,
tags: List<HashTag>?,
emojis: List<Emoji>,
listener: LinkListener,
removeQuote: Boolean,
) {
if (expanded) {
val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis)
LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener)
setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener)
} else {
LinkHelper.setClickableMentions(binding.statusContent, mentions, listener)
setClickableMentions(binding.statusContent, mentions, listener)
}
if (binding.statusContent.text.isNullOrBlank()) {
binding.statusContent.hide()

View File

@ -28,10 +28,10 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
@ -182,9 +182,9 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag))
override fun onViewUrl(url: String?, text: String?) = viewModel.checkClickedUrl(url)
override fun onViewUrl(url: String, text: String) = viewModel.checkClickedUrl(url)
companion object {
fun newInstance() = ReportStatusesFragment()

View File

@ -81,7 +81,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
}
adapter.addLoadStateListener { loadState ->
if (loadState.refresh is Error) {
if (loadState.refresh is LoadState.Error) {
binding.progressBar.hide()
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshStatuses()

View File

@ -42,7 +42,7 @@ class SearchViewModel @Inject constructor(
mastodonApi: MastodonApi,
notestockApi: NotestockApi,
private val timelineCases: TimelineCases,
private val accountManager: AccountManager
private val accountManager: AccountManager,
) : RxAwareViewModel() {
var currentQuery: String = ""
@ -58,14 +58,19 @@ class SearchViewModel @Inject constructor(
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
val quoteEnabled = activeAccount?.domain in CAN_USE_QUOTE_ID
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
private val loadedNotestockStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf()
private val loadedStatuses: MutableList<StatusViewData.Concrete> = mutableListOf()
private val loadedNotestockStatuses: MutableList<StatusViewData.Concrete> = mutableListOf()
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler, true)) }
.apply {
loadedStatuses.addAll(this)
}
it.statuses.map { status ->
status.toViewData(
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler,
isCollapsed = true
)
}.apply {
loadedStatuses.addAll(this)
}
}
private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) {
it.accounts
@ -74,7 +79,13 @@ class SearchViewModel @Inject constructor(
it.hashtags
}
private val notestockStatusesPagingSourceFactory = SearchNotestockPagingSourceFactory(notestockApi) {
it.statuses.map { status -> Pair(status, status.toViewData(alwaysShowSensitiveMedia, alwaysOpenSpoiler, true)) }
it.statuses.map { status ->
status.toViewData(
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler,
isCollapsed = true
)
}
.apply {
loadedNotestockStatuses.addAll(this)
}
@ -112,126 +123,125 @@ class SearchViewModel @Inject constructor(
notestockStatusesPagingSourceFactory.newSearch(query)
}
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id)
fun removeItem(statusViewData: StatusViewData.Concrete) {
timelineCases.delete(statusViewData.id)
.subscribe(
{
if (loadedStatuses.remove(status))
if (loadedStatuses.remove(statusViewData))
statusesPagingSourceFactory.invalidate()
},
{
err ->
{ err ->
Log.d(TAG, "Failed to delete status", err)
}
)
.autoDispose()
}
fun removeNotestockItem(status: Pair<Status, StatusViewData.Concrete>) {
if (loadedNotestockStatuses.remove(status))
fun removeNotestockItem(statusViewData: StatusViewData.Concrete) {
if (loadedNotestockStatuses.remove(statusViewData)) {
notestockStatusesPagingSourceFactory.invalidate()
}
}
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
loadedStatuses[idx] = statusViewData.copy(isExpanded = expanded)
statusesPagingSourceFactory.invalidate()
}
}
fun expandedNotestockChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) {
val idx = loadedNotestockStatuses.indexOf(status)
fun expandedNotestockChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
val idx = loadedNotestockStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded))
loadedNotestockStatuses[idx] = statusViewData.copy(isExpanded = expanded)
notestockStatusesPagingSourceFactory.invalidate()
}
}
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
timelineCases.reblog(status.first.id, reblog)
fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
timelineCases.reblog(statusViewData.id, reblog)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ setRebloggedForStatus(status, reblog) },
{ t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) }
{ setRebloggedForStatus(statusViewData, reblog) },
{ t -> Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) }
)
.autoDispose()
}
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
status.first.reblogged = reblog
status.first.reblog?.reblogged = reblog
private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
statusViewData.status.reblogged = reblog
statusViewData.status.reblog?.reblogged = reblog
statusesPagingSourceFactory.invalidate()
}
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
loadedStatuses[idx] = statusViewData.copy(isShowingContent = isShowing)
statusesPagingSourceFactory.invalidate()
}
}
fun contentHiddenNotestockChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) {
val idx = loadedNotestockStatuses.indexOf(status)
fun contentHiddenNotestockChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
val idx = loadedNotestockStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing))
loadedNotestockStatuses[idx] = statusViewData.copy(isShowingContent = isShowing)
notestockStatusesPagingSourceFactory.invalidate()
}
}
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
loadedStatuses[idx] = statusViewData.copy(isCollapsed = collapsed)
statusesPagingSourceFactory.invalidate()
}
}
fun collapsedNotestockChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) {
val idx = loadedNotestockStatuses.indexOf(status)
fun collapsedNotestockChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
val idx = loadedNotestockStatuses.indexOf(statusViewData)
if (idx >= 0) {
loadedNotestockStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed))
loadedNotestockStatuses[idx] = statusViewData.copy(isCollapsed = collapsed)
notestockStatusesPagingSourceFactory.invalidate()
}
}
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
updateStatus(status, votedPoll)
timelineCases.voteInPoll(status.first.id, votedPoll.id, choices)
fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList<Int>) {
val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices)
updateStatus(statusViewData, votedPoll)
timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ newPoll -> updateStatus(status, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) }
{ newPoll -> updateStatus(statusViewData, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) }
)
.autoDispose()
}
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
val idx = loadedStatuses.indexOf(status)
private fun updateStatus(statusViewData: StatusViewData.Concrete, newPoll: Poll) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = status.first.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData)
val newStatus = statusViewData.status.copy(poll = newPoll)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
}
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) {
status.first.favourited = isFavorited
fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) {
statusViewData.status.favourited = isFavorited
statusesPagingSourceFactory.invalidate()
timelineCases.favourite(status.first.id, isFavorited)
.onErrorReturnItem(status.first)
timelineCases.favourite(statusViewData.id, isFavorited)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
status.first.bookmarked = isBookmarked
fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) {
statusViewData.status.bookmarked = isBookmarked
statusesPagingSourceFactory.invalidate()
timelineCases.bookmark(status.first.id, isBookmarked)
.onErrorReturnItem(status.first)
timelineCases.bookmark(statusViewData.id, isBookmarked)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}
@ -256,19 +266,15 @@ class SearchViewModel @Inject constructor(
return timelineCases.delete(id)
}
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) {
val idx = loadedStatuses.indexOf(status)
fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {
val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) {
val newStatus = status.first.copy(muted = mute)
val newPair = Pair(
newStatus,
status.second.copy(status = newStatus)
)
loadedStatuses[idx] = newPair
val newStatus = statusViewData.status.copy(muted = mute)
loadedStatuses[idx] = statusViewData.copy(status = newStatus)
statusesPagingSourceFactory.invalidate()
}
timelineCases.muteConversation(status.first.id, mute)
.onErrorReturnItem(status.first)
timelineCases.muteConversation(statusViewData.id, mute)
.onErrorReturnItem(statusViewData.status)
.subscribe()
.autoDispose()
}

View File

@ -3,7 +3,6 @@ package com.keylesspalace.tusky.components.search.adapter
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.NotestockApi
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.rx3.await
@ -13,19 +12,19 @@ import java.util.*
class SearchNotestockPagingSource(
private val notestockApi: NotestockApi,
private val searchRequest: String,
private val initialItems: List<Pair<Status, StatusViewData.Concrete>>? = null,
private val parser: (SearchResult) -> List<Pair<Status, StatusViewData.Concrete>>
) : PagingSource<Date, Pair<Status, StatusViewData.Concrete>>() {
private val initialItems: List<StatusViewData.Concrete>? = null,
private val parser: (SearchResult) -> List<StatusViewData.Concrete>
) : PagingSource<Date, StatusViewData.Concrete>() {
@Suppress("SpellCheckingInspection")
private val iso8601 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
.apply { timeZone = UTC }
override fun getRefreshKey(state: PagingState<Date, Pair<Status, StatusViewData.Concrete>>): Date? {
override fun getRefreshKey(state: PagingState<Date, StatusViewData.Concrete>): Date? {
return null
}
override suspend fun load(params: LoadParams<Date>): LoadResult<Date, Pair<Status, StatusViewData.Concrete>> {
override suspend fun load(params: LoadParams<Date>): LoadResult<Date, StatusViewData.Concrete> {
if (searchRequest.isEmpty()) {
return LoadResult.Page(
data = emptyList(),
@ -38,7 +37,7 @@ class SearchNotestockPagingSource(
return LoadResult.Page(
data = initialItems.toList(),
prevKey = null,
nextKey = initialItems.last().first.createdAt
nextKey = initialItems.last().status.createdAt
)
}
@ -53,7 +52,7 @@ class SearchNotestockPagingSource(
return LoadResult.Page(
data = res,
prevKey = null,
nextKey = res.last().first.createdAt,
nextKey = res.last().status.createdAt,
)
} catch (e: Exception) {
return LoadResult.Error(e)

View File

@ -1,14 +1,13 @@
package com.keylesspalace.tusky.components.search.adapter
import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.NotestockApi
import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchNotestockPagingSourceFactory(
private val notestockApi: NotestockApi,
private val initialItems: List<Pair<Status, StatusViewData.Concrete>>? = null,
private val parser: (SearchResult) -> List<Pair<Status, StatusViewData.Concrete>>
private val initialItems: List<StatusViewData.Concrete>? = null,
private val parser: (SearchResult) -> List<StatusViewData.Concrete>
) : () -> SearchNotestockPagingSource {
private var searchRequest: String = ""

View File

@ -21,7 +21,6 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusViewHolder
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -29,7 +28,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
class SearchStatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusListener: StatusActionListener
) : PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, StatusViewHolder>(STATUS_COMPARATOR) {
) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder {
val view = LayoutInflater.from(parent.context)
@ -39,22 +38,18 @@ class SearchStatusesAdapter(
override fun onBindViewHolder(holder: StatusViewHolder, position: Int) {
getItem(position)?.let { item ->
holder.setupWithStatus(item.second, statusListener, statusDisplayOptions)
holder.setupWithStatus(item, statusListener, statusDisplayOptions)
}
}
fun item(position: Int): Pair<Status, StatusViewData.Concrete>? {
return getItem(position)
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Pair<Status, StatusViewData.Concrete>>() {
override fun areContentsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Pair<Status, StatusViewData.Concrete>, newItem: Pair<Status, StatusViewData.Concrete>): Boolean =
oldItem.second.id == newItem.second.id
override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id
}
}
}

View File

@ -13,10 +13,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.databinding.FragmentSearchBinding
import com.keylesspalace.tusky.di.Injectable
@ -113,7 +113,7 @@ abstract class SearchFragment<T : Any> :
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
override fun onViewTag(tag: String) = startActivity(ViewTagActivity.getIntent(requireContext(), tag))
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag))
override fun onViewUrl(url: String, text: String) {
bottomSheetActivity?.viewUrl(url, text = text)

View File

@ -24,7 +24,6 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -46,15 +45,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
class SearchNotestockFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
override val data: Flow<PagingData<StatusViewData.Concrete>>
get() = viewModel.notestockStatusesFlow
private val searchAdapter
get() = super.adapter as SearchStatusesAdapter
override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
override fun createAdapter(): PagingDataAdapter<StatusViewData.Concrete, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -77,13 +76,13 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.contentHiddenNotestockChange(it, isShowing)
}
}
override fun onReply(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
reply(status)
}
}
@ -93,25 +92,25 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
}
override fun onQuote(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
quote(status)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.bookmark(status, bookmark)
}
}
override fun onMore(view: View, position: Int) {
searchAdapter.item(position)?.first?.let {
searchAdapter.peek(position)?.status?.let {
more(it, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
searchAdapter.item(position)?.first?.actionableStatus?.let { actionable ->
searchAdapter.peek(position)?.status?.actionableStatus?.let { actionable ->
when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable)
@ -140,20 +139,20 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
}
override fun onViewThread(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewUrl(actionableStatus.id)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
}
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.expandedNotestockChange(it, expanded)
}
}
@ -163,7 +162,7 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.collapsedNotestockChange(it, isCollapsed)
}
}
@ -173,7 +172,7 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
}
fun removeItem(position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.removeNotestockItem(it)
}
}
@ -348,7 +347,7 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
searchAdapter.item(position)?.let { foundStatus ->
searchAdapter.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true)
}
return@setOnMenuItemClickListener true
@ -420,21 +419,12 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(statusUrl, account)
bottomSheetActivity?.openAsAccount(statusUrl, account)
}
}
)
}
private fun openAsAccount(statusUrl: String, account: AccountEntity) {
viewModel.activeAccount = account
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(MainActivity.STATUS_URL, statusUrl)
startActivity(intent)
(activity as BaseActivity).finishWithoutSlideOutAnimation()
}
private fun downloadAllMedia(status: Status) {
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
for ((_, url) in status.attachments) {

View File

@ -40,7 +40,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
@ -55,23 +54,23 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow
class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concrete>>(), StatusActionListener {
class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), StatusActionListener {
override val data: Flow<PagingData<Pair<Status, StatusViewData.Concrete>>>
override val data: Flow<PagingData<StatusViewData.Concrete>>
get() = viewModel.statusesFlow
private val searchAdapter
get() = super.adapter as SearchStatusesAdapter
override fun createAdapter(): PagingDataAdapter<Pair<Status, StatusViewData.Concrete>, *> {
override fun createAdapter(): PagingDataAdapter<StatusViewData.Concrete, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -93,43 +92,43 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.contentHiddenChange(it, isShowing)
}
}
override fun onReply(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
reply(status)
}
}
override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.favorite(status, favourite)
}
}
override fun onQuote(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
quote(status)
}
}
override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.bookmark(status, bookmark)
}
}
override fun onMore(view: View, position: Int) {
searchAdapter.item(position)?.first?.let {
searchAdapter.peek(position)?.status?.let {
more(it, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
searchAdapter.item(position)?.first?.actionableStatus?.let { actionable ->
searchAdapter.peek(position)?.status?.actionableStatus?.let { actionable ->
when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable)
@ -150,27 +149,27 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
}
Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
context?.openLink(actionable.attachments[attachmentIndex].url)
}
}
}
}
override fun onViewThread(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.item(position)?.first?.let { status ->
searchAdapter.peek(position)?.status?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
}
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.expandedChange(it, expanded)
}
}
@ -180,25 +179,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.collapsedChange(it, isCollapsed)
}
}
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.item(position)?.let {
searchAdapter.peek(position)?.let {
viewModel.voteInPoll(it, choices)
}
}
fun removeItem(position: Int) {
searchAdapter.item(position)?.let {
private fun removeItem(position: Int) {
searchAdapter.peek(position)?.let {
viewModel.removeItem(it)
}
}
override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.item(position)?.let { status ->
searchAdapter.peek(position)?.let { status ->
viewModel.reblog(status, reblog)
}
}
@ -258,9 +257,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
val accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username
val statusUrl = status.actionableStatus.url
val accounts = viewModel.getAllAccountsOrderedByActive()
var openAsTitle: String? = null
val loggedInAccountId = viewModel.activeAccount?.accountId
val popup = PopupMenu(view.context, view)
@ -291,17 +287,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
val openAsItem = popup.menu.findItem(R.id.status_open_as)
when (accounts.size) {
0, 1 -> openAsItem.isVisible = false
2 -> for (account in accounts) {
if (account !== viewModel.activeAccount) {
openAsTitle = String.format(getString(R.string.action_open_as), account.fullName)
break
}
}
else -> openAsTitle = String.format(getString(R.string.action_open_as), "")
val openAsText = bottomSheetActivity?.openAsText
if (openAsText == null) {
openAsItem.isVisible = false
} else {
openAsItem.title = openAsText
}
openAsItem.title = openAsTitle
val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
@ -355,7 +346,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
return@setOnMenuItemClickListener true
}
R.id.status_mute_conversation -> {
searchAdapter.item(position)?.let { foundStatus ->
searchAdapter.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true)
}
return@setOnMenuItemClickListener true
@ -426,21 +417,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
dialogTitle, false,
object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) {
openAsAccount(statusUrl, account)
bottomSheetActivity?.openAsAccount(statusUrl, account)
}
}
)
}
private fun openAsAccount(statusUrl: String, account: AccountEntity) {
viewModel.activeAccount = account
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra(MainActivity.STATUS_URL, statusUrl)
startActivity(intent)
(activity as BaseActivity).finishWithoutSlideOutAnimation()
}
private fun downloadAllMedia(status: Status) {
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
for ((_, url) in status.attachments) {

View File

@ -38,6 +38,7 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.QuickReplyEvent
@ -103,8 +104,6 @@ class TimelineFragment :
private var isSwipeToRefreshEnabled = true
private var eventRegistered = false
private var layoutManager: LinearLayoutManager? = null
private var scrollListener: RecyclerView.OnScrollListener? = null
private var hideFab = false
@ -222,9 +221,11 @@ class TimelineFragment :
if (adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (isSwipeToRefreshEnabled) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
} else binding.recyclerView.scrollToPosition(0)
if (getView() != null) {
if (isSwipeToRefreshEnabled) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
} else binding.recyclerView.scrollToPosition(0)
}
}
}
}
@ -235,36 +236,7 @@ class TimelineFragment :
adapter.submitData(pagingData)
}
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupRecyclerView() {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
adapter.peek(pos)
}
)
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
binding.recyclerView.addItemDecoration(divider)
// CWs are expanded without animation, buttons animate itself, we don't need it basically
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
* guaranteed to be set until then. */
if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
hideFab = preferences.getBoolean("fabHide", false)
@ -288,23 +260,43 @@ class TimelineFragment :
}
}
if (!eventRegistered) {
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
when (event) {
is PreferenceChangedEvent -> {
onPreferenceChanged(event.preferenceKey)
}
is StatusComposedEvent -> {
val status = event.status
handleStatusComposeEvent(status)
}
}
eventRegistered = true
}
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupRecyclerView() {
binding.recyclerView.setAccessibilityDelegateCompat(
ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos ->
adapter.peek(pos)
}
)
binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
binding.recyclerView.addItemDecoration(divider)
// CWs are expanded without animation, buttons animate itself, we don't need it basically
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter
}
override fun onStart() {
@ -449,7 +441,7 @@ class TimelineFragment :
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
}
}
@ -495,7 +487,7 @@ class TimelineFragment :
talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
startUpdateTimestamp()
}
@ -513,7 +505,7 @@ class TimelineFragment :
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe {
adapter.notifyDataSetChanged()
adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
}
}
}

View File

@ -41,6 +41,10 @@ class TimelinePagingAdapter(
)
}
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_STATUS -> {

View File

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.shouldTrimStatus
@ -41,6 +42,7 @@ data class Placeholder(
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.type
fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity(
@ -99,6 +101,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
visibility = Status.Visibility.UNKNOWN,
attachments = null,
mentions = null,
tags = null,
application = null,
reblogServerId = null,
reblogAccountId = null,
@ -138,6 +141,7 @@ fun Status.toEntity(
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson),
tags = actionableStatus.tags.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
@ -157,6 +161,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(status.tags, tagListType)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
@ -183,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = false,
muted = status.muted,
@ -212,6 +218,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = ArrayList(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = status.pinned,
muted = status.muted,
@ -241,6 +248,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = status.pinned,
muted = status.muted,

View File

@ -33,7 +33,7 @@ import com.keylesspalace.tusky.util.dec
import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
class CachedTimelineRemoteMediator(
accountManager: AccountManager,
private val api: MastodonApi,
@ -53,11 +53,19 @@ class CachedTimelineRemoteMediator(
try {
var dbEmpty = false
val topPlaceholderId = if (loadType == LoadType.REFRESH) {
timelineDao.getTopPlaceholderId(activeAccount.id)
} else {
null // don't execute the query if it is not needed
}
if (!initialRefresh && loadType == LoadType.REFRESH) {
val topId = timelineDao.getTopId(activeAccount.id)
topId?.let { cachedTopId ->
val statusResponse = api.homeTimeline(
maxId = cachedTopId,
sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten
limit = state.config.pageSize
).await()
@ -74,7 +82,7 @@ class CachedTimelineRemoteMediator(
val statusResponse = when (loadType) {
LoadType.REFRESH -> {
api.homeTimeline(limit = state.config.pageSize).await()
api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize).await()
}
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)

View File

@ -44,12 +44,15 @@ import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import net.accelf.yuito.streaming.StreamingManager
import retrofit2.HttpException
import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
/**
* TimelineViewModel that caches all statuses in a local database
@ -66,11 +69,11 @@ class CachedTimelineViewModel @Inject constructor(
streamingManager: StreamingManager,
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) {
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson),
pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) }
pagingSourceFactory = { db.timelineDao().getStatuses(accountManager.activeAccount!!.id) }
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
@ -84,6 +87,16 @@ class CachedTimelineViewModel @Inject constructor(
}
.cachedIn(viewModelScope)
init {
viewModelScope.launch {
delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh
accountManager.activeAccount?.id?.let { accountId ->
db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE)
db.timelineDao().cleanupAccounts(accountId)
}
}
}
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
// handled by CacheUpdater
}
@ -131,7 +144,9 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val response = api.homeTimeline(maxId = placeholderId.inc(), limit = 20).await()
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE).await()
val statuses = response.body()
if (!response.isSuccessful || statuses == null) {
@ -165,13 +180,13 @@ class CachedTimelineViewModel @Inject constructor(
)
}
if (overlappedStatuses == 0) {
if (overlappedStatuses == 0 && statuses.isNotEmpty()) {
timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id)
)
}
}
} catch (e: java.lang.Exception) {
} catch (e: Exception) {
loadMoreFailed(placeholderId, e)
}
}
@ -230,10 +245,11 @@ class CachedTimelineViewModel @Inject constructor(
override fun fullReload() {
viewModelScope.launch {
val activeAccount = accountManager.activeAccount!!
db.runInTransaction {
db.timelineDao().removeAllForAccount(activeAccount.id)
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
}
db.timelineDao().removeAll(activeAccount.id)
}
}
companion object {
private const val MAX_STATUSES_IN_CACHE = 1000
}
}

View File

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
import retrofit2.HttpException
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
class NetworkTimelineRemoteMediator(
private val accountManager: AccountManager,
private val viewModel: NetworkTimelineViewModel
@ -47,7 +47,11 @@ class NetworkTimelineRemoteMediator(
}
LoadType.APPEND -> {
val maxId = viewModel.nextKey
viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize)
if (maxId != null) {
viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize)
} else {
return MediatorResult.Success(endOfPaginationReached = true)
}
}
}

View File

@ -36,7 +36,7 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -67,7 +67,7 @@ class NetworkTimelineViewModel @Inject constructor(
var nextKey: String? = null
@ExperimentalPagingApi
@OptIn(ExperimentalPagingApi::class)
override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE),
pagingSourceFactory = {
@ -121,7 +121,7 @@ class NetworkTimelineViewModel @Inject constructor(
override fun removeAllByInstance(instance: String) {
statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false
LinkHelper.getDomain(status.account.url) == instance
getDomain(status.account.url) == instance
}
currentSource?.invalidate()
}

View File

@ -32,7 +32,6 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await

View File

@ -32,7 +32,7 @@ import java.io.File;
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 28)
}, version = 30)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -457,4 +457,21 @@ public abstract class AppDatabase extends RoomDatabase {
"ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)");
}
};
public static final Migration MIGRATION_28_29 = new Migration(28, 29) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_tags` TEXT");
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `tags` TEXT");
}
};
public static final Migration MIGRATION_29_30 = new Migration(29, 30) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `charactersReservedPerUrl` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `minPollDuration` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER");
}
};
}

View File

@ -28,6 +28,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
@ -123,6 +124,16 @@ class Converters @Inject constructor (
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
}
@TypeConverter
fun tagListToJson(tagArray: List<HashTag>?): String? {
return gson.toJson(tagArray)
}
@TypeConverter
fun jsonToTagArray(tagListJson: String?): List<HashTag>? {
return gson.fromJson(tagListJson, object : TypeToken<List<HashTag>>() {}.type)
}
@TypeConverter
fun dateToLong(date: Date): Long {
return date.time

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import kotlinx.parcelize.Parcelize
@ -40,11 +41,16 @@ data class DraftEntity(
val failedToSend: Boolean
)
/**
* The alternate names are here because we accidentally published versions were DraftAttachment was minified
* Tusky 15: uriString = e, description = f, type = g
* Tusky 16 beta: uriString = i, description = j, type = k
*/
@Parcelize
data class DraftAttachment(
val uriString: String,
val description: String?,
val type: Type
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type
) : Parcelable {
val uri: Uri
get() = uriString.toUri()

View File

@ -28,5 +28,8 @@ data class InstanceEntity(
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val minPollDuration: Int?,
val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?,
val version: String?
)

View File

@ -35,7 +35,7 @@ abstract class TimelineDao {
SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
@ -51,7 +51,7 @@ LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND
WHERE s.timelineUserId = :account
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC"""
)
abstract fun getStatusesForAccount(account: Long): PagingSource<Int, TimelineStatusWithAccount>
abstract fun getStatuses(account: Long): PagingSource<Int, TimelineStatusWithAccount>
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND
@ -86,11 +86,20 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
)
abstract fun removeAllByUser(accountId: Long, userId: String)
/**
* Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account
* @param accountId id of the account for which to clean tables
*/
suspend fun removeAll(accountId: Long) {
removeAllStatuses(accountId)
removeAllAccounts(accountId)
}
@Query("DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
abstract fun removeAllForAccount(accountId: Long)
abstract suspend fun removeAllStatuses(accountId: Long)
@Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
abstract fun removeAllUsersForAccount(accountId: Long)
abstract suspend fun removeAllAccounts(accountId: Long)
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
@ -98,8 +107,39 @@ AND serverId = :statusId"""
)
abstract fun delete(accountId: Long, statusId: String)
@Query("""DELETE FROM TimelineStatusEntity WHERE createdAt < :olderThan""")
abstract fun cleanup(olderThan: Long)
/**
* Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries.
* @param accountId id of the account for which to clean tables
* @param limit how many statuses to keep
*/
suspend fun cleanup(accountId: Long, limit: Int) {
cleanupStatuses(accountId, limit)
cleanupAccounts(accountId)
}
/**
* Cleans the TimelineStatusEntity table from old status entries.
* @param accountId id of the account for which to clean statuses
* @param limit how many statuses to keep
*/
@Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId NOT IN
(SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit)
"""
)
abstract suspend fun cleanupStatuses(accountId: Long, limit: Int)
/**
* Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table
* @param accountId id of the user account for which to clean timeline accounts
*/
@Query(
"""DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN
(SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId)
AND serverId NOT IN
(SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)"""
)
abstract suspend fun cleanupAccounts(accountId: Long)
@Query(
"""UPDATE TimelineStatusEntity SET poll = :poll
@ -142,4 +182,10 @@ AND timelineUserId = :accountId
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getTopId(accountId: Long): String?
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getTopPlaceholderId(accountId: Long): String?
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
}

View File

@ -69,6 +69,7 @@ data class TimelineStatusEntity(
val visibility: Status.Visibility,
val attachments: String?,
val mentions: String?,
val tags: String?,
val application: String?,
val reblogServerId: String?, // if it has a reblogged status, it's id is stored here
val reblogAccountId: String?,

View File

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.AboutActivity
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.EditProfileActivity
@ -25,13 +24,12 @@ import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.LoginActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.ModalTimelineActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.ViewThreadActivity
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity
@ -72,12 +70,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesAccountListActivity(): AccountListActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesModalTimelineActivity(): ModalTimelineActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewTagActivity(): ViewTagActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewThreadActivity(): ViewThreadActivity

View File

@ -34,8 +34,7 @@ import javax.inject.Singleton
ActivitiesModule::class,
ServicesModule::class,
BroadcastReceiverModule::class,
ViewModelModule::class,
MediaUploaderModule::class
ViewModelModule::class
]
)
interface AppComponent {

View File

@ -19,19 +19,11 @@ import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import androidx.room.Room
import com.keylesspalace.tusky.TuskyApplication
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.EventHubImpl
import com.keylesspalace.tusky.components.notifications.Notifier
import com.keylesspalace.tusky.components.notifications.SystemNotifier
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.network.TimelineCasesImpl
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
@ -54,28 +46,11 @@ class AppModule {
return PreferenceManager.getDefaultSharedPreferences(app)
}
@Provides
fun providesBroadcastManager(app: Application): LocalBroadcastManager {
return LocalBroadcastManager.getInstance(app)
}
@Provides
fun providesConnectivityManager(appContext: Context): ConnectivityManager {
return appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
@Provides
fun providesTimelineUseCases(
api: MastodonApi,
eventHub: EventHub
): TimelineCases {
return TimelineCasesImpl(api, eventHub)
}
@Provides
@Singleton
fun providesEventHub(): EventHub = EventHubImpl
@Provides
@Singleton
fun providesDatabase(appContext: Context, converters: Converters): AppDatabase {
@ -92,12 +67,9 @@ class AppModule {
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30
)
.build()
}
@Provides
@Singleton
fun notifier(context: Context): Notifier = SystemNotifier(context)
}

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.AccountsInListFragment
import com.keylesspalace.tusky.components.account.media.AccountMediaFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
@ -30,7 +31,6 @@ import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragme
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.fragment.AccountListFragment
import com.keylesspalace.tusky.fragment.AccountMediaFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.ViewThreadFragment
import dagger.Module
@ -57,9 +57,6 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun notificationsFragment(): NotificationsFragment
@ContributesAndroidInjector
abstract fun searchFragment(): SearchStatusesFragment
@ContributesAndroidInjector
abstract fun notificationPreferencesFragment(): NotificationPreferencesFragment
@ -67,7 +64,7 @@ abstract class FragmentBuildersModule {
abstract fun accountPreferencesFragment(): AccountPreferencesFragment
@ContributesAndroidInjector
abstract fun directMessagesPreferencesFragment(): ConversationsFragment
abstract fun conversationsFragment(): ConversationsFragment
@ContributesAndroidInjector
abstract fun accountInListsFragment(): AccountsInListFragment
@ -84,6 +81,9 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun instanceListFragment(): InstanceListFragment
@ContributesAndroidInjector
abstract fun searchStatusesFragment(): SearchStatusesFragment
@ContributesAndroidInjector
abstract fun searchAccountFragment(): SearchAccountsFragment

View File

@ -15,25 +15,12 @@
package com.keylesspalace.tusky.di
import android.content.Context
import com.keylesspalace.tusky.service.SendTootService
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.ServiceClientImpl
import dagger.Module
import dagger.Provides
import dagger.android.ContributesAndroidInjector
@Module
abstract class ServicesModule {
@ContributesAndroidInjector
abstract fun contributesSendTootService(): SendTootService
@Module
companion object {
@Provides
@JvmStatic
fun providesServiceClient(context: Context): ServiceClient {
return ServiceClientImpl(context)
}
}
}

View File

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.account.AccountViewModel
import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
@ -13,7 +14,6 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.keylesspalace.tusky.viewmodel.ListsViewModel

View File

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

View File

@ -30,7 +30,8 @@ data class Instance(
@SerializedName("contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollLimits: PollLimits?
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?,
val configuration: InstanceConfiguration?,
) {
override fun hashCode(): Int {
return uri.hashCode()
@ -45,7 +46,31 @@ data class Instance(
}
}
data class PollLimits(
data class PollConfiguration(
@SerializedName("max_options") val maxOptions: Int?,
@SerializedName("max_option_chars") val maxOptionChars: Int?
@SerializedName("max_option_chars") val maxOptionChars: Int?,
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?,
@SerializedName("min_expiration") val minExpiration: Int?,
@SerializedName("max_expiration") val maxExpiration: Int?,
)
data class InstanceConfiguration(
val statuses: StatusConfiguration?,
@SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?,
val polls: PollConfiguration?,
)
data class StatusConfiguration(
@SerializedName("max_characters") val maxCharacters: Int?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?,
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?,
)
data class MediaAttachmentConfiguration(
@SerializedName("supported_mime_types") val supportedMimeTypes: List<String>?,
@SerializedName("image_size_limit") val imageSizeLimit: Int?,
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int?,
@SerializedName("video_size_limit") val videoSizeLimit: Int?,
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?,
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?,
)

View File

@ -0,0 +1,9 @@
package com.keylesspalace.tusky.entity
/**
* The same as Attachment, except the url is null - see https://docs.joinmastodon.org/methods/statuses/media/
* We are only interested in the id, so other attributes are omitted
*/
data class MediaUploadResult(
val id: String
)

View File

@ -15,7 +15,9 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.json.GuardedBooleanAdapter
data class Relationship(
val id: String,
@ -26,7 +28,11 @@ data class Relationship(
@SerializedName("muting_notifications") val mutingNotifications: Boolean,
val requested: Boolean,
@SerializedName("showing_reblogs") val showingReblogs: Boolean,
val subscribing: Boolean? = null, // Pleroma extension
/* Pleroma extension, same as 'notifying' on Mastodon.
* Some instances like qoto.org have a custom subscription feature where 'subscribing' is a json object,
* so we use the custom GuardedBooleanAdapter to ignore the field if it is not a boolean.
*/
@JsonAdapter(GuardedBooleanAdapter::class) val subscribing: Boolean? = null,
@SerializedName("domain_blocking") val blockingDomain: Boolean,
val note: String?, // nullable for backward compatibility / feature detection
val notifying: Boolean? // since 3.3.0rc

View File

@ -42,6 +42,7 @@ data class Status(
val visibility: Visibility,
@SerializedName("media_attachments", alternate = ["attachment"]) var attachments: ArrayList<Attachment>,
val mentions: List<Mention>,
val tags: List<HashTag>?,
val application: Application?,
val pinned: Boolean?,
val muted: Boolean?,
@ -59,10 +60,11 @@ data class Status(
val isNotestock: Boolean
get() = !account.notestockUsername.isNullOrEmpty()
/** Helper for Java */
/** Helpers for Java */
fun copyWithFavourited(favourited: Boolean): Status = copy(favourited = favourited)
fun copyWithReblogged(reblogged: Boolean): Status = copy(reblogged = reblogged)
fun copyWithBookmarked(bookmarked: Boolean): Status = copy(bookmarked = bookmarked)
fun copyWithPoll(poll: Poll?): Status = copy(poll = poll)
/** Helper for Java */
fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned)
enum class Visibility(val num: Int) {
@ -156,18 +158,6 @@ data class Status(
return builder.toString()
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val status = other as Status?
return id == status?.id
}
override fun hashCode(): Int {
return id.hashCode()
}
data class Mention(
val id: String,
val url: String,

View File

@ -29,7 +29,6 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.AccountListActivity.Type
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
@ -39,6 +38,7 @@ import com.keylesspalace.tusky.adapter.FollowAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter
import com.keylesspalace.tusky.adapter.MutesAdapter
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable

View File

@ -188,9 +188,11 @@ public class NotificationsFragment extends SFragment implements
Notification notification = input.asRight()
.rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId());
boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive();
return ViewDataUtils.notificationToViewData(
notification,
alwaysShowSensitiveMedia,
alwaysShowSensitiveMedia || !sensitiveStatus,
alwaysOpenSpoiler,
true
);
@ -416,10 +418,7 @@ public class NotificationsFragment extends SFragment implements
}
private void setReblogForStatus(String statusId, boolean reblog) {
updateStatus(statusId, (s) -> {
s.setReblogged(reblog);
return s;
});
updateStatus(statusId, (s) -> s.copyWithReblogged(reblog));
}
@Override
@ -438,10 +437,7 @@ public class NotificationsFragment extends SFragment implements
}
private void setFavouriteForStatus(String statusId, boolean favourite) {
updateStatus(statusId, (s) -> {
s.setFavourited(favourite);
return s;
});
updateStatus(statusId, (s) -> s.copyWithFavourited(favourite));
}
@Override
@ -460,10 +456,7 @@ public class NotificationsFragment extends SFragment implements
}
private void setBookmarkForStatus(String statusId, boolean bookmark) {
updateStatus(statusId, (s) -> {
s.setBookmarked(bookmark);
return s;
});
updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark));
}
@Override
@ -528,10 +521,7 @@ public class NotificationsFragment extends SFragment implements
}
private void setPinForStatus(String statusId, boolean pinned) {
updateStatus(statusId, status -> {
status.copyWithPinned(pinned);
return status;
});
updateStatus(statusId, status -> status.copyWithPinned(pinned));
}
@Override

View File

@ -41,11 +41,10 @@ import androidx.lifecycle.Lifecycle;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.StatusListActivity;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
import com.keylesspalace.tusky.components.report.ReportActivity;
@ -189,8 +188,6 @@ public abstract class SFragment extends Fragment implements Injectable {
final String accountId = status.getActionableStatus().getAccount().getId();
final String accountUsername = status.getActionableStatus().getAccount().getUsername();
final String statusUrl = status.getActionableStatus().getUrl();
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
String openAsTitle = null;
String loggedInAccountId = null;
AccountEntity activeAccount = accountManager.getActiveAccount();
@ -228,24 +225,12 @@ public abstract class SFragment extends Fragment implements Injectable {
Menu menu = popup.getMenu();
MenuItem openAsItem = menu.findItem(R.id.status_open_as);
switch (accounts.size()) {
case 0:
case 1:
openAsItem.setVisible(false);
break;
case 2:
for (AccountEntity account : accounts) {
if (account != activeAccount) {
openAsTitle = String.format(getString(R.string.action_open_as), account.getFullName());
break;
}
}
break;
default:
openAsTitle = String.format(getString(R.string.action_open_as), "");
break;
String openAsText = ((BaseActivity)getActivity()).getOpenAsText();
if (openAsText == null) {
openAsItem.setVisible(false);
} else {
openAsItem.setTitle(openAsText);
}
openAsItem.setTitle(openAsTitle);
MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation);
boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions());
@ -405,15 +390,14 @@ public abstract class SFragment extends Fragment implements Injectable {
}
default:
case UNKNOWN: {
LinkHelper.openLink(active.getAttachment().getUrl(), getContext());
LinkHelper.openLink(requireContext(), active.getAttachment().getUrl());
break;
}
}
}
protected void viewTag(String tag) {
Intent intent = new Intent(getContext(), ViewTagActivity.class);
intent.putExtra("hashtag", tag);
Intent intent = StatusListActivity.newHashtagIntent(requireContext(), tag);
startActivity(intent);
}
@ -483,18 +467,9 @@ public abstract class SFragment extends Fragment implements Injectable {
.show();
}
private void openAsAccount(String statusUrl, AccountEntity account) {
accountManager.setActiveAccount(account);
Intent intent = new Intent(getContext(), MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.putExtra(MainActivity.STATUS_URL, statusUrl);
startActivity(intent);
((BaseActivity) getActivity()).finishWithoutSlideOutAnimation();
}
private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) {
BaseActivity activity = (BaseActivity) getActivity();
activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account));
activity.showAccountChooserDialog(dialogTitle, false, account -> activity.openAsAccount(statusUrl, account));
}
private void downloadAllMedia(Status status) {

View File

@ -61,6 +61,7 @@ import com.keylesspalace.tusky.network.FilterModel;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.CardViewMode;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
@ -107,10 +108,10 @@ public final class ViewThreadFragment extends SFragment implements
private final PairedList<Status, StatusViewData.Concrete> statuses =
new PairedList<>(new Function<Status, StatusViewData.Concrete>() {
@Override
public StatusViewData.Concrete apply(Status input) {
public StatusViewData.Concrete apply(Status status) {
return ViewDataUtils.statusToViewData(
input,
alwaysShowSensitiveMedia,
status,
alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(),
alwaysOpenSpoiler,
true
);
@ -327,6 +328,22 @@ public final class ViewThreadFragment extends SFragment implements
super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl());
}
@Override
public void onViewUrl(String url, String text) {
Status status = null;
if (!statuses.isEmpty()) {
status = statuses.get(statusIndex);
}
if (status != null && status.getUrl().equals(url)) {
// already viewing the status with this url
// probably just a preview federated and the user is clicking again to view more -> open the browser
// this can happen with some friendica statuses
LinkHelper.openLink(requireContext(), url);
return;
}
super.onViewUrl(url, text);
}
@Override
public void onOpenReblog(int position) {
// there should be no reblogs in the thread but let's implement it to be sure
@ -490,7 +507,7 @@ public final class ViewThreadFragment extends SFragment implements
private int setStatus(Status status) {
if (statuses.size() > 0
&& statusIndex < statuses.size()
&& statuses.get(statusIndex).equals(status)) {
&& statuses.get(statusIndex).getId().equals(status.getId())) {
// Do not add this status on refresh, it's already in there.
statuses.set(statusIndex, status);
return statusIndex;

View File

@ -13,10 +13,10 @@
* 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.interfaces;
package com.keylesspalace.tusky.interfaces
public interface LinkListener {
void onViewTag(String tag);
void onViewAccount(String id);
void onViewUrl(String url, String text);
interface LinkListener {
fun onViewTag(tag: String)
fun onViewAccount(id: String)
fun onViewUrl(url: String, text: String)
}

View File

@ -1,4 +1,4 @@
/* Copyright 2019 Tusky Contributors
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
@ -13,18 +13,21 @@
* 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.di
package com.keylesspalace.tusky.json
import android.content.Context
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
import com.keylesspalace.tusky.network.MastodonApi
import dagger.Module
import dagger.Provides
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
@Module
class MediaUploaderModule {
@Provides
fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader =
MediaUploaderImpl(context, mastodonApi)
class GuardedBooleanAdapter : JsonDeserializer<Boolean?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean? {
return if (json.isJsonObject) {
null
} else {
json.asBoolean
}
}
}

View File

@ -28,6 +28,7 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.entity.MediaUploadResult
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
@ -142,11 +143,11 @@ interface MastodonApi {
fun clearNotifications(): Single<ResponseBody>
@Multipart
@POST("api/v1/media")
@POST("api/v2/media")
fun uploadMedia(
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Single<Attachment>
): Single<MediaUploadResult>
@FormUrlEncoded
@PUT("api/v1/media/{mediaId}")

View File

@ -32,27 +32,16 @@ import com.keylesspalace.tusky.entity.Status
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo
import javax.inject.Inject
/**
* Created by charlag on 3/24/18.
*/
interface TimelineCases {
fun reblog(statusId: String, reblog: Boolean): Single<Status>
fun favourite(statusId: String, favourite: Boolean): Single<Status>
fun bookmark(statusId: String, bookmark: Boolean): Single<Status>
fun mute(statusId: String, notifications: Boolean, duration: Int?)
fun block(statusId: String)
fun delete(statusId: String): Single<DeletedStatus>
fun pin(statusId: String, pin: Boolean): Single<Status>
fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll>
fun muteConversation(statusId: String, mute: Boolean): Single<Status>
}
class TimelineCasesImpl(
class TimelineCases @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : TimelineCases {
) {
/**
* Unused yet but can be use for cancellation later. It's always a good idea to save
@ -60,7 +49,7 @@ class TimelineCasesImpl(
*/
private val cancelDisposable = CompositeDisposable()
override fun reblog(statusId: String, reblog: Boolean): Single<Status> {
fun reblog(statusId: String, reblog: Boolean): Single<Status> {
val call = if (reblog) {
mastodonApi.reblogStatus(statusId)
} else {
@ -71,7 +60,7 @@ class TimelineCasesImpl(
}
}
override fun favourite(statusId: String, favourite: Boolean): Single<Status> {
fun favourite(statusId: String, favourite: Boolean): Single<Status> {
val call = if (favourite) {
mastodonApi.favouriteStatus(statusId)
} else {
@ -82,7 +71,7 @@ class TimelineCasesImpl(
}
}
override fun bookmark(statusId: String, bookmark: Boolean): Single<Status> {
fun bookmark(statusId: String, bookmark: Boolean): Single<Status> {
val call = if (bookmark) {
mastodonApi.bookmarkStatus(statusId)
} else {
@ -93,7 +82,7 @@ class TimelineCasesImpl(
}
}
override fun muteConversation(statusId: String, mute: Boolean): Single<Status> {
fun muteConversation(statusId: String, mute: Boolean): Single<Status> {
val call = if (mute) {
mastodonApi.muteConversation(statusId)
} else {
@ -104,7 +93,7 @@ class TimelineCasesImpl(
}
}
override fun mute(statusId: String, notifications: Boolean, duration: Int?) {
fun mute(statusId: String, notifications: Boolean, duration: Int?) {
mastodonApi.muteAccount(statusId, notifications, duration)
.subscribe(
{
@ -117,7 +106,7 @@ class TimelineCasesImpl(
.addTo(cancelDisposable)
}
override fun block(statusId: String) {
fun block(statusId: String) {
mastodonApi.blockAccount(statusId)
.subscribe(
{
@ -130,14 +119,14 @@ class TimelineCasesImpl(
.addTo(cancelDisposable)
}
override fun delete(statusId: String): Single<DeletedStatus> {
fun delete(statusId: String): Single<DeletedStatus> {
return mastodonApi.deleteStatus(statusId)
.doAfterSuccess {
eventHub.dispatch(StatusDeletedEvent(statusId))
}
}
override fun pin(statusId: String, pin: Boolean): Single<Status> {
fun pin(statusId: String, pin: Boolean): Single<Status> {
// Replace with extension method if we use RxKotlin
return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId))
.doAfterSuccess {
@ -145,7 +134,7 @@ class TimelineCasesImpl(
}
}
override fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
if (choices.isEmpty()) {
return Single.error(IllegalStateException())
}

View File

@ -16,19 +16,12 @@
package com.keylesspalace.tusky.service
import android.content.Context
import android.os.Build
import androidx.core.content.ContextCompat
import javax.inject.Inject
interface ServiceClient {
fun sendToot(tootToSend: TootToSend)
}
class ServiceClientImpl(private val context: Context) : ServiceClient {
override fun sendToot(tootToSend: TootToSend) {
class ServiceClient @Inject constructor(private val context: Context) {
fun sendToot(tootToSend: TootToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
ContextCompat.startForegroundService(context, intent)
}
}

View File

@ -33,7 +33,7 @@ import java.util.regex.Pattern
/**
* replaces emoji shortcodes in a text with EmojiSpans
* @param text the text containing custom emojis
* @receiver the text containing custom emojis
* @param emojis a list of the custom emojis (nullable for backward compatibility with old mastodon instances)
* @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable)
* @return the text with the shortcodes replaced by EmojiSpans
@ -44,7 +44,7 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View, animate: Boolean): Ch
val builder = SpannableStringBuilder.valueOf(this)
emojis.forEach { (shortcode, url) ->
emojis.forEach { (shortcode, url, staticUrl) ->
val matcher = Pattern.compile(":$shortcode:", Pattern.LITERAL)
.matcher(this)
@ -54,7 +54,7 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View, animate: Boolean): Ch
builder.setSpan(span, matcher.start(), matcher.end(), 0)
Glide.with(view)
.asDrawable()
.load(url)
.load(if (animate) { url } else { staticUrl })
.into(span.getTarget(animate))
}
}

View File

@ -1,256 +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.util;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
public class LinkHelper {
public static String getDomain(String urlString) {
URI uri;
try {
uri = new URI(urlString);
} catch (URISyntaxException e) {
return "";
}
String host = uri.getHost();
if(host == null) {
return "";
} else if (host.startsWith("www.")) {
return host.substring(4);
} else {
return host;
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @param content containing text with mentions, links, or hashtags
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableText(TextView view, CharSequence content,
@Nullable List<Status.Mention> mentions, final LinkListener listener) {
SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content);
URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {
int start = builder.getSpanStart(span);
int end = builder.getSpanEnd(span);
int flags = builder.getSpanFlags(span);
CharSequence text = builder.subSequence(start, end);
ClickableSpan customSpan = null;
if (text.charAt(0) == '#') {
final String tag = text.subSequence(1, text.length()).toString();
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewTag(tag); }
};
} else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) {
String accountUsername = text.subSequence(1, text.length()).toString();
/* There may be multiple matches for users on different instances with the same
* username. If a match has the same domain we know it's for sure the same, but if
* that can't be found then just go with whichever one matched last. */
String id = null;
for (Status.Mention mention : mentions) {
if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) {
id = mention.getId();
if (mention.getUrl().contains(getDomain(span.getURL()))) {
break;
}
}
}
if (id != null) {
final String accountId = id;
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); }
};
}
}
if (customSpan == null) {
customSpan = new NoUnderlineURLSpan(span.getURL()) {
@Override
public void onClick(@NonNull View widget) {
listener.onViewUrl(getURL(), text.toString());
}
};
}
builder.removeSpan(span);
builder.setSpan(customSpan, start, end, flags);
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= builder.length() ||
builder.subSequence(end, end + 1).toString().equals("\n")){
builder.insert(end, "\u200B");
}
}
view.setText(builder);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableMentions(
TextView view, @Nullable List<Status.Mention> mentions, final LinkListener listener) {
if (mentions == null || mentions.size() == 0) {
view.setText(null);
return;
}
SpannableStringBuilder builder = new SpannableStringBuilder();
int start = 0;
int end = 0;
int flags;
boolean firstMention = true;
for (Status.Mention mention : mentions) {
String accountUsername = mention.getLocalUsername();
final String accountId = mention.getId();
ClickableSpan customSpan = new NoUnderlineURLSpan(mention.getUrl()) {
@Override
public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); }
};
end += 1 + accountUsername.length(); // length of @ + username
flags = builder.getSpanFlags(customSpan);
if (firstMention) {
firstMention = false;
} else {
builder.append(" ");
start += 1;
end += 1;
}
builder.append("@");
builder.append(accountUsername);
builder.setSpan(customSpan, start, end, flags);
builder.append("\u200B"); // same reasonning than in setClickableText
end += 1; // shift position to take the previous character into account
start = end;
}
view.setText(builder);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
public static CharSequence createClickableText(String text, String link) {
URLSpan span = new NoUnderlineURLSpan(link);
SpannableStringBuilder clickableText = new SpannableStringBuilder(text);
clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
return clickableText;
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @param url a string containing the url to open
* @param context context
*/
public static void openLink(String url, Context context) {
Uri uri = Uri.parse(url).normalizeScheme();
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("customTabs", false);
if (useCustomTabs) {
openLinkInCustomTab(uri, context);
} else {
openLinkInBrowser(uri, context);
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInBrowser(Uri uri, Context context) {
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w("LinkHelper", "Actvity was not found for intent, " + intent);
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInCustomTab(Uri uri, Context context) {
int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface);
int navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor);
int navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor);
CustomTabColorSchemeParams colorSchemeParams = new CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build();
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build();
try {
customTabsIntent.launchUrl(context, uri);
} catch (ActivityNotFoundException e) {
Log.w("LinkHelper", "Activity was not found for intent " + customTabsIntent);
openLinkInBrowser(uri, context);
}
}
}

View File

@ -0,0 +1,240 @@
/* 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>. */
@file:JvmName("LinkHelper")
package com.keylesspalace.tusky.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status.Mention
import com.keylesspalace.tusky.interfaces.LinkListener
fun getDomain(urlString: String?): String {
val host = urlString?.toUri()?.host
return when {
host == null -> ""
host.startsWith("www.") -> host.substring(4)
else -> host
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @param content containing text with mentions, links, or hashtags
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableText(view: TextView, content: CharSequence, mentions: List<Mention>, tags: List<HashTag>?, listener: LinkListener) {
view.text = SpannableStringBuilder.valueOf(content).apply {
getSpans(0, content.length, URLSpan::class.java).forEach {
setClickableText(it, this, mentions, tags, listener)
}
}
view.movementMethod = LinkMovementMethod.getInstance()
}
@VisibleForTesting
fun setClickableText(
span: URLSpan,
builder: SpannableStringBuilder,
mentions: List<Mention>,
tags: List<HashTag>?,
listener: LinkListener
) = builder.apply {
val start = getSpanStart(span)
val end = getSpanEnd(span)
val flags = getSpanFlags(span)
val text = subSequence(start, end)
val customSpan = when (text[0]) {
'#' -> getCustomSpanForTag(text, tags, span, listener)
'@' -> getCustomSpanForMention(mentions, span, listener)
else -> null
} ?: object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewUrl(url, text.toString())
}
removeSpan(span)
setSpan(customSpan, start, end, flags)
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= length || subSequence(end, end + 1).toString() == "\n") {
insert(end, "\u200B")
}
}
@VisibleForTesting
fun getTagName(text: CharSequence, tags: List<HashTag>?): String? {
val scrapedName = text.subSequence(1, text.length).toString()
return when (tags) {
null -> scrapedName
else -> tags.firstOrNull { it.name.equals(scrapedName, true) }?.name
}
}
private fun getCustomSpanForTag(text: CharSequence, tags: List<HashTag>?, span: URLSpan, listener: LinkListener): ClickableSpan? {
return getTagName(text, tags)?.let {
object : NoUnderlineURLSpan(span.url) {
override fun onClick(view: View) = listener.onViewTag(it)
}
}
}
private fun getCustomSpanForMention(mentions: List<Mention>, span: URLSpan, listener: LinkListener): ClickableSpan? {
// https://github.com/tuskyapp/Tusky/pull/2339
return mentions.firstOrNull { it.url == span.url }?.let {
getCustomSpanForMentionUrl(span.url, it.id, listener)
}
}
private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan {
return object : NoUnderlineURLSpan(url) {
override fun onClick(view: View) = listener.onViewAccount(mentionId)
}
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableMentions(view: TextView, mentions: List<Mention>?, listener: LinkListener) {
if (mentions?.isEmpty() != false) {
view.text = null
return
}
view.text = SpannableStringBuilder().apply {
var start = 0
var end = 0
var flags: Int
var firstMention = true
for (mention in mentions) {
val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener)
end += 1 + mention.username.length // length of @ + username
flags = getSpanFlags(customSpan)
if (firstMention) {
firstMention = false
} else {
append(" ")
start += 1
end += 1
}
append("@")
append(mention.username)
setSpan(customSpan, start, end, flags)
append("\u200B") // same reasoning as in setClickableText
end += 1 // shift position to take the previous character into account
start = end
}
}
view.movementMethod = LinkMovementMethod.getInstance()
}
fun createClickableText(text: String, link: String): CharSequence {
return SpannableStringBuilder(text).apply {
setSpan(NoUnderlineURLSpan(link), 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @receiver the Context to open the link from
* @param url a string containing the url to open
*/
fun Context.openLink(url: String) {
val uri = url.toUri().normalizeScheme()
val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("customTabs", false)
if (useCustomTabs) {
openLinkInCustomTab(uri, this)
} else {
openLinkInBrowser(uri, this)
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
private fun openLinkInBrowser(uri: Uri?, context: Context) {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Actvity was not found for intent, $intent")
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
private fun openLinkInCustomTab(uri: Uri, context: Context) {
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
openLinkInBrowser(uri, context)
}
}
private const val TAG = "LinkHelper"

View File

@ -182,7 +182,7 @@ class ListStatusAccessibilityDelegate(
android.R.layout.simple_list_item_1,
textLinks
)
) { _, which -> LinkHelper.openLink(links[which].link, host.context) }
) { _, which -> host.context.openLink(links[which].link) }
.show()
.let { forceFocus(it.listView) }
}

View File

@ -1,35 +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.util
enum class Status {
RUNNING,
SUCCESS,
FAILED
}
@Suppress("DataClassPrivateConstructor")
data class NetworkState private constructor(
val status: Status,
val msg: String? = null
) {
companion object {
val LOADED = NetworkState(Status.SUCCESS)
val LOADING = NetworkState(Status.RUNNING)
fun error(msg: String?) = NetworkState(Status.FAILED, msg)
}
}

View File

@ -29,6 +29,6 @@ open class NoUnderlineURLSpan(
}
override fun onClick(view: View) {
LinkHelper.openLink(url, view.context)
view.context.openLink(url)
}
}

View File

@ -17,27 +17,39 @@ fun randomAlphanumericString(count: Int): String {
}
// We sort statuses by ID. Something we need to invent some ID for placeholder.
// Not sure if inc()/dec() should be made `operator` or not
/**
* "Increment" string so that during sorting it's bigger than [this].
* "Increment" string so that during sorting it's bigger than [this]. Inverse operation to [dec].
*/
fun String.inc(): String {
// We assume that we will stay in the safe range for now
val builder = this.toCharArray()
builder[lastIndex] = builder[lastIndex].inc()
return String(builder)
var i = builder.lastIndex
while (i >= 0) {
if (builder[i] < 'z') {
builder[i] = builder[i].inc()
return String(builder)
} else {
builder[i] = '0'
}
i--
}
return String(
CharArray(builder.size + 1) { index ->
if (index == 0) '0' else builder[index - 1]
}
)
}
/**
* "Decrement" string so that during sorting it's smaller than [this].
* "Decrement" string so that during sorting it's smaller than [this]. Inverse operation to [inc].
*/
fun String.dec(): String {
if (this.isEmpty()) return this
val builder = this.toCharArray()
var i = builder.lastIndex
while (i > 0) {
while (i >= 0) {
if (builder[i] > '0') {
builder[i] = builder[i].dec()
return String(builder)
@ -46,12 +58,7 @@ fun String.dec(): String {
}
i--
}
return if (builder[0] > '1') {
builder[0] = builder[0].dec()
String(builder)
} else {
String(builder.copyOfRange(1, builder.size))
}
return String(builder.copyOfRange(1, builder.size))
}
/**
@ -71,15 +78,6 @@ fun String.isLessThan(other: String): Boolean {
}
}
fun String.idCompareTo(other: String): Int {
return when {
this === other -> 0
this.length < other.length -> -1
this.length > other.length -> 1
else -> this.compareTo(other)
}
}
fun Spanned.trimTrailingWhitespace(): Spanned {
var i = length
do {

View File

@ -21,9 +21,9 @@ import android.view.LayoutInflater
import com.google.android.material.card.MaterialCardView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.CardLicenseBinding
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
class LicenseCard
@JvmOverloads constructor(
@ -50,7 +50,7 @@ class LicenseCard
binding.licenseCardLink.hide()
} else {
binding.licenseCardLink.text = link
setOnClickListener { LinkHelper.openLink(link, context) }
setOnClickListener { context.openLink(link) }
}
}
}

View File

@ -15,15 +15,11 @@
package com.keylesspalace.tusky.viewmodel
import android.content.Context
import android.graphics.Bitmap
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.keylesspalace.tusky.EditProfileActivity.Companion.AVATAR_SIZE
import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_HEIGHT
import com.keylesspalace.tusky.EditProfileActivity.Companion.HEADER_WIDTH
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.entity.Account
@ -31,16 +27,12 @@ import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.IOUtils
import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getSampledBitmap
import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
@ -52,30 +44,26 @@ import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.OutputStream
import javax.inject.Inject
private const val HEADER_FILE_NAME = "header.png"
private const val AVATAR_FILE_NAME = "avatar.png"
private const val TAG = "EditProfileViewModel"
class EditProfileViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
private val eventHub: EventHub,
private val application: Application
) : ViewModel() {
val profileData = MutableLiveData<Resource<Account>>()
val avatarData = MutableLiveData<Resource<Bitmap>>()
val headerData = MutableLiveData<Resource<Bitmap>>()
val avatarData = MutableLiveData<Uri>()
val headerData = MutableLiveData<Uri>()
val saveData = MutableLiveData<Resource<Nothing>>()
val instanceData = MutableLiveData<Resource<Instance>>()
private var oldProfileData: Account? = null
private val disposeables = CompositeDisposable()
private val disposables = CompositeDisposable()
fun obtainProfile() {
if (profileData.value == null || profileData.value is Error) {
@ -92,70 +80,30 @@ class EditProfileViewModel @Inject constructor(
profileData.postValue(Error())
}
)
.addTo(disposeables)
.addTo(disposables)
}
}
fun newAvatar(uri: Uri, context: Context) {
val cacheFile = getCacheFileForName(context, AVATAR_FILE_NAME)
fun getAvatarUri() = getCacheFileForName(AVATAR_FILE_NAME).toUri()
resizeImage(uri, context, AVATAR_SIZE, AVATAR_SIZE, cacheFile, avatarData)
fun getHeaderUri() = getCacheFileForName(HEADER_FILE_NAME).toUri()
fun newAvatarPicked() {
avatarData.value = getAvatarUri()
}
fun newHeader(uri: Uri, context: Context) {
val cacheFile = getCacheFileForName(context, HEADER_FILE_NAME)
resizeImage(uri, context, HEADER_WIDTH, HEADER_HEIGHT, cacheFile, headerData)
fun newHeaderPicked() {
headerData.value = getHeaderUri()
}
private fun resizeImage(
uri: Uri,
context: Context,
resizeWidth: Int,
resizeHeight: Int,
cacheFile: File,
imageLiveData: MutableLiveData<Resource<Bitmap>>
) {
Single.fromCallable {
val contentResolver = context.contentResolver
val sourceBitmap = getSampledBitmap(contentResolver, uri, resizeWidth, resizeHeight)
if (sourceBitmap == null) {
throw Exception()
}
// dont upscale image if its smaller than the desired size
val bitmap =
if (sourceBitmap.width <= resizeWidth && sourceBitmap.height <= resizeHeight) {
sourceBitmap
} else {
Bitmap.createScaledBitmap(sourceBitmap, resizeWidth, resizeHeight, true)
}
if (!saveBitmapToFile(bitmap, cacheFile)) {
throw Exception()
}
bitmap
}.subscribeOn(Schedulers.io())
.subscribe(
{
imageLiveData.postValue(Success(it))
},
{
imageLiveData.postValue(Error())
}
)
.addTo(disposeables)
}
fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>, context: Context) {
fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List<StringField>) {
if (saveData.value is Loading || profileData.value !is Success) {
return
}
saveData.value = Loading()
val displayName = if (oldProfileData?.intentionallyUseDisplayName == newDisplayName) {
null
} else {
@ -174,15 +122,15 @@ class EditProfileViewModel @Inject constructor(
newLocked.toString().toRequestBody(MultipartBody.FORM)
}
val avatar = if (avatarData.value is Success && avatarData.value?.data != null) {
val avatarBody = getCacheFileForName(context, AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
val avatar = if (avatarData.value != null) {
val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody)
} else {
null
}
val header = if (headerData.value is Success && headerData.value?.data != null) {
val headerBody = getCacheFileForName(context, HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
val header = if (headerData.value != null) {
val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull())
MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody)
} else {
null
@ -256,29 +204,12 @@ class EditProfileViewModel @Inject constructor(
)
}
private fun getCacheFileForName(context: Context, filename: String): File {
return File(context.cacheDir, filename)
}
private fun saveBitmapToFile(bitmap: Bitmap, file: File): Boolean {
val outputStream: OutputStream
try {
outputStream = FileOutputStream(file)
} catch (e: FileNotFoundException) {
Log.w(TAG, Log.getStackTraceString(e))
return false
}
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
IOUtils.closeQuietly(outputStream)
return true
private fun getCacheFileForName(filename: String): File {
return File(application.cacheDir, filename)
}
override fun onCleared() {
disposeables.dispose()
disposables.dispose()
}
fun obtainInstance() {
@ -293,7 +224,7 @@ class EditProfileViewModel @Inject constructor(
instanceData.postValue(Error())
}
)
.addTo(disposeables)
.addTo(disposables)
}
}
}

View File

@ -12,6 +12,7 @@ import com.google.android.material.button.MaterialButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -66,11 +67,16 @@ public class QuoteInlineHelper {
quoteUsername.setText(usernameText);
}
private void setContent(Spanned content, List<Status.Mention> mentions, List<Emoji> emojis,
LinkListener listener) {
private void setContent(
Spanned content,
List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis,
LinkListener listener
) {
Spanned singleLineText = SpannedTextHelper.replaceSpanned(content);
CharSequence emojifiedText = CustomEmojiHelper.emojify(singleLineText, emojis, quoteContent, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(quoteContent, emojifiedText, mentions, listener);
LinkHelper.setClickableText(quoteContent, emojifiedText, mentions, tags, listener);
}
private void setAvatar(String url, @Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) {
@ -117,8 +123,13 @@ public class QuoteInlineHelper {
Account account = quoteStatus.getAccount();
setDisplayName(account.getName(), account.getEmojis());
setUsername(account.getUsername());
setContent(quoteStatus.getContent(), quoteStatus.getMentions(),
quoteStatus.getEmojis(), listener);
setContent(
quoteStatus.getContent(),
quoteStatus.getMentions(),
quoteStatus.getTags(),
quoteStatus.getEmojis(),
listener
);
setAvatar(account.getAvatar(), avatarRadius24dp, statusDisplayOptions);
setOnClickListener(account.getId(), quoteStatus.getUrl());

View File

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.EditProfileActivity">
tools:context=".EditProfileActivity">
<include
android:id="@+id/includedToolbar"
@ -37,17 +37,6 @@
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_add_a_photo_32dp" />
<ProgressBar
android:id="@+id/headerProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/headerPreview"
app:layout_constraintEnd_toEndOf="@id/headerPreview"
app:layout_constraintStart_toStartOf="@id/headerPreview"
app:layout_constraintTop_toTopOf="@id/headerPreview" />
<ImageView
android:id="@+id/avatarPreview"
android:layout_width="80dp"
@ -71,18 +60,6 @@
app:layout_constraintTop_toBottomOf="@id/headerPreview"
app:srcCompat="@drawable/ic_add_a_photo_32dp" />
<ProgressBar
android:id="@+id/avatarProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:indeterminate="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/avatarPreview"
app:layout_constraintEnd_toEndOf="@id/avatarPreview"
app:layout_constraintStart_toStartOf="@id/avatarPreview"
app:layout_constraintTop_toTopOf="@id/avatarPreview" />
<LinearLayout
android:id="@+id/contentContainer"
android:layout_width="@dimen/timeline_width"

View File

@ -94,6 +94,16 @@
license:link="https://square.github.io/retrofit/"
license:name="Retrofit" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
license:license="@string/license_apache_2"
license:link="https://github.com/google/gson"
license:name="Gson" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -174,6 +184,16 @@
license:link="https://github.com/CanHub/Android-Image-Cropper"
license:name="Android Image Cropper" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
license:license="@string/license_apache_2"
license:link="https://github.com/penfeizhou/APNG4Android"
license:name="APNG4Android" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -242,7 +262,6 @@
android:layout_marginTop="12dp"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>

View File

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.keylesspalace.tusky.ModalTimelineActivity">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<include
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/contentFrame"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/floating_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/action_compose"
app:layout_anchor="@id/contentFrame"
app:layout_anchorGravity="bottom|end"
app:srcCompat="@drawable/ic_create_24dp" />
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<net.accelf.yuito.QuickTootView
android:id="@+id/viewQuickToot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0" />
</LinearLayout>

View File

@ -18,18 +18,18 @@
layout="@layout/toolbar_basic" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/floating_btn"
android:id="@+id/floatingBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/action_compose"
app:layout_anchor="@id/fragment_container"
app:layout_anchor="@id/fragmentContainer"
app:layout_anchorGravity="bottom|end"
app:srcCompat="@drawable/ic_create_24dp" />

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.keylesspalace.tusky.ViewTagActivity">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<include layout="@layout/toolbar_basic" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<net.accelf.yuito.QuickTootView
android:id="@+id/viewQuickToot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0" />
</LinearLayout>

View File

@ -37,7 +37,6 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:entries="@array/poll_duration_names"
app:layout_constraintBottom_toBottomOf="@id/addChoiceButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/addChoiceButton"

View File

@ -72,12 +72,13 @@
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginStart="12dp"
android:layout_marginTop="14dp"
android:layout_marginEnd="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_accept"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/rejectButton"
app:layout_constraintTop_toBottomOf="@id/notificationTextView"
app:layout_constraintTop_toTopOf="@id/avatar"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:srcCompat="@drawable/ic_check_24dp" />
<ImageButton
@ -87,12 +88,12 @@
android:layout_height="32dp"
android:layout_centerVertical="true"
android:layout_marginStart="12dp"
android:layout_marginTop="14dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_reject"
android:padding="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/notificationTextView"
app:layout_constraintTop_toTopOf="@id/avatar"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:srcCompat="@drawable/ic_reject_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -6,6 +6,10 @@
android:title="@string/action_open_in_web"
app:showAsAction="never" />
<item android:id="@+id/action_open_as"
android:title="@string/action_open_as"
app:showAsAction="never" />
<item android:id="@+id/action_mute"
android:title="@string/action_mute"
app:showAsAction="never" />

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