Merge tag 'v17.0' into develop

This commit is contained in:
Mike Barnes 2022-06-06 16:57:59 +10:00
commit ea95fc2f4b
243 changed files with 8456 additions and 5710 deletions

View file

@ -14,7 +14,7 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
- Material Design - Material Design
- Multi-Account support - Multi-Account support
- Respect device preferences for light/dark theming - Respect device preferences for light/dark theming
- Drafts - compose toots and save them for later - Drafts - compose posts and save them for later
- Choose between different emoji styles - Choose between different emoji styles
- Optimized for all screen sizes - Optimized for all screen sizes
- Completely open-source - no non-free dependencies like Google services - Completely open-source - no non-free dependencies like Google services
@ -22,7 +22,7 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C
### Support ### Support
Check out Tusky's [FAQs](https://github.com/tuskyapp/faq), your question may already be answered. Check out Tusky's [FAQs](https://github.com/tuskyapp/faq), your question may already be answered.
If you have any bug reports, feature requests or questions please open an issue or send us a toot at [chinwagnews@chinwag.org](https://social.chinwag.org/@ChinwagNews)! If you have any bug reports, feature requests or questions please open an issue or send us a message at [chinwagnews@chinwag.org](https://social.chinwag.org/@ChinwagNews)!
For translating Tusky into your language, visit https://weblate.tusky.app/ For translating Tusky into your language, visit https://weblate.tusky.app/

View file

@ -15,13 +15,13 @@ def getGitSha = {
} }
android { android {
compileSdkVersion 30 compileSdkVersion 31
defaultConfig { defaultConfig {
applicationId APP_ID applicationId APP_ID
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 31
versionCode 87 versionCode 87
versionName "16.0-CW1" versionName "17.0-CW1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -90,12 +90,12 @@ android {
} }
ext.coroutinesVersion = "1.6.0" ext.coroutinesVersion = "1.6.0"
ext.lifecycleVersion = "2.3.1" ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.3.0' ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.9.3' ext.okhttpVersion = '4.9.3'
ext.glideVersion = '4.12.0' ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.40.5' ext.daggerVersion = '2.41'
ext.materialdrawerVersion = '8.4.5' ext.materialdrawerVersion = '8.4.5'
// if libraries are changed here, they should also be changed in LicenseActivity // if libraries are changed here, they should also be changed in LicenseActivity
@ -105,33 +105,35 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
implementation "androidx.core:core-ktx:1.5.0" implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.appcompat:appcompat:1.3.0" implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.fragment:fragment-ktx:1.3.4" implementation "androidx.fragment:fragment-ktx:1.4.1"
implementation "androidx.browser:browser:1.3.0" implementation "androidx.browser:browser:1.4.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.exifinterface:exifinterface:1.3.3" implementation "androidx.exifinterface:exifinterface:1.3.3"
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.sharetarget:sharetarget:1.1.0" implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
implementation "androidx.emoji:emoji:1.1.0" implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0" implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:2.1.2" implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.paging:paging-runtime-ktx:3.0.0" implementation "androidx.paging:paging-runtime-ktx:3.1.1"
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.5.0" implementation "androidx.work:work-runtime:2.7.1"
implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion"
implementation "androidx.room:room-paging:$roomVersion"
implementation "androidx.room:room-rxjava3:$roomVersion" implementation "androidx.room:room-rxjava3:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
implementation "com.google.android.material:material:1.4.0" implementation "com.google.android.material:material:1.5.0"
implementation "com.google.code.gson:gson:2.8.9" implementation "com.google.code.gson:gson:2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
@ -146,14 +148,14 @@ dependencies {
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion" kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.17.0" implementation "com.github.penfeizhou.android.animation:glide-plugin:2.20.0"
implementation "io.reactivex.rxjava3:rxjava:3.0.12" implementation "io.reactivex.rxjava3:rxjava:3.1.3"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0" implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
implementation "io.reactivex.rxjava3:rxkotlin:3.0.1" implementation "io.reactivex.rxjava3:rxkotlin:3.0.1"
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.0.0" implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"
implementation "com.uber.autodispose2:autodispose:2.0.0" implementation "com.uber.autodispose2:autodispose:2.1.1"
implementation "com.google.dagger:dagger:$daggerVersion" implementation "com.google.dagger:dagger:$daggerVersion"
kapt "com.google.dagger:dagger-compiler:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion"
@ -169,7 +171,7 @@ dependencies {
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-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" implementation "de.c1710:filemojicompat:1.0.18"

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

@ -0,0 +1,809 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"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"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, 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"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `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

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="notification_color">#19A341</color>
</resources>

View file

@ -20,9 +20,12 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/TuskyTheme" android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<activity <activity
android:name=".SplashActivity" android:name=".SplashActivity"
android:theme="@style/SplashTheme"> android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -35,22 +38,15 @@
</activity> </activity>
<activity <activity
android:name=".LoginActivity" android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:scheme="@string/oauth_scheme" />
</intent-filter>
</activity> </activity>
<activity android:name=".components.login.LoginWebViewActivity" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"> android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@ -106,7 +102,6 @@
<activity <activity
android:name=".ViewThreadActivity" android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" /> android:configChanges="orientation|screenSize" />
<activity android:name=".ViewTagActivity" />
<activity <activity
android:name=".ViewMediaActivity" android:name=".ViewMediaActivity"
android:theme="@style/TuskyBaseTheme" /> android:theme="@style/TuskyBaseTheme" />
@ -124,7 +119,8 @@
android:theme="@style/Base.Theme.AppCompat" /> android:theme="@style/Base.Theme.AppCompat" />
<activity <activity
android:name=".components.search.SearchActivity" android:name=".components.search.SearchActivity"
android:launchMode="singleTop"> android:launchMode="singleTop"
android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEARCH" /> <action android:name="android.intent.action.SEARCH" />
</intent-filter> </intent-filter>
@ -134,18 +130,18 @@
android:resource="@xml/searchable" /> android:resource="@xml/searchable" />
</activity> </activity>
<activity android:name=".ListsActivity" /> <activity android:name=".ListsActivity" />
<activity android:name=".ModalTimelineActivity" />
<activity android:name=".LicenseActivity" /> <activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" /> <activity android:name=".FiltersActivity" />
<activity <activity
android:name=".components.report.ReportActivity" android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" /> <activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.scheduled.ScheduledTootActivity" /> <activity android:name=".components.scheduled.ScheduledStatusActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" /> <activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" /> <activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver"
android:exported="false" />
<receiver <receiver
android:name=".receiver.SendStatusBroadcastReceiver" android:name=".receiver.SendStatusBroadcastReceiver"
android:enabled="true" android:enabled="true"
@ -154,15 +150,17 @@
<service <service
android:name=".service.TuskyTileService" android:name=".service.TuskyTileService"
android:icon="@drawable/ic_chinwag_logo_simple" android:icon="@drawable/ic_chinwag_logo_simple"
android:label="Compose Toot" android:label="@string/tusky_compose_post_quicksetting_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true"
tools:targetApi="24"> tools:targetApi="24">
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service android:name=".service.SendTootService" /> <service android:name=".service.SendStatusService"
android:exported="false" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@ -176,10 +174,16 @@
<!-- disable automatic WorkManager initialization --> <!-- disable automatic WorkManager initialization -->
<provider <provider
android:name="androidx.work.impl.WorkManagerInitializer" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.workmanager-init" android:authorities="${applicationId}.androidx-startup"
android:exported="false" android:exported="false"
tools:node="remove" /> tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -1,488 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8"?>
<svg <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
xmlns:dc="http://purl.org/dc/elements/1.1/" <svg version="1.2" width="180mm" height="180mm" viewBox="0 0 18000 18000" preserveAspectRatio="xMidYMid" fill-rule="evenodd" stroke-width="28.222" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xmlns:ooo="http://xml.openoffice.org/svg/export" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:presentation="http://sun.com/xmlns/staroffice/presentation" xmlns:smil="http://www.w3.org/2001/SMIL20/" xmlns:anim="urn:oasis:names:tc:opendocument:xmlns:animation:1.0" xml:space="preserve">
xmlns:cc="http://creativecommons.org/ns#" <defs class="ClipPathGroup">
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" <clipPath id="presentation_clip_path" clipPathUnits="userSpaceOnUse">
xmlns:svg="http://www.w3.org/2000/svg" <rect x="0" y="0" width="18000" height="18000"/>
xmlns="http://www.w3.org/2000/svg" </clipPath>
xmlns:xlink="http://www.w3.org/1999/xlink" <clipPath id="presentation_clip_path_shrink" clipPathUnits="userSpaceOnUse">
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" <rect x="18" y="18" width="17964" height="17964"/>
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" </clipPath>
id="svg181" </defs>
viewBox="0 0 135.46666 135.46666" <defs class="TextShapeIndex">
version="1.1" <g ooo:slide="id1" ooo:id-list="id3 id4 id5 id6 id7"/>
height="512" </defs>
width="512" <defs class="EmbeddedBulletChars">
sodipodi:docname="ic_launcher2.svg" <g id="bullet-char-template-57356" transform="scale(0.00048828125,-0.00048828125)">
inkscape:version="0.92.1 r15371"> <path d="M 580,1141 L 1163,571 580,0 -4,571 580,1141 Z"/>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1147"
id="namedview4579"
showgrid="false"
inkscape:measure-start="140.54,184.424"
inkscape:measure-end="499.461,-175.02"
inkscape:zoom="1.9140625"
inkscape:cx="253.80846"
inkscape:cy="248.95369"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g157" />
<metadata
id="metadata185">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs147">
<linearGradient
inkscape:collect="always"
id="linearGradient4780">
<stop
style="stop-color:#2588d0;stop-opacity:1;"
offset="0"
id="stop4776" />
<stop
style="stop-color:#1967a3;stop-opacity:1"
offset="1"
id="stop4778" />
</linearGradient>
<linearGradient
id="c">
<stop
id="stop2"
offset="0" />
<stop
id="stop4"
offset="1"
stop-opacity="0" />
</linearGradient>
<clipPath
id="g">
<circle
id="circle7"
fill="#2588d0"
r="112.52"
cy="125"
cx="125" />
</clipPath>
<clipPath
id="e">
<g
id="g132"
opacity=".05"
fill="url(#a)"
clip-path="url(#g)"
transform="matrix(.26458 0 0 .26458 7.7244 -29.561)">
<g
id="g128"
stroke-width=".5">
<path
id="path10"
fill="url(#a)"
d="m98.535 73.473c-1.8371-0.09054-2.2298 0.10241-1.635 0.69722l70.711 70.711c-0.59481-0.59481-0.2021-0.78775 1.635-0.69722z" />
<path
id="path12"
fill="url(#a)"
d="m96.9 74.17c0.70818 0.70818 2.8162 1.986 5.553 4.0332l70.711 70.711c-2.7368-2.0472-4.8448-3.3251-5.553-4.0332z" />
<path
id="path14"
fill="url(#a)"
d="m102.45 78.203c2.4807 1.8557 4.9237 4.0232 7.1498 6.2492l70.711 70.711c-2.226-2.226-4.6691-4.3935-7.1498-6.2492z" />
<path
id="path16"
fill="url(#a)"
d="m109.6 84.452c3.2825 3.2825 6.093 6.6922 7.8561 9.4168l70.711 70.711c-1.7631-2.7246-4.5736-6.1343-7.8561-9.4168z" />
<path
id="path18"
fill="url(#a)"
d="m117.46 93.869c2.9912 4.6225 5.6692 8.3037 12.262 28.254l70.711 70.711c-6.5925-19.95-9.2705-23.631-12.262-28.254z" />
<path
id="path20"
fill="url(#a)"
d="m129.72 122.12c2.4794 7.5032 5.7538 14.14 6.6211 16.113l70.711 70.711c-0.86733-1.9736-4.1417-8.6101-6.6211-16.113z" />
<path
id="path22"
fill="url(#a)"
d="m136.34 138.24 1.3476 3.0059 70.711 70.711-1.3476-3.0059z" />
<path
id="path24"
fill="url(#a)"
d="m137.69 141.24c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l70.711 70.711c0.0663-0.0833 0.12024-0.14516 0.18554-0.22656z" />
<path
id="path26"
fill="url(#a)"
d="m137.5 141.47-3.4395 3.0098 70.711 70.711 3.4395-3.0098z" />
<path
id="path28"
fill="url(#a)"
d="m134.06 144.48c-5.5481 5.7637-16.033 11.691-24.285 13.729l70.711 70.711c8.2524-2.0372 18.737-7.9648 24.285-13.729z" />
<path
id="path30"
fill="url(#a)"
d="m109.78 158.21c-6.3614 1.5706-21.301 1.1651-27.809 0.0137l70.711 70.711c6.5077 1.1514 21.447 1.5569 27.809-0.0137z" />
<path
id="path32"
fill="url(#a)"
d="m81.971 158.22c-7.8527-1.3894-14.205-5.074-19.134-10.002l70.711 70.711c4.9282 4.9282 11.281 8.6128 19.134 10.002z" />
<path
id="path34"
fill="url(#a)"
d="m62.837 148.22c-6.1069-6.1069-10.026-14.123-11.902-22.049l70.711 70.711c1.8752 7.9251 5.7946 15.942 11.902 22.049z" />
<path
id="path36"
fill="url(#a)"
d="m50.936 126.17c-1.5987-9.5426 0.52116-16.959 1.6895-21.707l70.711 70.711c-1.1683 4.7477-3.2882 12.164-1.6894 21.707z" />
<path
id="path38"
fill="url(#a)"
d="m52.625 104.46c2.6637-10.825 9.7356-20.465 18.479-26.402l70.711 70.711c-8.7429 5.9372-15.815 15.578-18.479 26.402z" />
<path
id="path40"
fill="url(#a)"
d="m71.104 78.061c3.0009-2.0378 4.6792-2.7066 4.4597-2.9261l70.711 70.711c0.21947 0.21947-1.4588 0.88834-4.4597 2.9261z" />
<path
id="path42"
fill="url(#a)"
d="m75.563 75.134c-0.11026-0.11026-0.69947-0.10709-1.8406-0.10709l70.711 70.711c1.1411 0 1.7303-3e-3 1.8406 0.10709z" />
<path
id="path44"
fill="url(#a)"
d="m73.723 75.027c-3.9668 0-10.077 2.6389-14.275 6.0215l70.711 70.711c4.1987-3.3826 10.309-6.0215 14.275-6.0215z" />
<path
id="path46"
fill="url(#a)"
d="m59.447 81.049c-8.0199 6.461-14.768 15.598-18.961 26.336l70.711 70.711c4.1931-10.738 10.941-19.875 18.961-26.336z" />
<path
id="path48"
fill="url(#a)"
d="m40.486 107.38c-2.2433 6.6848-2.2747 5.5109-2.418 15.162l70.711 70.711c0.14324-9.6512 0.17463-8.4773 2.418-15.162z" />
<path
id="path50"
fill="url(#a)"
d="m38.068 122.55c0.37633 6.9829 1.0389 9.3071 3.0723 15.406l70.711 70.711c-2.0333-6.0991-2.6959-8.4234-3.0723-15.406z" />
<path
id="path52"
fill="url(#a)"
d="m41.141 137.95c2.3472 7.0404 6.2768 13.161 11.343 18.227l70.711 70.711c-5.0663-5.0663-8.9959-11.187-11.343-18.227z" />
<path
id="path54"
fill="url(#a)"
d="m52.484 156.18c2.9744 2.9744 6.3406 5.5855 10.008 7.806l70.711 70.711c-3.6678-2.2205-7.034-4.8316-10.008-7.806z" />
<path
id="path56"
fill="url(#a)"
d="m62.492 163.99c4.9985 3.0261 15.324 6.7023 22.098 7.8027l70.711 70.711c-6.7734-1.1005-17.099-4.7766-22.098-7.8027z" />
<path
id="path58"
fill="url(#a)"
d="m84.59 171.79c1.8904 0.30967 4.992 0.90865 7.8848 1.1875l70.711 70.711c-2.8928-0.27885-5.9944-0.87783-7.8848-1.1875z" />
<path
id="path60"
fill="url(#a)"
d="m92.475 172.98c12.195 0.94625 15.055-0.32666 24.506-1.8418l70.711 70.711c-9.4508 1.5151-12.311 2.788-24.506 1.8418z" />
<path
id="path62"
fill="url(#a)"
d="m116.98 171.13c8.5906-2.4983 16.678-7.275 22.934-12.367l70.711 70.711c-6.2561 5.0922-14.343 9.8689-22.934 12.367z" />
<path
id="path64"
fill="url(#a)"
d="m139.91 158.77 5.7871-4.7168 70.711 70.711-5.7871 4.7168z" />
<path
id="path66"
fill="url(#a)"
d="m145.7 154.05c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l70.711 70.711c-0.0864-0.10738-0.19411-0.26225-0.28321-0.375z" />
<path
id="path68"
fill="url(#a)"
d="m145.98 154.43 1.9648 2.0644 70.711 70.711-1.9648-2.0644z" />
<path
id="path70"
fill="url(#a)"
d="m147.95 156.49c0.29948 0.31455 0.59902 0.62179 0.89884 0.92161l70.711 70.711c-0.29982-0.29982-0.59936-0.60705-0.89884-0.9216z" />
<path
id="path72"
fill="url(#a)"
d="m148.85 157.41c5.8411 5.8411 11.787 8.8672 19.377 8.303l70.711 70.711c-7.5898 0.56423-13.535-2.4619-19.377-8.303z" />
<path
id="path74"
fill="url(#a)"
d="m168.22 165.71c7.5551-0.56163 13.041-2.6761 17.49-6.7422l70.711 70.711c-4.4488 4.066-9.9351 6.1806-17.49 6.7422z" />
<path
id="path76"
fill="url(#a)"
d="m185.71 158.97c4.5132-4.1248 5.5354-6.236 7.6699-12.512l70.711 70.711c-2.1345 6.2758-3.1567 8.3869-7.6699 12.512z" />
<path
id="path78"
fill="url(#a)"
d="m193.38 146.46c2.9552-8.6889 4.3184-16.193 3.9922-29.721l70.711 70.711c0.32618 13.527-1.0369 21.032-3.9922 29.721z" />
<path
id="path80"
fill="url(#a)"
d="m197.38 116.74c-0.33294-13.818-1.6567-19.14-5.4434-23.453l70.711 70.711c3.7867 4.3128 5.1104 9.6355 5.4434 23.453z" />
<path
id="path82"
fill="url(#a)"
d="m191.93 93.287c-0.0959-0.10288-0.19218-0.20269-0.28888-0.2994l70.711 70.711c0.0967 0.0967 0.19301 0.19652 0.28888 0.2994z" />
<path
id="path84"
fill="url(#a)"
d="m191.64 92.988c-3.0781-3.0781-6.553-3.0044-8.5724 1.8287l70.711 70.711c2.0194-4.8331 5.4943-4.9068 8.5724-1.8287z" />
<path
id="path86"
fill="url(#a)"
d="m183.07 94.816c-0.79737 1.9089-0.78178 2.91 0.11132 6.918l70.711 70.711c-0.8931-4.008-0.90869-5.0091-0.11132-6.918z" />
<path
id="path88"
fill="url(#a)"
d="m183.18 101.73c1.6289 7.3102 1.7837 25.828 0.45313 32.072l70.711 70.711c1.3306-6.2445 1.1758-24.762-0.45313-32.072z" />
<path
id="path90"
fill="url(#a)"
d="m183.64 133.81c-2.7904 13.095-11.463 17.859-22.207 12.49l70.711 70.711c10.744 5.3688 19.417 0.60451 22.207-12.49z" />
<path
id="path92"
fill="url(#a)"
d="m161.43 146.3c-2.4274-1.213-3.8861-1.9048-5.748-3.5762l70.711 70.711c1.862 1.6714 3.3206 2.3632 5.748 3.5762z" />
<path
id="path94"
fill="url(#a)"
d="m155.68 142.72c-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101l70.711 70.711c0.17623 0.18753 0.31813 0.34519 0.50391 0.54101z" />
<path
id="path96"
fill="url(#a)"
d="m155.18 142.18c1.047-1.7899 1.5781-3.0116 2.7754-4.9883l70.711 70.711c-1.1973 1.9767-1.7284 3.1984-2.7754 4.9883z" />
<path
id="path98"
fill="url(#a)"
d="m157.95 137.19c4.9715-8.2077 9.7196-21.436 11.604-32.795l70.711 70.711c-1.8839 11.359-6.632 24.587-11.604 32.795z" />
<path
id="path100"
fill="url(#a)"
d="m169.56 104.4c1.1273-6.797 0.81713-9.1729-0.83126-10.821l70.711 70.711c1.6484 1.6484 1.9586 4.0243 0.83126 10.821z" />
<path
id="path102"
fill="url(#a)"
d="m168.73 93.575c-0.32731-0.32731-0.70738-0.62594-1.1394-0.92479l70.711 70.711c0.43206 0.29886 0.81213 0.59748 1.1394 0.92479z" />
<path
id="path104"
fill="url(#a)"
d="m167.59 92.65c-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844l70.711 70.711c2.12-0.65064 2.9438-0.58478 4.9531 0.39844z" />
<path
id="path106"
fill="url(#a)"
d="m162.63 92.252c-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531l70.711 70.711c0.58337-0.64492 2.2067-1.5238 3.6055-1.9531z" />
<path
id="path108"
fill="url(#a)"
d="m159.03 94.205c-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945l70.711 70.711c1.1145-4.2479 2.5043-8.2496 3.0879-8.8945z" />
<path
id="path110"
fill="url(#a)"
d="m155.94 103.1c-1.1144 4.2479-2.7555 9.2858-3.6465 11.193l70.711 70.711c0.89102-1.9076 2.532-6.9455 3.6465-11.193z" />
<path
id="path112"
fill="url(#a)"
d="m152.29 114.29c-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777l70.711 70.711c0.28157-0.94013 1.2399-3.2702 2.1309-5.1777z" />
<path
id="path114"
fill="url(#a)"
d="m150.16 119.47c-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l70.711 70.711c0.94515-1.8798 1.9489-4.1868 2.2305-5.127z" />
<path
id="path116"
fill="url(#a)"
d="m147.93 124.6-2 4.0059 70.711 70.711 2-4.0059z" />
<path
id="path118"
fill="url(#a)"
d="m145.93 128.6-1.1016-2.5469 70.711 70.711 1.1016 2.5469z" />
<path
id="path120"
fill="url(#a)"
d="m144.83 126.06c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898l70.711 70.711c2.4766 6.0168 4.6425 11.771 5.1504 12.898z" />
<path
id="path122"
fill="url(#a)"
d="m139.68 113.16c-5.7036-13.857-11.141-23.408-17.401-29.668l70.711 70.711c6.2598 6.2598 11.697 15.811 17.401 29.668z" />
<path
id="path124"
fill="url(#a)"
d="m122.28 83.49c-6.0835-6.0835-12.944-9.058-21.58-9.8534l70.711 70.711c8.6358 0.79538 15.496 3.7699 21.58 9.8534z" />
<path
id="path126"
fill="url(#a)"
d="m100.7 73.637c-0.8718-0.08029-1.5892-0.13573-2.1641-0.16406l70.711 70.711c0.57491 0.0283 1.2923 0.0838 2.1641 0.16407z" />
</g>
<path
id="path130"
fill="url(#a)"
d="m98.535 73.473c-4.0243-0.19833-1.1175 0.96369 3.918 4.7305 6.1387 4.592 12.047 11.094 15.006 15.666 2.9912 4.6225 5.6692 8.3037 12.262 28.254 2.4794 7.5032 5.7538 14.14 6.6211 16.113l1.3476 3.0059c-0.0653 0.0814-0.11927 0.14322-0.18554 0.22656l-3.4395 3.0098c-5.5481 5.7637-16.033 11.691-24.285 13.729-6.3614 1.5706-21.301 1.1651-27.809 0.0137-17.584-3.1111-27.647-17.73-31.035-32.051-1.5987-9.5426 0.52116-16.959 1.6895-21.707 2.6637-10.825 9.7356-20.465 18.479-26.402 4.5085-3.0615 6.0316-3.0332 2.6191-3.0332-3.9668 0-10.077 2.6389-14.275 6.0215-8.0199 6.461-14.768 15.598-18.961 26.336-2.2433 6.6848-2.2747 5.5109-2.418 15.162 0.37633 6.9829 1.0389 9.3071 3.0723 15.406 3.7252 11.174 11.436 20.03 21.352 26.033 4.9985 3.0261 15.324 6.7023 22.098 7.8027 1.8904 0.30967 4.992 0.90865 7.8848 1.1875 12.195 0.94625 15.055-0.32666 24.506-1.8418 8.5906-2.4983 16.678-7.275 22.934-12.367l5.7871-4.7168c0.0891 0.11275 0.1968 0.26762 0.28321 0.375l1.9648 2.0644c6.134 6.4426 12.296 9.8178 20.275 9.2246 7.5551-0.56163 13.041-2.6761 17.49-6.7422 4.5132-4.1248 5.5354-6.236 7.6699-12.512 2.9552-8.6889 4.3184-16.193 3.9922-29.721-0.33294-13.818-1.6567-19.14-5.4434-23.453-3.1472-3.3774-6.7785-3.4556-8.8613 1.5293-0.79737 1.9089-0.78178 2.91 0.11132 6.918 1.6289 7.3102 1.7837 25.828 0.45313 32.072-2.7904 13.095-11.463 17.859-22.207 12.49-2.4274-1.213-3.8861-1.9048-5.748-3.5762-0.18578-0.19582-0.32768-0.35348-0.50391-0.54101 1.047-1.7899 1.5781-3.0116 2.7754-4.9883 4.9715-8.2077 9.7196-21.436 11.604-32.795 1.3511-8.1466 0.63728-9.9421-1.9707-11.746-2.0094-0.98322-2.8331-1.0491-4.9531-0.39844-1.3988 0.42933-3.0221 1.3082-3.6055 1.9531-0.58363 0.64492-1.9734 4.6467-3.0879 8.8945-1.1144 4.2479-2.7555 9.2858-3.6465 11.193-0.89099 1.9076-1.8493 4.2376-2.1309 5.1777-0.28161 0.94013-1.2853 3.2472-2.2305 5.127l-2 4.0059-1.1016-2.5469c-0.50786-1.1277-2.6738-6.8816-5.1504-12.898-11.247-27.323-21.459-37.908-38.98-39.522-0.8718-0.080295-1.5892-0.13573-2.1641-0.16406z" />
</g>
</clipPath>
<linearGradient
xlink:href="#c"
gradientUnits="userSpaceOnUse"
y2="210.2"
y1="153.65"
x2="201.21"
x1="146.2"
id="a" />
<clipPath
id="f">
<circle
id="circle136"
opacity=".1"
clip-path="url(#e)"
r="29.772"
cy="2.6396"
cx="40.292"
transform="matrix(1.0037 0 0 1.0037 1.3836 -1.2671)" />
</clipPath>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="22.179"
y1="6.5707"
x2="61.309"
x1="45.701"
id="b">
<stop
id="stop139"
offset="0" />
<stop
id="stop141"
offset="1"
stop-opacity="0" />
</linearGradient>
<filter
color-interpolation-filters="sRGB"
height="1.048"
width="1.048"
y="-.024"
x="-.024"
id="d">
<feGaussianBlur
id="feGaussianBlur144"
stdDeviation="13.532652" />
</filter>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath4599">
<circle
cx="725.02002"
cy="-684.40002"
id="circle4601"
style="display:inline;fill:#2588d0;stroke-width:1"
r="666.01202" />
</clipPath>
<linearGradient
inkscape:collect="always"
xlink:href="#b"
id="linearGradient4609"
x1="932.39093"
y1="-448.59128"
x2="1103.4291"
y2="-292.29779"
gradientUnits="userSpaceOnUse" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient4780"
id="radialGradient4782"
cx="725.02002"
cy="-684.40002"
fx="725.02002"
fy="-684.40002"
r="666.01202"
gradientUnits="userSpaceOnUse" />
</defs>
<g
style="display:inline"
id="g177"
transform="translate(-8.0628,99.804)">
<g
style="display:inline"
id="g175"
transform="matrix(2,0,0,2,-5.3112,-36.986)">
<g
style="display:inline"
id="g173"
transform="matrix(0.046875,0,0,0.046875,6.3858,34.613)">
<g
style="display:inline"
id="g157">
<circle
style="display:inline;opacity:0.36000001;filter:url(#d)"
id="circle149"
r="676.63"
cy="-672.09003"
cx="710.14001"
transform="matrix(0.98504,0,0,0.98504,29.403,-5.6005)" />
<circle
r="666.01202"
style="display:inline;fill:url(#radialGradient4782);fill-opacity:1"
id="ellipse153"
cy="-684.40002"
cx="725.02002" />
<path
style="opacity:0.1;fill:url(#linearGradient4609);fill-opacity:1.0;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 502.01302,-949.02227 384.24915,383.32798 -3.43645,-89.82106 -77.68618,-110.93471 -187.18224,-187.80303 137.91285,50.53567 165.36793,165.01042 87.04612,-110.32758 124.5448,123.76483 1.5739,-125.34778 679.4554,673.25412 -544.5472,559.35788 -866.24945,-865.97293 -82.57017,-258.03173 64.87656,-135.65098 z"
id="path4595"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccccccccccc"
clip-path="url(#clipPath4599)" />
</g>
<g
style="display:inline;stroke-width:0.052917"
id="g171"
transform="matrix(21.768,0,0,21.716,-148.18,-741.68)">
<g
style="display:inline;stroke:#efefef"
id="g167">
<g
style="display:inline;stroke:#efefef;stroke-width:0.052917"
id="g165">
<g
style="display:inline;stroke-width:0.052853"
id="g163"
transform="matrix(1.0012,0,0,1.0012,-0.2806,-0.13189)">
<path
style="display:inline;fill:#fefefe;stroke:#e9e9eb"
id="path159"
d="M 33.997,14.956 C 27.2465,14.30008 22.721,10.953 21.162,5.4628 c -0.97232,-3.4236 -0.40007,-6.6554 1.7689,-9.9897 1.963,-3.0177 4.5842,-4.9793 6.6538,-4.9793 h 0.55721 l -0.68124,0.45556 c -2.3327,1.5599 -4.1732,3.9523 -5.0456,6.5585 -0.51198,1.5295 -0.64528,4.5724 -0.27628,6.3067 0.63604,2.9894 2.4635,5.3876 5.0854,6.6734 1.7788,0.87238 3.409,1.2191 5.7324,1.2191 3.8422,0 6.9587,-1.203 9.6137,-3.711 l 0.90726,-0.85704 -0.45582,-1.0092 c -0.40414,-0.89475 -1.3238,-3.3221 -3.0074,-7.9377 -0.66266,-1.8167 -1.3902,-3.0367 -2.6217,-4.3966 -0.83567,-0.92274 -2.4857,-2.3558 -3.8598,-3.3523 -0.41933,-0.3041 -0.41152,-0.30624 0.78481,-0.21562 4.547,0.34444 6.8542,2.8969 10.192,11.276 0.58286,1.463 0.84878,2.0983 0.96426,2.3599 0.02953,0.0669 0.07669,0.21387 0.13247,0.16887 0.05579,-0.044998 0.2425,-0.39246 0.30776,-0.50578 0.06527,-0.11332 0.13756,-0.25964 0.22146,-0.43667 0.0839,-0.17703 0.1794,-0.38476 0.29108,-0.62088 0.81832,-1.7301 1.8142,-4.5739 2.1755,-6.2124 0.17685,-0.80191 0.31139,-1.0422 0.6944,-1.2403 1.2546,-0.64879 2.3739,-0.046158 2.3739,1.2782 0,2.3978 -1.562,7.2043 -3.3553,10.325 -0.11458,0.1994 -0.2299,0.32992 -0.28958,0.44825 -0.05969,0.11833 -0.22117,0.32942 -0.18689,0.38982 0.0262,0.046164 0.03631,0.058028 0.07857,0.1003 0.04494,0.044952 0.10546,0.1025 0.18606,0.16729 0.59444,0.47791 2.1261,1.0701 2.9132,1.178 1.0012,0.13722 2.1429,-0.34975 2.7808,-1.186 1.4436,-1.8927 1.7772,-5.2915 1.0067,-10.256 -0.28139,-1.8131 -0.2804,-1.8507 0.0612,-2.3095 0.8275,-1.1115 1.8285,-0.9019 2.5288,0.52951 1.4339,2.9307 1.2081,10.094 -0.43371,13.76 -0.9119,2.0362 -2.4949,3.1576 -5.135,3.6376 -2.1119,0.38401 -3.6271,-0.10683 -5.3738,-1.7407 l -1.0573,-0.98905 -0.49724,0.52519 c -1.2916,1.3642 -4.2775,3.0339 -6.4849,3.6264 -1.0871,0.29178 -3.7581,0.64007 -4.619,0.60228 -0.28227,-0.01239 -1.0906,-0.07862 -1.7963,-0.14719 z"
inkscape:connector-curvature="0" />
<path
style="display:inline;fill:#e9e9eb;stroke:#efefef"
id="path161"
d="M 49.244,6.7482 C 48.93619,6.36517 48.43741,5.6114 48.1356,5.0733 L 47.58686,4.09477 48.05882,3.2394 C 48.3184,2.76894 48.70519,1.8739 48.94989,1.2573 l 0.39948,-0.98883 1.3378,1.3563 c 0.8003,0.81132 1.266,1.4388 1.2301,1.6104 -0.08034,0.3845 -1.217,2.7582 -1.713,3.561 l -0.40066,0.6485 z"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
<path
style="display:inline;fill:none;stroke:#e9e9eb"
id="path169"
d="m 45.251,7.0162 c 0,0 0.6208,1.4785 1.9195,3.2142"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</g> </g>
<style <g id="bullet-char-template-57354" transform="scale(0.00048828125,-0.00048828125)">
id="style179">.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style> <path d="M 8,1128 L 1137,1128 1137,0 8,0 8,1128 Z"/>
</svg> </g>
<g id="bullet-char-template-10146" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 174,0 L 602,739 174,1481 1456,739 174,0 Z M 1358,739 L 309,1346 659,739 1358,739 Z"/>
</g>
<g id="bullet-char-template-10132" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 2015,739 L 1276,0 717,0 1260,543 174,543 174,936 1260,936 717,1481 1274,1481 2015,739 Z"/>
</g>
<g id="bullet-char-template-10007" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 0,-2 C -7,14 -16,27 -25,37 L 356,567 C 262,823 215,952 215,954 215,979 228,992 255,992 264,992 276,990 289,987 310,991 331,999 354,1012 L 381,999 492,748 772,1049 836,1024 860,1049 C 881,1039 901,1025 922,1006 886,937 835,863 770,784 769,783 710,716 594,584 L 774,223 C 774,196 753,168 711,139 L 727,119 C 717,90 699,76 672,76 641,76 570,178 457,381 L 164,-76 C 142,-110 111,-127 72,-127 30,-127 9,-110 8,-76 1,-67 -2,-52 -2,-32 -2,-23 -1,-13 0,-2 Z"/>
</g>
<g id="bullet-char-template-10004" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 285,-33 C 182,-33 111,30 74,156 52,228 41,333 41,471 41,549 55,616 82,672 116,743 169,778 240,778 293,778 328,747 346,684 L 369,508 C 377,444 397,411 428,410 L 1163,1116 C 1174,1127 1196,1133 1229,1133 1271,1133 1292,1118 1292,1087 L 1292,965 C 1292,929 1282,901 1262,881 L 442,47 C 390,-6 338,-33 285,-33 Z"/>
</g>
<g id="bullet-char-template-9679" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 813,0 C 632,0 489,54 383,161 276,268 223,411 223,592 223,773 276,916 383,1023 489,1130 632,1184 813,1184 992,1184 1136,1130 1245,1023 1353,916 1407,772 1407,592 1407,412 1353,268 1245,161 1136,54 992,0 813,0 Z"/>
</g>
<g id="bullet-char-template-8226" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 346,457 C 273,457 209,483 155,535 101,586 74,649 74,723 74,796 101,859 155,911 209,963 273,989 346,989 419,989 480,963 531,910 582,859 608,796 608,723 608,648 583,586 532,535 482,483 420,457 346,457 Z"/>
</g>
<g id="bullet-char-template-8211" transform="scale(0.00048828125,-0.00048828125)">
<path d="M -4,459 L 1135,459 1135,606 -4,606 -4,459 Z"/>
</g>
<g id="bullet-char-template-61548" transform="scale(0.00048828125,-0.00048828125)">
<path d="M 173,740 C 173,903 231,1043 346,1159 462,1274 601,1332 765,1332 928,1332 1067,1274 1183,1159 1299,1043 1357,903 1357,740 1357,577 1299,437 1183,322 1067,206 928,148 765,148 601,148 462,206 346,322 231,437 173,577 173,740 Z"/>
</g>
</defs>
<defs class="TextEmbeddedBitmaps"/>
<g>
<g id="id2" class="Master_Slide">
<g id="bg-id2" class="Background"/>
<g id="bo-id2" class="BackgroundObjects"/>
</g>
</g>
<g class="SlideGroup">
<g>
<g id="container-id1">
<g id="id1" class="Slide" clip-path="url(#presentation_clip_path)">
<g class="Page">
<g class="com.sun.star.drawing.CustomShape">
<g id="id3">
<rect class="BoundingBox" stroke="none" fill="none" x="-6" y="-6" width="18014" height="18014"/>
<path fill="rgb(56,142,60)" stroke="none" d="M 9000,100 C 14046,100 17900,3954 17900,9000 17900,14046 14046,17900 9000,17900 3954,17900 100,14046 100,9000 100,3954 3954,100 9000,100 Z"/>
<path fill="none" stroke="rgb(0,0,0)" stroke-width="212" stroke-linejoin="round" d="M 9000,100 C 14046,100 17900,3954 17900,9000 17900,14046 14046,17900 9000,17900 3954,17900 100,14046 100,9000 100,3954 3954,100 9000,100 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.CustomShape">
<g id="id4">
<rect class="BoundingBox" stroke="none" fill="none" x="1094" y="994" width="15813" height="15813"/>
<path fill="rgb(231,231,231)" stroke="none" d="M 9000,1100 C 13422,1100 16800,4478 16800,8900 16800,13322 13422,16700 9000,16700 4578,16700 1200,13322 1200,8900 1200,4478 4578,1100 9000,1100 Z"/>
<path fill="none" stroke="rgb(0,0,0)" stroke-width="212" stroke-linejoin="round" d="M 9000,1100 C 13422,1100 16800,4478 16800,8900 16800,13322 13422,16700 9000,16700 4578,16700 1200,13322 1200,8900 1200,4478 4578,1100 9000,1100 Z"/>
</g>
</g>
<g class="Group">
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id5">
<rect class="BoundingBox" stroke="none" fill="none" x="1265" y="1958" width="7446" height="12870"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 5401,2000 C 5190,2137 4655,2216 4001,2800 3160,3551 2170,4881 1901,5500 625,8597 1313,13283 3601,14600 3643,14621 3768,14533 3801,14500 3834,14467 3801,14433 3801,14400 4501,14700 5001,14900 5801,14800 6316,14701 6847,14367 7101,14000 7601,13200 7254,13438 7401,13300 6701,13000 5501,12900 5201,12200 5027,11781 4897,11621 4801,11300 4727,11051 4683,10737 4601,10500 5268,10733 5990,11177 6601,11200 7212,11223 7949,11138 8101,10800 8253,10462 8032,10520 7801,10200 7570,9880 7068,9467 6701,9100 7168,9067 7744,9129 8101,9000 8458,8871 8614,8942 8701,8500 8788,8058 8197,7274 7801,6800 7405,6326 6868,6067 6401,5700 6468,5533 6567,5400 6601,5200 6635,5000 6424,4712 6601,4500 6778,4288 7300,4417 7501,4200 7702,3983 7736,3632 7701,3400 7666,3168 7647,3107 7301,2800 6955,2493 5776,1783 5401,2000 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 5401,2000 C 5190,2137 4655,2216 4001,2800 3160,3551 2170,4881 1901,5500 625,8597 1313,13283 3601,14600 3643,14621 3768,14533 3801,14500 3834,14467 3801,14433 3801,14400 4501,14700 5001,14900 5801,14800 6316,14701 6847,14367 7101,14000 7601,13200 7254,13438 7401,13300 6701,13000 5501,12900 5201,12200 5027,11781 4897,11621 4801,11300 4727,11051 4683,10737 4601,10500 5268,10733 5990,11177 6601,11200 7212,11223 7949,11138 8101,10800 8253,10462 8032,10520 7801,10200 7570,9880 7068,9467 6701,9100 7168,9067 7744,9129 8101,9000 8458,8871 8614,8942 8701,8500 8788,8058 8197,7274 7801,6800 7405,6326 6868,6067 6401,5700 6468,5533 6567,5400 6601,5200 6635,5000 6424,4712 6601,4500 6778,4288 7300,4417 7501,4200 7702,3983 7736,3632 7701,3400 7666,3168 7647,3107 7301,2800 6955,2493 5776,1783 5401,2000 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id6">
<rect class="BoundingBox" stroke="none" fill="none" x="10569" y="1898" width="5534" height="3904"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 12400,1900 C 12077,1874 10510,2796 10601,3100 10469,3201 10771,3800 11301,4100 12890,4999 15894,5652 16101,5800 16090,5800 15987,5038 15100,4000 14410,3192 13119,1957 12400,1900 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 12400,1900 C 12077,1874 10510,2796 10601,3100 10469,3201 10771,3800 11301,4100 12890,4999 15894,5652 16101,5800 16090,5800 15987,5038 15100,4000 14410,3192 13119,1957 12400,1900 Z"/>
</g>
</g>
<g class="com.sun.star.drawing.ClosedBezierShape">
<g id="id7">
<rect class="BoundingBox" stroke="none" fill="none" x="9094" y="4800" width="7691" height="10403"/>
<path fill="rgb(0,0,0)" stroke="none" d="M 11901,4801 C 16301,6501 16401,6501 16401,6501 16401,6501 17106,9347 16600,10900 16371,11648 15622,13159 15001,14001 14621,14516 13708,15222 13701,15201 13701,15201 12457,15112 11901,14801 11424,14534 10801,13601 10801,13601 10801,13601 13076,13181 13801,12401 14297,11868 14401,10401 14401,10401 14401,10401 13234,11134 12501,11201 11768,11268 10395,11342 10001,10701 9928,10584 10189,10442 10301,10301 10731,9757 11901,9101 11901,9101 11901,9101 10203,9101 9601,8901 9259,8787 9132,8735 9101,8501 9030,7968 9628,7388 10001,7001 10573,6409 12101,5701 12101,5701 12101,5701 11918,5337 11900,5201 11881,5036 11901,4801 11901,4801 Z"/>
<path fill="none" stroke="rgb(0,0,0)" d="M 11901,4801 C 16301,6501 16401,6501 16401,6501 16401,6501 17106,9347 16600,10900 16371,11648 15622,13159 15001,14001 14621,14516 13708,15222 13701,15201 13701,15201 12457,15112 11901,14801 11424,14534 10801,13601 10801,13601 10801,13601 13076,13181 13801,12401 14297,11868 14401,10401 14401,10401 14401,10401 13234,11134 12501,11201 11768,11268 10395,11342 10001,10701 9928,10584 10189,10442 10301,10301 10731,9757 11901,9101 11901,9101 11901,9101 10203,9101 9601,8901 9259,8787 9132,8735 9101,8501 9030,7968 9628,7388 10001,7001 10573,6409 12101,5701 12101,5701 12101,5701 11918,5337 11900,5201 11881,5036 11901,4801 11901,4801 Z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
@ -49,7 +49,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
private typealias AccountInfo = Pair<Account, Boolean> private typealias AccountInfo = Pair<TimelineAccount, Boolean>
class AccountsInListFragment : DialogFragment(), Injectable { class AccountsInListFragment : DialogFragment(), Injectable {
@ -168,21 +168,21 @@ class AccountsInListFragment : DialogFragment(), Injectable {
viewModel.deleteAccountFromList(listId, accountId) viewModel.deleteAccountFromList(listId, accountId)
} }
private fun onAddToList(account: Account) { private fun onAddToList(account: TimelineAccount) {
viewModel.addAccountToList(listId, account) viewModel.addAccountToList(listId, account)
} }
private object AccountDiffer : DiffUtil.ItemCallback<Account>() { private object AccountDiffer : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean { override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem == newItem return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean { override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
return oldItem.deepEquals(newItem) return oldItem == newItem
} }
} }
inner class Adapter : ListAdapter<Account, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) { inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(AccountDiffer) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -209,12 +209,11 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() { private object SearchDiffer : DiffUtil.ItemCallback<AccountInfo>() {
override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem == newItem return oldItem.first.id == newItem.first.id
} }
override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean {
return oldItem.second == newItem.second && return oldItem == newItem
oldItem.first.deepEquals(newItem.first)
} }
} }

View file

@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter; import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.di.Injectable;
@ -197,6 +198,33 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
.show(); .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 @Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@ -27,7 +28,7 @@ import autodispose2.autoDispose
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.network.MastodonApi 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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
@ -157,9 +158,9 @@ abstract class BottomSheetActivity : BaseActivity() {
} }
} }
@VisibleForTesting @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED)
open fun openLink(url: String) { open fun openLink(url: String) {
LinkHelper.openLink(url, this) (this as Context).openLink(url)
} }
private fun showQuerySheet() { private fun showQuerySheet() {

View file

@ -15,28 +15,26 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.Manifest
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide 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.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners 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.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
@ -44,9 +42,7 @@ import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
@ -63,12 +59,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val HEADER_WIDTH = 1500 const val HEADER_WIDTH = 1500
const val HEADER_HEIGHT = 500 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 MAX_ACCOUNT_FIELDS = 4
private const val BUNDLE_CURRENTLY_PICKING = "BUNDLE_CURRENTLY_PICKING"
} }
@Inject @Inject
@ -78,23 +69,28 @@ class EditProfileActivity : BaseActivity(), Injectable {
private val binding by viewBinding(ActivityEditProfileBinding::inflate) private val binding by viewBinding(ActivityEditProfileBinding::inflate)
private var currentlyPicking: PickType = PickType.NOTHING
private val accountFieldEditAdapter = AccountFieldEditAdapter() private val accountFieldEditAdapter = AccountFieldEditAdapter()
private enum class PickType { private enum class PickType {
NOTHING,
AVATAR, AVATAR,
HEADER 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
savedInstanceState?.getString(BUNDLE_CURRENTLY_PICKING)?.let {
currentlyPicking = PickType.valueOf(it)
}
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
@ -104,8 +100,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
binding.avatarButton.setOnClickListener { onMediaPick(PickType.AVATAR) } binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) }
binding.headerButton.setOnClickListener { onMediaPick(PickType.HEADER) } binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) }
binding.fieldList.layoutManager = LinearLayoutManager(this) binding.fieldList.layoutManager = LinearLayoutManager(this)
binding.fieldList.adapter = accountFieldEditAdapter binding.fieldList.adapter = accountFieldEditAdapter
@ -159,11 +155,11 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
is Error -> { is Error -> {
val snackbar = Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG) Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG)
snackbar.setAction(R.string.action_retry) { .setAction(R.string.action_retry) {
viewModel.obtainProfile() viewModel.obtainProfile()
} }
snackbar.show() .show()
} }
is Loading -> { } is Loading -> { }
} }
@ -179,30 +175,24 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
observeImage(viewModel.avatarData, binding.avatarPreview, binding.avatarProgressBar, true) observeImage(viewModel.avatarData, binding.avatarPreview, true)
observeImage(viewModel.headerData, binding.headerPreview, binding.headerProgressBar, false) observeImage(viewModel.headerData, binding.headerPreview, false)
viewModel.saveData.observe( viewModel.saveData.observe(
this, this
{ ) {
when (it) { when (it) {
is Success -> { is Success -> {
finish() finish()
} }
is Loading -> { is Loading -> {
binding.saveProgressBar.visibility = View.VISIBLE binding.saveProgressBar.visibility = View.VISIBLE
} }
is Error -> { is Error -> {
onSaveFailure(it.errorMessage) onSaveFailure(it.errorMessage)
}
} }
} }
) }
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(BUNDLE_CURRENTLY_PICKING, currentlyPicking.toString())
} }
override fun onStop() { override fun onStop() {
@ -218,90 +208,60 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
private fun observeImage( private fun observeImage(
liveData: LiveData<Resource<Bitmap>>, liveData: LiveData<Uri>,
imageView: ImageView, imageView: ImageView,
progressBar: View,
roundedCorners: Boolean roundedCorners: Boolean
) { ) {
liveData.observe( liveData.observe(
this, this
{ ) { imageUri ->
when (it) { // skipping all caches so we can always reuse the same uri
is Success -> { val glide = Glide.with(imageView)
val glide = Glide.with(imageView) .load(imageUri)
.load(it.data) .skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
if (roundedCorners) { if (roundedCorners) {
glide.transform( glide.transform(
FitCenter(), FitCenter(),
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp))
) ).into(imageView)
} } else {
glide.into(imageView)
glide.into(imageView)
imageView.show()
progressBar.hide()
}
is Loading -> {
progressBar.show()
}
is Error -> {
progressBar.hide()
if (!it.consumed) {
onResizeFailure()
it.consumed = true
}
}
}
} }
)
}
private fun onMediaPick(pickType: PickType) { imageView.show()
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()
} }
} }
override fun onRequestPermissionsResult( private fun pickMedia(pickType: PickType) {
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
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() {
val intent = Intent(Intent.ACTION_GET_CONTENT) val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*" intent.type = "image/*"
when (currentlyPicking) { when (pickType) {
PickType.AVATAR -> { 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 -> { 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() { private fun save() {
if (currentlyPicking != PickType.NOTHING) {
return
}
viewModel.save( viewModel.save(
binding.displayNameEditText.text.toString(), binding.displayNameEditText.text.toString(),
binding.noteEditText.text.toString(), binding.noteEditText.text.toString(),
binding.lockedCheckBox.isChecked, binding.lockedCheckBox.isChecked,
accountFieldEditAdapter.getFieldData(), accountFieldEditAdapter.getFieldData()
this
) )
} }
@ -340,90 +295,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.saveProgressBar.visibility = View.GONE binding.saveProgressBar.visibility = View.GONE
} }
private fun beginMediaPicking() { private fun onPickFailure(throwable: Throwable?) {
when (currentlyPicking) { Log.w("EditProfileActivity", "failed to pick media", throwable)
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() {
Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show()
endMediaPicking()
} }
} }

View file

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

View file

@ -1,381 +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.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll
import com.keylesspalace.tusky.util.viewBinding
import okhttp3.HttpUrl
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import javax.inject.Inject
class LoginActivity : BaseActivity(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityLoginBinding::inflate)
private lateinit var preferences: SharedPreferences
private val oauthRedirectUri: String
get() {
val scheme = getString(R.string.oauth_scheme)
val host = BuildConfig.APPLICATION_ID
return "$scheme://$host/"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin()) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() }
binding.registerButton.setOnClickListener { onRegisterClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance()
}
if (isAdditionalLogin()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
binding.toolbar.visibility = View.GONE
}
}
override fun requiresLogin(): Boolean {
return false
}
override fun finish() {
super.finish()
if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
private fun onRegisterClick() {
binding.registerButton.isEnabled = false
val uri = Uri.parse(BuildConfig.REGISTER_ACCOUNT_URL)
if (!openInCustomTab(uri, this)) {
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (viewIntent.resolveActivity(packageManager) != null) {
startActivity(viewIntent)
} else {
binding.domainEditText.error = getString(R.string.error_no_web_browser_found)
setLoading(false)
}
}
}
/**
* Obtain the oauth client credentials for this app. This is only necessary the first time the
* app is run on a given server instance. So, after the first authentication, they are
* saved in SharedPreferences and every subsequent run they are simply fetched from there.
*/
private fun onButtonClick() {
binding.loginButton.isEnabled = false
val domain = canonicalizeDomain(binding.domainEditText.text.toString())
try {
HttpUrl.Builder().host(domain).scheme("https").build()
} catch (e: IllegalArgumentException) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain)
return
}
if (shouldRickRoll(this, domain)) {
rickRoll(this)
return
}
val callback = object : Callback<AppCredentials> {
override fun onResponse(
call: Call<AppCredentials>,
response: Response<AppCredentials>
) {
if (!response.isSuccessful) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, "App authentication failed. " + response.message())
return
}
val credentials = response.body()
val clientId = credentials!!.clientId
val clientSecret = credentials.clientSecret
preferences.edit()
.putString("domain", domain)
.putString("clientId", clientId)
.putString("clientSecret", clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, clientId)
}
override fun onFailure(call: Call<AppCredentials>, t: Throwable) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(t))
}
}
mastodonApi
.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
.enqueue(callback)
setLoading(true)
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
/* To authorize this app and log in it's necessary to redirect to the domain given,
* login there, and the server will redirect back to the app with its response. */
val endpoint = MastodonApi.ENDPOINT_AUTHORIZE
val parameters = mapOf(
"client_id" to clientId,
"redirect_uri" to oauthRedirectUri,
"response_type" to "code",
"scope" to OAUTH_SCOPES
)
val url = "https://" + domain + endpoint + "?" + toQueryString(parameters)
val uri = Uri.parse(url)
if (!openInCustomTab(uri, this)) {
val viewIntent = Intent(Intent.ACTION_VIEW, uri)
if (viewIntent.resolveActivity(packageManager) != null) {
startActivity(viewIntent)
} else {
binding.domainEditText.error = getString(R.string.error_no_web_browser_found)
setLoading(false)
}
}
}
override fun onStart() {
super.onStart()
/* Check if we are resuming during authorization by seeing if the intent contains the
* redirect that was given to the server. If so, its response is here! */
val uri = intent.data
val redirectUri = oauthRedirectUri
if (uri != null && uri.toString().startsWith(redirectUri)) {
// This should either have returned an authorization code or an error.
val code = uri.getQueryParameter("code")
val error = uri.getQueryParameter("error")
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
if (code != null && domain.isNotEmpty() && clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
setLoading(true)
/* Since authorization has succeeded, the final step to log in is to exchange
* the authorization code for an access token. */
val callback = object : Callback<AccessToken> {
override fun onResponse(call: Call<AccessToken>, response: Response<AccessToken>) {
if (response.isSuccessful) {
onLoginSuccess(response.body()!!.accessToken, domain)
} else {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), response.message()))
}
}
override fun onFailure(call: Call<AccessToken>, t: Throwable) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token)
Log.e(TAG, "%s %s".format(getString(R.string.error_retrieving_oauth_token), t.message))
}
}
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, redirectUri, code,
"authorization_code"
).enqueue(callback)
} else if (error != null) {
/* Authorization failed. Put the error response where the user can read it and they
* can try again. */
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(TAG, "%s %s".format(getString(R.string.error_authorization_denied), error))
} else {
// This case means a junk response was received somehow.
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_unknown)
}
} else {
// first show or user cancelled login
setLoading(false)
}
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
private fun onLoginSuccess(accessToken: String, domain: String) {
setLoading(true)
accountManager.addAccount(accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
/**
* Chain together the key-value pairs into a query string, for either appending to a URL or
* as the content of an HTTP request.
*/
private fun toQueryString(parameters: Map<String, String>): String {
val s = StringBuilder()
var between = ""
for ((key, value) in parameters) {
s.append(between)
s.append(Uri.encode(key))
s.append("=")
s.append(Uri.encode(value))
between = "&"
}
return s.toString()
}
private fun openInCustomTab(uri: Uri, context: Context): Boolean {
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)
.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent $customTabsIntent")
return false
}
return true
}
}
}

View file

@ -64,9 +64,10 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canH
import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
@ -325,9 +326,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
super.onPostCreate(savedInstanceState) super.onPostCreate(savedInstanceState)
if (intent != null) { if (intent != null) {
val statusUrl = intent.getStringExtra(STATUS_URL) val redirectUrl = intent.getStringExtra(REDIRECT_URL)
if (statusUrl != null) { if (redirectUrl != null) {
viewUrl(statusUrl, PostLookupFallbackBehavior.DISPLAY_ERROR) viewUrl(redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR)
} }
} }
} }
@ -454,10 +455,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_access_scheduled_toot nameRes = R.string.action_access_scheduled_posts
iconRes = R.drawable.ic_access_time iconRes = R.drawable.ic_access_time
onClick = { onClick = {
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context)) startActivityWithSlideInAnimation(ScheduledStatusActivity.newIntent(context))
} }
}, },
primaryDrawerItem { primaryDrawerItem {
@ -834,7 +835,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl" const val REDIRECT_URL = "redirectUrl"
} }
} }

View file

@ -1,62 +0,0 @@
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton
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.interfaces.ActionButtonActivity
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
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()
}
}
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

@ -15,14 +15,17 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import javax.inject.Inject import javax.inject.Inject
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity(), Injectable { class SplashActivity : AppCompatActivity(), Injectable {
@Inject @Inject

View file

@ -31,9 +31,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject @Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any> lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
private val kind: Kind
get() = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityStatuslistBinding.inflate(layoutInflater) val binding = ActivityStatuslistBinding.inflate(layoutInflater)
@ -41,10 +38,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
val title = if (kind == Kind.FAVOURITES) { val kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!)
R.string.title_favourites val listId = intent.getStringExtra(EXTRA_LIST_ID)
} else { val hashtag = intent.getStringExtra(EXTRA_HASHTAG)
R.string.title_bookmarks
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 { supportActionBar?.run {
@ -53,9 +55,15 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
supportFragmentManager.commit { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
val fragment = TimelineFragment.newInstance(kind) supportFragmentManager.commit {
replace(R.id.fragment_container, fragment) val fragment = if (kind == Kind.TAG) {
TimelineFragment.newHashtagInstance(listOf(hashtag!!))
} else {
TimelineFragment.newInstance(kind, listId)
}
replace(R.id.fragmentContainer, fragment)
}
} }
} }
@ -64,17 +72,30 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
companion object { companion object {
private const val EXTRA_KIND = "kind" private const val EXTRA_KIND = "kind"
private const val EXTRA_LIST_ID = "id"
private const val EXTRA_HASHTAG = "tag"
@JvmStatic
fun newFavouritesIntent(context: Context) = fun newFavouritesIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply { Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.FAVOURITES.name) putExtra(EXTRA_KIND, Kind.FAVOURITES.name)
} }
@JvmStatic
fun newBookmarksIntent(context: Context) = fun newBookmarksIntent(context: Context) =
Intent(context, StatusListActivity::class.java).apply { Intent(context, StatusListActivity::class.java).apply {
putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) putExtra(EXTRA_KIND, Kind.BOOKMARKS.name)
} }
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

@ -36,6 +36,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -205,10 +206,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(url)) val request = DownloadManager.Request(Uri.parse(url))
request.setDestinationInExternalPublicDir( request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename)
Environment.DIRECTORY_PICTURES,
getString(R.string.app_name) + "/" + filename
)
downloadManager.enqueue(request) downloadManager.enqueue(request)
} }
@ -255,11 +253,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
private fun shareFile(file: File, mimeType: String?) { private fun shareFile(file: File, mimeType: String?) {
val sendIntent = Intent() ShareCompat.IntentBuilder(this)
sendIntent.action = Intent.ACTION_SEND .setType(mimeType)
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)) .addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file))
sendIntent.type = mimeType .setChooserTitle(R.string.send_media_to)
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_media_to))) .startChooser()
} }
private var isCreating: Boolean = false private var isCreating: Boolean = false

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) { public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.action_open_in_web: { case R.id.action_open_in_web: {
LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this); openLink(getIntent().getStringExtra(URL_EXTRA));
return true; return true;
} }
case R.id.action_reveal: { case R.id.action_reveal: {

View file

@ -18,7 +18,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.removeDuplicates import com.keylesspalace.tusky.util.removeDuplicates
@ -28,7 +28,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
protected val animateAvatar: Boolean, protected val animateAvatar: Boolean,
protected val animateEmojis: Boolean protected val animateEmojis: Boolean
) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder?>() {
var accountList = mutableListOf<Account>() var accountList = mutableListOf<TimelineAccount>()
private var bottomLoading: Boolean = false private var bottomLoading: Boolean = false
override fun getItemCount(): Int { override fun getItemCount(): Int {
@ -73,12 +73,12 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
} }
} }
fun update(newAccounts: List<Account>) { fun update(newAccounts: List<TimelineAccount>) {
accountList = removeDuplicates(newAccounts) accountList = removeDuplicates(newAccounts)
notifyDataSetChanged() notifyDataSetChanged()
} }
fun addItems(newAccounts: List<Account>) { fun addItems(newAccounts: List<TimelineAccount>) {
val end = accountList.size val end = accountList.size
val last = accountList[end - 1] val last = accountList[end - 1]
if (newAccounts.none { it.id == last.id }) { if (newAccounts.none { it.id == last.id }) {
@ -100,7 +100,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
} }
} }
fun removeItem(position: Int): Account? { fun removeItem(position: Int): TimelineAccount? {
if (position < 0 || position >= accountList.size) { if (position < 0 || position >= accountList.size) {
return null return null
} }
@ -109,7 +109,7 @@ abstract class AccountAdapter<AVH : RecyclerView.ViewHolder> internal constructo
return account return account
} }
fun addItem(account: Account, position: Int) { fun addItem(account: TimelineAccount, position: Int) {
if (position < 0 || position > accountList.size) { if (position < 0 || position > accountList.size) {
return return
} }

View file

@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
@ -33,9 +33,9 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true);
} }
public void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) { public void setupWithAccount(TimelineAccount account, boolean animateAvatar, boolean animateEmojis) {
accountId = account.getId(); accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.post_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername); username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);

View file

@ -22,7 +22,7 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -55,11 +55,11 @@ class BlocksAdapter(
private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock)
private var id: String? = null private var id: String? = null
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format) val format = username.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
username.text = formattedUsername username.text = formattedUsername
val avatarRadius = avatar.context.resources val avatarRadius = avatar.context.resources

View file

@ -22,7 +22,7 @@ import android.text.style.StyleSpan
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -34,7 +34,7 @@ class FollowRequestViewHolder(
private val showHeader: Boolean private val showHeader: Boolean
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) {
val wrappedName = account.name.unicodeWrap() val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
binding.displayNameTextView.text = emojifiedName binding.displayNameTextView.text = emojifiedName
@ -45,7 +45,7 @@ class FollowRequestViewHolder(
}.emojify(account.emojis, itemView, animateEmojis) }.emojify(account.emojis, itemView, animateEmojis)
} }
binding.notificationTextView.visible(showHeader) binding.notificationTextView.visible(showHeader)
val format = itemView.context.getString(R.string.status_username_format) val format = itemView.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
binding.usernameTextView.text = formattedUsername binding.usernameTextView.text = formattedUsername
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)

View file

@ -9,7 +9,7 @@ import android.widget.TextView
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -69,7 +69,7 @@ class MutesAdapter(
private var notifications = false private var notifications = false
fun setupWithAccount( fun setupWithAccount(
account: Account, account: TimelineAccount,
mutingNotifications: Boolean?, mutingNotifications: Boolean?,
animateAvatar: Boolean, animateAvatar: Boolean,
animateEmojis: Boolean animateEmojis: Boolean
@ -77,7 +77,7 @@ class MutesAdapter(
id = account.id id = account.id
val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis)
displayName.text = emojifiedName displayName.text = emojifiedName
val format = username.context.getString(R.string.status_username_format) val format = username.context.getString(R.string.post_username_format)
val formattedUsername = String.format(format, account.username) val formattedUsername = String.format(format, account.username)
username.text = formattedUsername username.text = formattedUsername
val avatarRadius = avatar.context.resources val avatarRadius = avatar.context.resources

View file

@ -40,10 +40,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
@ -335,7 +335,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
} }
void setMessage(Account account) { void setMessage(TimelineAccount account) {
Context context = message.getContext(); Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format); String format = context.getString(R.string.notification_follow_format);
@ -346,7 +346,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
); );
message.setText(emojifiedMessage); message.setText(emojifiedMessage);
String username = context.getString(R.string.status_username_format, account.getUsername()); String username = context.getString(R.string.post_username_format, account.getUsername());
usernameView.setText(username); usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
@ -440,7 +440,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private void setUsername(String name) { private void setUsername(String name) {
Context context = username.getContext(); Context context = username.getContext();
String format = context.getString(R.string.status_username_format); String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name); String usernameText = String.format(format, name);
username.setText(usernameText); username.setText(usernameText);
} }
@ -538,9 +538,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE);
if (statusViewData.isExpanded()) { if (statusViewData.isExpanded()) {
contentWarningButton.setText(R.string.status_content_warning_show_less); contentWarningButton.setText(R.string.post_content_warning_show_less);
} else { } else {
contentWarningButton.setText(R.string.status_content_warning_show_more); contentWarningButton.setText(R.string.post_content_warning_show_more);
} }
contentWarningButton.setOnClickListener(view -> { contentWarningButton.setOnClickListener(view -> {
@ -630,10 +630,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (statusViewData.isCollapsed()) { if (statusViewData.isCollapsed()) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
statusContent.setFilters(COLLAPSE_INPUT_FILTER); statusContent.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
statusContent.setFilters(NO_INPUT_FILTER); statusContent.setFilters(NO_INPUT_FILTER);
} }
} else { } else {
@ -644,7 +644,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis() content, emojis, statusContent, statusDisplayOptions.animateEmojis()
); );
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener);
CharSequence emojifiedContentWarning; CharSequence emojifiedContentWarning;
if (statusViewData.getSpoilerText() != null) { if (statusViewData.getSpoilerText() != null) {

View file

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Attachment.MetaData;
import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CardViewMode;
@ -190,7 +191,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected void setUsername(String name) { protected void setUsername(String name) {
Context context = username.getContext(); Context context = username.getContext();
String usernameText = context.getString(R.string.status_username_format, name); String usernameText = context.getString(R.string.post_username_format, name);
username.setText(usernameText); username.setText(usernameText);
} }
@ -202,6 +203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@NonNull Spanned content, @NonNull Spanned content,
@Nullable String spoilerText, @Nullable String spoilerText,
@Nullable List<Status.Mention> mentions, @Nullable List<Status.Mention> mentions,
@Nullable List<HashTag> tags,
@NonNull List<Emoji> emojis, @NonNull List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@ -222,21 +224,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
setContentWarningButtonText(!expanded); 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 { } else {
contentWarningDescription.setVisibility(View.GONE); contentWarningDescription.setVisibility(View.GONE);
contentWarningButton.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);
} }
} }
private void setContentWarningButtonText(boolean expanded) { private void setContentWarningButtonText(boolean expanded) {
if (expanded) { if (expanded) {
contentWarningButton.setText(R.string.status_content_warning_show_less); contentWarningButton.setText(R.string.post_content_warning_show_less);
} else { } else {
contentWarningButton.setText(R.string.status_content_warning_show_more); contentWarningButton.setText(R.string.post_content_warning_show_more);
} }
} }
@ -244,13 +246,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
boolean expanded, boolean expanded,
Spanned content, Spanned content,
List<Status.Mention> mentions, List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis, List<Emoji> emojis,
@Nullable PollViewData poll, @Nullable PollViewData poll,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) { final StatusActionListener listener) {
if (expanded) { if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); 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) { for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded); updateMediaLabel(i, sensitive, expanded);
} }
@ -505,9 +508,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
if (sensitive) { if (sensitive) {
sensitiveMediaWarning.setText(R.string.status_sensitive_media_title); sensitiveMediaWarning.setText(R.string.post_sensitive_media_title);
} else { } else {
sensitiveMediaWarning.setText(R.string.status_media_hidden_title); sensitiveMediaWarning.setText(R.string.post_media_hidden_title);
} }
sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE);
@ -552,7 +555,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) { private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) {
Context context = itemView.getContext(); Context context = itemView.getContext();
CharSequence label = (sensitive && !showingContent) ? CharSequence label = (sensitive && !showingContent) ?
context.getString(R.string.status_sensitive_media_title) : context.getString(R.string.post_sensitive_media_title) :
mediaDescriptions[index]; mediaDescriptions[index];
mediaLabels[index].setText(label); mediaLabels[index].setText(label);
} }
@ -604,7 +607,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
duration = formatDuration(attachment.getMeta().getDuration()) + " "; duration = formatDuration(attachment.getMeta().getDuration()) + " ";
} }
if (TextUtils.isEmpty(attachment.getDescription())) { if (TextUtils.isEmpty(attachment.getDescription())) {
return duration + context.getString(R.string.description_status_media_no_description_placeholder); return duration + context.getString(R.string.description_post_media_no_description_placeholder);
} else { } else {
return duration + attachment.getDescription(); return duration + attachment.getDescription();
} }
@ -739,7 +742,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
Status actionable = status.getActionable(); Status actionable = status.getActionable();
setDisplayName(actionable.getAccount().getDisplayName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions);
setUsername(status.getUsername()); setUsername(status.getUsername());
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setIsReply(actionable.getInReplyToId() != null); setIsReply(actionable.getInReplyToId() != null);
@ -779,7 +782,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(),
actionable.getMentions(), actionable.getEmojis(), actionable.getMentions(), actionable.getTags(), actionable.getEmojis(),
PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions,
listener); listener);
@ -823,9 +826,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions),
getReblogDescription(context, status), getReblogDescription(context, status),
status.getUsername(), status.getUsername(),
actionable.getReblogged() ? context.getString(R.string.description_status_reblogged) : "", actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "",
actionable.getFavourited() ? context.getString(R.string.description_status_favourited) : "", actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "",
actionable.getBookmarked() ? context.getString(R.string.description_status_bookmarked) : "", actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "",
getMediaDescription(context, status), getMediaDescription(context, status),
getVisibilityDescription(context, actionable.getVisibility()), getVisibilityDescription(context, actionable.getVisibility()),
getFavsText(context, actionable.getFavouritesCount()), getFavsText(context, actionable.getFavouritesCount()),
@ -840,7 +843,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
Status reblog = status.getRebloggingStatus(); Status reblog = status.getRebloggingStatus();
if (reblog != null) { if (reblog != null) {
return context return context
.getString(R.string.status_boosted_format, reblog.getAccount().getUsername()); .getString(R.string.post_boosted_format, reblog.getAccount().getUsername());
} else { } else {
return ""; return "";
} }
@ -857,20 +860,20 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
(builder, a) -> { (builder, a) -> {
if (a.getDescription() == null) { if (a.getDescription() == null) {
String placeholder = String placeholder =
context.getString(R.string.description_status_media_no_description_placeholder); context.getString(R.string.description_post_media_no_description_placeholder);
return builder.append(placeholder); return builder.append(placeholder);
} else { } else {
builder.append("; "); builder.append("; ");
return builder.append(a.getDescription()); return builder.append(a.getDescription());
} }
}); });
return context.getString(R.string.description_status_media, mediaDescriptions); return context.getString(R.string.description_post_media, mediaDescriptions);
} }
private static CharSequence getContentWarningDescription(Context context, private static CharSequence getContentWarningDescription(Context context,
@NonNull StatusViewData.Concrete status) { @NonNull StatusViewData.Concrete status) {
if (!TextUtils.isEmpty(status.getSpoilerText())) { if (!TextUtils.isEmpty(status.getSpoilerText())) {
return context.getString(R.string.description_status_cw, status.getSpoilerText()); return context.getString(R.string.description_post_cw, status.getSpoilerText());
} else { } else {
return ""; return "";
} }

View file

@ -70,7 +70,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
if (reblogging == null) { if (reblogging == null) {
hideStatusInfo(); hideStatusInfo();
} else { } else {
String rebloggedByDisplayName = reblogging.getAccount().getDisplayName(); String rebloggedByDisplayName = reblogging.getAccount().getName();
setRebloggedByDisplayName(rebloggedByDisplayName, setRebloggedByDisplayName(rebloggedByDisplayName,
reblogging.getAccount().getEmojis(), statusDisplayOptions); reblogging.getAccount().getEmojis(), statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition()));
@ -86,7 +86,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
final StatusDisplayOptions statusDisplayOptions) { final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
CharSequence wrappedName = StringUtils.unicodeWrap(name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName); CharSequence boostedText = context.getString(R.string.post_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify( CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis() boostedText, accountEmoji, statusInfo, statusDisplayOptions.animateEmojis()
); );
@ -118,10 +118,10 @@ public class StatusViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (status.isCollapsed()) { if (status.isCollapsed()) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER); content.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} else { } else {

View file

@ -3,9 +3,7 @@ package com.keylesspalace.tusky.appstore
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject import javax.inject.Inject
class CacheUpdater @Inject constructor( class CacheUpdater @Inject constructor(
@ -47,12 +45,7 @@ class CacheUpdater @Inject constructor(
this.disposable.dispose() this.disposable.dispose()
} }
fun clearForUser(accountId: Long) { suspend fun clearForUser(accountId: Long) {
Single.fromCallable { appDatabase.timelineDao().removeAll(accountId)
appDatabase.timelineDao().removeAllForAccount(accountId)
appDatabase.timelineDao().removeAllUsersForAccount(accountId)
}
.subscribeOn(Schedulers.io())
.subscribe()
} }
} }

View file

@ -55,27 +55,31 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.databinding.ActivityAccountBinding import com.keylesspalace.tusky.databinding.ActivityAccountBinding
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.DefaultTextWatcher import com.keylesspalace.tusky.util.DefaultTextWatcher
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
@ -230,7 +234,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountFragmentViewPager.adapter = adapter binding.accountFragmentViewPager.adapter = adapter
binding.accountFragmentViewPager.offscreenPageLimit = 2 binding.accountFragmentViewPager.offscreenPageLimit = 2
val pageTitles = arrayOf(getString(R.string.title_statuses), getString(R.string.title_statuses_with_replies), getString(R.string.title_statuses_pinned), getString(R.string.title_media)) val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media))
TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position -> TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position ->
tab.text = pageTitles[position] tab.text = pageTitles[position]
@ -402,12 +406,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun onAccountChanged(account: Account?) { private fun onAccountChanged(account: Account?) {
loadedAccount = account ?: return loadedAccount = account ?: return
val usernameFormatted = getString(R.string.status_username_format, account.username) val usernameFormatted = getString(R.string.post_username_format, account.username)
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, 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.fields = account.fields ?: emptyList()
accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList()
@ -473,7 +477,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
supportActionBar?.title = emojifiedName supportActionBar?.title = emojifiedName
} }
supportActionBar?.subtitle = String.format(getString(R.string.status_username_format), account.username) supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username)
} }
} }
@ -490,7 +494,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
binding.accountMovedDisplayName.text = movedAccount.name binding.accountMovedDisplayName.text = movedAccount.name
binding.accountMovedUsername.text = getString(R.string.status_username_format, movedAccount.username) binding.accountMovedUsername.text = getString(R.string.post_username_format, movedAccount.username)
val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
@ -515,7 +519,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (account.isRemote()) { if (account.isRemote()) {
binding.accountRemoveView.show() binding.accountRemoveView.show()
binding.accountRemoveView.setOnClickListener { binding.accountRemoveView.setOnClickListener {
LinkHelper.openLink(account.url, this) openLink(account.url)
} }
} }
} }
@ -686,6 +690,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.account_toolbar, menu) 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) { if (!viewModel.isSelf) {
val block = menu.findItem(R.id.action_block) val block = menu.findItem(R.id.action_block)
@ -704,7 +716,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (loadedAccount != null) { if (loadedAccount != null) {
val muteDomain = menu.findItem(R.id.action_mute_domain) val muteDomain = menu.findItem(R.id.action_mute_domain)
domain = LinkHelper.getDomain(loadedAccount?.url) domain = getDomain(loadedAccount?.url)
if (domain.isEmpty()) { if (domain.isEmpty()) {
// If we can't get the domain, there's no way we can mute it anyway... // If we can't get the domain, there's no way we can mute it anyway...
menu.removeItem(R.id.action_mute_domain) menu.removeItem(R.id.action_mute_domain)
@ -805,8 +817,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
val intent = Intent(this, ViewTagActivity::class.java) val intent = StatusListActivity.newHashtagIntent(this, tag)
intent.putExtra("hashtag", tag)
startActivityWithSlideInAnimation(intent) startActivityWithSlideInAnimation(intent)
} }
@ -824,11 +835,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
when (item.itemId) { when (item.itemId) {
R.id.action_open_in_web -> { R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input. // If the account isn't loaded yet, eat the input.
if (loadedAccount != null) { if (loadedAccount?.url != null) {
LinkHelper.openLink(loadedAccount?.url, this) openLink(loadedAccount!!.url)
} }
return true 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 -> { R.id.action_block -> {
toggleBlock() toggleBlock()
return true return true

View file

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

View file

@ -37,9 +37,9 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.view.SquareImageView import com.keylesspalace.tusky.view.SquareImageView
@ -252,7 +252,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr
} }
} }
Attachment.Type.UNKNOWN -> { Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(items[currentIndex].attachment.url, context) context?.openLink(items[currentIndex].attachment.url)
} }
} }
} }

View file

@ -31,8 +31,8 @@ import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.EmojiSpan
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.setClickableText
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
interface AnnouncementActionListener : LinkListener { interface AnnouncementActionListener : LinkListener {
@ -62,7 +62,7 @@ class AnnouncementAdapter(
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener) setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
// If wellbeing mode is enabled, announcement badge counts should not be shown. // If wellbeing mode is enabled, announcement badge counts should not be shown.
if (wellbeingEnabled) { if (wellbeingEnabled) {

View file

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

View file

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

View file

@ -16,7 +16,9 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.Manifest import android.Manifest
import android.app.NotificationManager
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
@ -45,8 +47,8 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.ContentInfoCompat
import androidx.core.view.inputmethod.InputContentInfoCompat import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -105,7 +107,7 @@ class ComposeActivity :
ComposeAutoCompleteAdapter.AutocompletionProvider, ComposeAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener, OnEmojiSelectedListener,
Injectable, Injectable,
InputConnectionCompat.OnCommitContentListener, OnReceiveContentListener,
ComposeScheduleView.OnTimeSetListener { ComposeScheduleView.OnTimeSetListener {
@Inject @Inject
@ -122,6 +124,7 @@ class ComposeActivity :
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
private val viewModel: ComposeViewModel by viewModels { viewModelFactory } private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
@ -148,6 +151,18 @@ class ComposeActivity :
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)
if (notificationId != -1) {
// ComposeActivity was opened from a notification, delete the notification
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(notificationId)
}
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
if (accountId != -1L) {
accountManager.setActiveAccount(accountId)
}
val preferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
if (theme == "black") { if (theme == "black") {
@ -186,9 +201,9 @@ class ComposeActivity :
viewModel.setup(composeOptions) viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
val tootText = composeOptions?.tootText val statusContent = composeOptions?.content
if (!tootText.isNullOrEmpty()) { if (!statusContent.isNullOrEmpty()) {
binding.composeEditField.setText(tootText) binding.composeEditField.setText(statusContent)
} }
if (!composeOptions?.scheduledAt.isNullOrEmpty()) { if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
@ -221,26 +236,25 @@ class ComposeActivity :
} }
} }
} }
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) { }
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
val shareBody = if (!subject.isNullOrBlank() && subject !in text) { val shareBody = if (!subject.isNullOrBlank() && subject !in text) {
subject + '\n' + text subject + '\n' + text
} else { } else {
text text
} }
if (shareBody.isNotBlank()) { if (shareBody.isNotBlank()) {
val start = binding.composeEditField.selectionStart.coerceAtLeast(0) val start = binding.composeEditField.selectionStart.coerceAtLeast(0)
val end = binding.composeEditField.selectionEnd.coerceAtLeast(0) val end = binding.composeEditField.selectionEnd.coerceAtLeast(0)
val left = min(start, end) val left = min(start, end)
val right = max(start, end) val right = max(start, end)
binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed // move edittext cursor to first when shareBody parsed
binding.composeEditField.text.insert(0, "\n") binding.composeEditField.text.insert(0, "\n")
binding.composeEditField.setSelection(0) binding.composeEditField.setSelection(0)
}
} }
} }
} }
@ -281,7 +295,7 @@ class ComposeActivity :
} }
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) { private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
binding.composeEditField.setOnCommitContentListener(this) binding.composeEditField.setOnReceiveContentListener(this)
binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
@ -316,6 +330,7 @@ class ComposeActivity :
withLifecycleContext { withLifecycleContext {
viewModel.instanceParams.observe { instanceData -> viewModel.instanceParams.observe { instanceData ->
maximumTootCharacters = instanceData.maxChars maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
updateVisibleCharactersLeft() updateVisibleCharactersLeft()
binding.composeScheduleButton.visible(instanceData.supportsScheduled) binding.composeScheduleButton.visible(instanceData.supportsScheduled)
} }
@ -654,7 +669,8 @@ class ComposeActivity :
val instanceParams = viewModel.instanceParams.value!! val instanceParams = viewModel.instanceParams.value!!
showAddPollDialog( showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions, this, viewModel.poll.value, instanceParams.pollMaxOptions,
instanceParams.pollMaxLength, viewModel::updatePoll instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
viewModel::updatePoll
) )
} }
@ -699,7 +715,9 @@ class ComposeActivity :
val urlSpans = binding.composeEditField.urls val urlSpans = binding.composeEditField.urls
if (urlSpans != null) { if (urlSpans != null) {
for (span in urlSpans) { for (span in urlSpans) {
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH) // it's expected that this will be negative
// when the url length is less than the reserved character count
offset += (span.url.length - charactersReservedPerUrl)
} }
} }
var length = binding.composeEditField.length() - offset var length = binding.composeEditField.length() - offset
@ -739,26 +757,18 @@ class ComposeActivity :
} }
} }
/** This is for the fancy keyboards which can insert images and stuff. */ /** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle?): Boolean { override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? {
// Verify the returned content's type is of the correct MIME type if (contentInfo.clip.description.hasMimeType("image/*")) {
val supported = inputContentInfo.description.hasMimeType("image/*") val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
split.first?.let { content ->
if (supported) { for (i in 0 until content.clip.itemCount) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0 pickMedia(content.clip.getItemAt(i).uri)
if (lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
return false
} }
} }
pickMedia(inputContentInfo.contentUri, inputContentInfo) return split.second
return true
} }
return contentInfo
return false
} }
private fun sendStatus() { private fun sendStatus() {
@ -781,12 +791,11 @@ class ComposeActivity :
} }
viewModel.sendStatus(contentText, spoilerText).observe( viewModel.sendStatus(contentText, spoilerText).observe(
this, this
{ ) {
finishingUploadDialog?.dismiss() finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
} }
)
} else { } else {
binding.composeEditField.error = getString(R.string.error_compose_character_limit) binding.composeEditField.error = getString(R.string.error_compose_character_limit)
enableButtons(true) enableButtons(true)
@ -856,12 +865,9 @@ class ComposeActivity :
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null) { private fun pickMedia(uri: Uri) {
withLifecycleContext { withLifecycleContext {
viewModel.pickMedia(uri).observe { exceptionOrItem -> viewModel.pickMedia(uri).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
exceptionOrItem.asLeftOrNull()?.let { exceptionOrItem.asLeftOrNull()?.let {
val errorId = when (it) { val errorId = when (it) {
is VideoSizeException -> { is VideoSizeException -> {
@ -1017,7 +1023,7 @@ class ComposeActivity :
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
var scheduledTootId: String? = null, var scheduledTootId: String? = null,
var draftId: Int? = null, var draftId: Int? = null,
var tootText: String? = null, var content: String? = null,
var mediaUrls: List<String>? = null, var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null, var mediaDescriptions: List<String>? = null,
var mentionedUsernames: Set<String>? = null, var mentionedUsernames: Set<String>? = null,
@ -1040,16 +1046,32 @@ class ComposeActivity :
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID"
private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
// Mastodon only counts URLs as this long in terms of status character limits /**
@VisibleForTesting * @param options ComposeOptions to configure the ComposeActivity
const val MAXIMUM_URL_LENGTH = 23 * @param notificationId the id of the notification that starts the Activity
* @param accountId the id of the account to compose with, null for the current account
* @return an Intent to start the ComposeActivity
*/
@JvmStatic @JvmStatic
fun startIntent(context: Context, options: ComposeOptions): Intent { @JvmOverloads
fun startIntent(
context: Context,
options: ComposeOptions,
notificationId: Int? = null,
accountId: Long? = null
): Intent {
return Intent(context, ComposeActivity::class.java).apply { return Intent(context, ComposeActivity::class.java).apply {
putExtra(COMPOSE_OPTIONS_EXTRA, options) putExtra(COMPOSE_OPTIONS_EXTRA, options)
if (notificationId != null) {
putExtra(NOTIFICATION_ID_EXTRA, notificationId)
}
if (accountId != null) {
putExtra(ACCOUNT_ID_EXTRA, accountId)
}
} }
} }

View file

@ -16,7 +16,6 @@
package com.keylesspalace.tusky.components.compose; package com.keylesspalace.tusky.components.compose;
import android.content.Context; import android.content.Context;
import android.preference.PreferenceManager;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -28,9 +27,9 @@ import android.widget.TextView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
@ -144,9 +143,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
AccountResult accountResult = ((AccountResult) getItem(position)); AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) { if (accountResult != null) {
Account account = accountResult.account; TimelineAccount account = accountResult.account;
String formattedUsername = context.getString( String formattedUsername = context.getString(
R.string.status_username_format, R.string.post_username_format,
account.getUsername() account.getUsername()
); );
accountViewHolder.username.setText(formattedUsername); accountViewHolder.username.setText(formattedUsername);
@ -268,9 +267,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
} }
public final static class AccountResult extends AutocompleteResult { public final static class AccountResult extends AutocompleteResult {
private final Account account; private final TimelineAccount account;
public AccountResult(Account account) { public AccountResult(TimelineAccount account) {
this.account = account; this.account = account;
} }
} }

View file

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.TootToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.VersionUtils import com.keylesspalace.tusky.util.VersionUtils
@ -79,6 +79,9 @@ class ComposeViewModel @Inject constructor(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
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 supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
) )
} }
@ -102,18 +105,20 @@ class ComposeViewModel @Inject constructor(
init { init {
Single.zip( Single.zip(
api.getCustomEmojis(), api.getInstance(), api.getCustomEmojis(), api.getInstance()
{ emojis, instance -> ) { emojis, instance ->
InstanceEntity( InstanceEntity(
instance = accountManager.activeAccount?.domain!!, instance = accountManager.activeAccount?.domain!!,
emojiList = emojis, emojiList = emojis,
maximumTootCharacters = instance.maxTootChars, maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions, maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars, maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
version = instance.version minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
) maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
} charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
) version = instance.version
)
}
.doOnSuccess { .doOnSuccess {
db.instanceDao().insertOrReplace(it) db.instanceDao().insertOrReplace(it)
} }
@ -185,7 +190,7 @@ class ComposeViewModel @Inject constructor(
is UploadEvent.ProgressEvent -> is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage) item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent -> is UploadEvent.FinishedEvent ->
item.copy(id = event.attachment.id, uploadPercent = -1) item.copy(id = event.mediaId, uploadPercent = -1)
} }
synchronized(media) { synchronized(media) {
val mediaValue = media.value!! val mediaValue = media.value!!
@ -303,7 +308,7 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
} }
val tootToSend = TootToSend( val tootToSend = StatusToSend(
text = content, text = content,
warningText = spoilerText, warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(), visibility = statusVisibility.value!!.serverString(),
@ -451,7 +456,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText startingText = composeOptions?.content
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
@ -506,11 +511,19 @@ fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = defau
const val DEFAULT_CHARACTER_LIMIT = 500 const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4 private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 50 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
data class ComposeInstanceParams( data class ComposeInstanceParams(
val maxChars: Int, val maxChars: Int,
val pollMaxOptions: Int, val pollMaxOptions: Int,
val pollMaxLength: Int, val pollMaxLength: Int,
val pollMinDuration: Int,
val pollMaxDuration: Int,
val charactersReservedPerUrl: Int,
val supportsScheduled: Boolean val supportsScheduled: Boolean
) )

View file

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.components.compose package com.keylesspalace.tusky.components.compose
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
@ -25,7 +26,6 @@ import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
@ -38,6 +38,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.util.Date import java.util.Date
@ -45,7 +46,7 @@ import javax.inject.Inject
sealed class UploadEvent { sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : 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 { fun createNewImageFile(context: Context): File {
@ -84,36 +85,70 @@ class MediaUploader @Inject constructor(
fun prepareMedia(inUri: Uri): Single<PreparedMedia> { fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
return Single.fromCallable { return Single.fromCallable {
var mediaSize = getMediaSize(contentResolver, inUri) var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri var uri = inUri
val mimeType = contentResolver.getType(uri) var mimeType: String? = null
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
try { try {
contentResolver.openInputStream(inUri).use { input -> when (inUri.scheme) {
if (input == null) { ContentResolver.SCHEME_CONTENT -> {
Log.w(TAG, "Media input is null")
uri = inUri mimeType = contentResolver.getType(uri)
return@use
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
contentResolver.openInputStream(inUri).use { input ->
if (input == null) {
Log.w(TAG, "Media input is null")
uri = inUri
return@use
}
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
} }
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) ContentResolver.SCHEME_FILE -> {
FileOutputStream(file.absoluteFile).use { out -> val path = uri.path
input.copyTo(out) if (path == null) {
uri = FileProvider.getUriForFile( Log.w(TAG, "empty uri path $uri")
context, throw CouldNotOpenFileException()
BuildConfig.APPLICATION_ID + ".fileprovider", }
file val inputFile = File(path)
) val suffix = inputFile.name.substringAfterLast('.', "tmp")
mediaSize = getMediaSize(contentResolver, uri) mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
val input = FileInputStream(inputFile)
FileOutputStream(file.absoluteFile).use { out ->
input.copyTo(out)
uri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID + ".fileprovider",
file
)
mediaSize = getMediaSize(contentResolver, uri)
}
}
else -> {
Log.w(TAG, "Unknown uri scheme $uri")
throw CouldNotOpenFileException()
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, e) Log.w(TAG, e)
uri = inUri throw CouldNotOpenFileException()
} }
if (mediaSize == MEDIA_SIZE_UNKNOWN) { if (mediaSize == MEDIA_SIZE_UNKNOWN) {
throw CouldNotOpenFileException() Log.w(TAG, "Could not determine file size of upload")
throw MediaTypeException()
} }
if (mimeType != null) { if (mimeType != null) {
@ -139,6 +174,7 @@ class MediaUploader @Inject constructor(
} }
} }
} else { } else {
Log.w(TAG, "Could not determine mime type of upload")
throw MediaTypeException() throw MediaTypeException()
} }
} }
@ -183,8 +219,8 @@ class MediaUploader @Inject constructor(
val uploadDisposable = mastodonApi.uploadMedia(body, description) val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe( .subscribe(
{ attachment -> { result ->
emitter.onNext(UploadEvent.FinishedEvent(attachment)) emitter.onNext(UploadEvent.FinishedEvent(result.id))
emitter.onComplete() emitter.onComplete()
}, },
{ e -> { e ->

View file

@ -20,6 +20,7 @@ package com.keylesspalace.tusky.components.compose.dialog
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.WindowManager import android.view.WindowManager
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogAddPollBinding import com.keylesspalace.tusky.databinding.DialogAddPollBinding
@ -30,6 +31,8 @@ fun showAddPollDialog(
poll: NewPoll?, poll: NewPoll?,
maxOptionCount: Int, maxOptionCount: Int,
maxOptionLength: Int, maxOptionLength: Int,
minDuration: Int,
maxDuration: Int,
onUpdatePoll: (NewPoll) -> Unit onUpdatePoll: (NewPoll) -> Unit
) { ) {
@ -57,6 +60,13 @@ fun showAddPollDialog(
binding.pollChoices.adapter = adapter 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 { binding.addChoiceButton.setOnClickListener {
if (adapter.itemCount < maxOptionCount) { if (adapter.itemCount < maxOptionCount) {
adapter.addChoice() 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 it <= poll?.expiresIn ?: 0
} }
@ -79,13 +89,10 @@ fun showAddPollDialog(
button.setOnClickListener { button.setOnClickListener {
val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition val selectedPollDurationId = binding.pollDurationSpinner.selectedItemPosition
val pollDuration = context.resources
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
onUpdatePoll( onUpdatePoll(
NewPoll( NewPoll(
options = adapter.pollOptions, options = adapter.pollOptions,
expiresIn = pollDuration, expiresIn = durations[selectedPollDurationId],
multiple = binding.multipleChoicesCheckBox.isChecked multiple = binding.multipleChoicesCheckBox.isChecked
) )
) )

View file

@ -22,6 +22,8 @@ import android.util.AttributeSet
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection import android.view.inputmethod.InputConnection
import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.emoji.widget.EmojiEditTextHelper import androidx.emoji.widget.EmojiEditTextHelper
@ -32,41 +34,33 @@ class EditTextTyped @JvmOverloads constructor(
) : ) :
AppCompatMultiAutoCompleteTextView(context, attributeSet) { AppCompatMultiAutoCompleteTextView(context, attributeSet) {
private var onCommitContentListener: InputConnectionCompat.OnCommitContentListener? = null
private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this)
init { init {
// fix a bug with autocomplete and some keyboards // fix a bug with autocomplete and some keyboards
val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE)
inputType = newInputType inputType = newInputType
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(keyListener)) super.setKeyListener(emojiEditTextHelper.getKeyListener(keyListener))
} }
override fun setKeyListener(input: KeyListener) { override fun setKeyListener(input: KeyListener?) {
super.setKeyListener(getEmojiEditTextHelper().getKeyListener(input)) if (input != null) {
super.setKeyListener(emojiEditTextHelper.getKeyListener(input))
} else {
super.setKeyListener(input)
}
} }
fun setOnCommitContentListener(listener: InputConnectionCompat.OnCommitContentListener) { fun setOnReceiveContentListener(listener: OnReceiveContentListener) {
onCommitContentListener = listener ViewCompat.setOnReceiveContentListener(this, arrayOf("image/*"), listener)
} }
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo) val connection = super.onCreateInputConnection(editorInfo)
return if (onCommitContentListener != null) { EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) return emojiEditTextHelper.onCreateInputConnection(
getEmojiEditTextHelper().onCreateInputConnection( InputConnectionCompat.createWrapper(this, connection, editorInfo),
InputConnectionCompat.createWrapper( editorInfo
connection, editorInfo, )!!
onCommitContentListener!!
),
editorInfo
)!!
} else {
connection
}
}
private fun getEmojiEditTextHelper(): EmojiEditTextHelper {
return emojiEditTextHelper
} }
} }

View file

@ -16,17 +16,17 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.text.Spanned import android.text.Spanned
import android.text.SpannedString
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import java.util.Date import java.util.Date
@ -47,17 +47,15 @@ data class ConversationAccountEntity(
val avatar: String, val avatar: String,
val emojis: List<Emoji> val emojis: List<Emoji>
) { ) {
fun toAccount(): Account { fun toAccount(): TimelineAccount {
return Account( return TimelineAccount(
id = id, id = id,
username = username, username = username,
displayName = displayName, displayName = displayName,
url = "",
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
url = "",
localUsername = "", localUsername = "",
note = SpannedString(""),
header = ""
) )
} }
} }
@ -79,6 +77,7 @@ data class ConversationStatusEntity(
val spoilerText: String, val spoilerText: String,
val attachments: ArrayList<Attachment>, val attachments: ArrayList<Attachment>,
val mentions: List<Status.Mention>, val mentions: List<Status.Mention>,
val tags: List<HashTag>?,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean, val collapsible: Boolean,
@ -98,7 +97,7 @@ data class ConversationStatusEntity(
if (inReplyToId != other.inReplyToId) return false if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false if (account != other.account) return false
if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false if (favouritesCount != other.favouritesCount) return false
@ -107,6 +106,7 @@ data class ConversationStatusEntity(
if (spoilerText != other.spoilerText) return false if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false if (attachments != other.attachments) return false
if (mentions != other.mentions) return false if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false if (collapsible != other.collapsible) return false
@ -123,7 +123,7 @@ data class ConversationStatusEntity(
result = 31 * result + (inReplyToId?.hashCode() ?: 0) result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode() result = 31 * result + account.hashCode()
result = 31 * result + content.hashCode() result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode() result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode() result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount result = 31 * result + favouritesCount
@ -132,6 +132,7 @@ data class ConversationStatusEntity(
result = 31 * result + spoilerText.hashCode() result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode() result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode() result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode() result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode() result = 31 * result + collapsible.hashCode()
@ -162,6 +163,7 @@ data class ConversationStatusEntity(
visibility = Status.Visibility.DIRECT, visibility = Status.Visibility.DIRECT,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
tags = tags,
application = null, application = null,
pinned = false, pinned = false,
muted = muted, muted = muted,
@ -171,7 +173,7 @@ data class ConversationStatusEntity(
} }
} }
fun Account.toEntity() = fun TimelineAccount.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id = id, id = id,
username = username, username = username,
@ -197,6 +199,7 @@ fun Status.toEntity() =
spoilerText = spoilerText, spoilerText = spoilerText,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
tags = tags,
showingHiddenContent = false, showingHiddenContent = false,
expanded = false, expanded = false,
collapsible = shouldTrimStatus(content), collapsible = shouldTrimStatus(content),

View file

@ -108,7 +108,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
statusDisplayOptions); statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
status.getMentions(), status.getEmojis(), status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts()); setConversationName(conversation.getAccounts());
@ -154,10 +154,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
contentCollapseButton.setVisibility(View.VISIBLE); contentCollapseButton.setVisibility(View.VISIBLE);
if (collapsed) { if (collapsed) {
contentCollapseButton.setText(R.string.status_content_warning_show_more); contentCollapseButton.setText(R.string.post_content_warning_show_more);
content.setFilters(COLLAPSE_INPUT_FILTER); content.setFilters(COLLAPSE_INPUT_FILTER);
} else { } else {
contentCollapseButton.setText(R.string.status_content_warning_show_less); contentCollapseButton.setText(R.string.post_content_warning_show_less);
content.setFilters(NO_INPUT_FILTER); content.setFilters(NO_INPUT_FILTER);
} }
} else { } else {

View file

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -31,7 +30,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.R 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.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
@ -103,7 +102,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh() initSwipeToRefresh()
lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData -> viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData) adapter.submitData(pagingData)
} }
@ -233,8 +232,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java) val intent = StatusListActivity.newHashtagIntent(requireContext(), tag)
intent.putExtra("hashtag", tag)
startActivity(intent) startActivity(intent)
} }

View file

@ -90,14 +90,14 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (draft.inReplyToId != null) { if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId) viewModel.getStatus(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this)) .autoDispose(from(this))
.subscribe( .subscribe(
{ status -> { status ->
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, content = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId, inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(), replyingStatusContent = status.content.toString(),
@ -121,7 +121,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted // the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information // let's open the ComposeActivity without reply information
Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show() Toast.makeText(this, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show()
openDraftWithoutReply(draft) openDraftWithoutReply(draft)
} else { } else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
@ -137,7 +137,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
private fun openDraftWithoutReply(draft: DraftEntity) { private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions( val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id, draftId = draft.id,
tootText = draft.content, content = draft.content,
contentWarning = draft.contentWarning, contentWarning = draft.contentWarning,
draftAttachments = draft.attachments, draftAttachments = draft.attachments,
poll = draft.poll, poll = draft.poll,

View file

@ -60,8 +60,8 @@ class DraftsViewModel @Inject constructor(
} }
} }
fun getToot(tootId: String): Single<Status> { fun getStatus(statusId: String): Single<Status> {
return api.status(tootId) return api.status(statusId)
} }
override fun onCleared() { override fun onCleared() {

View file

@ -0,0 +1,309 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.login
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.rickRoll
import com.keylesspalace.tusky.util.shouldRickRoll
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import javax.inject.Inject
/** Main login page, the first thing that users see. Has prompt for instance and login button. */
class LoginActivity : BaseActivity(), Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityLoginBinding::inflate)
private lateinit var preferences: SharedPreferences
private val oauthRedirectUri: String
get() {
val scheme = getString(R.string.oauth_scheme)
val host = BuildConfig.APPLICATION_ID
return "$scheme://$host/"
}
private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result ->
when (result) {
is LoginResult.Ok -> lifecycleScope.launch {
fetchOauthToken(result.code)
}
is LoginResult.Err -> {
// Authorization failed. Put the error response where the user can read it and they
// can try again.
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_authorization_denied)
Log.e(
TAG,
"%s %s".format(
getString(R.string.error_authorization_denied),
result.errorMessage
)
)
}
is LoginResult.Cancel -> {
setLoading(false)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
!isAdditionalLogin()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
.placeholder(null)
.into(binding.loginLogo)
}
preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE
)
binding.loginButton.setOnClickListener { onButtonClick() }
binding.registerButton.setOnClickListener { onRegisterClick() }
binding.whatsAnInstanceTextView.setOnClickListener {
val dialog = AlertDialog.Builder(this)
.setMessage(R.string.dialog_whats_an_instance)
.setPositiveButton(R.string.action_close, null)
.show()
val textView = dialog.findViewById<TextView>(android.R.id.message)
textView?.movementMethod = LinkMovementMethod.getInstance()
}
if (isAdditionalLogin()) {
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
} else {
binding.toolbar.visibility = View.GONE
}
}
override fun requiresLogin(): Boolean {
return false
}
override fun finish() {
super.finish()
if (isAdditionalLogin()) {
overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right)
}
}
/**
* Handle registation of new account in the most basic way possible; open a URL
* in the system default browser.
*/
private fun onRegisterClick() {
binding.registerButton.isEnabled = false
val openRegisterPage = Intent(android.content.Intent.ACTION_VIEW)
openRegisterPage.data = Uri.parse(BuildConfig.REGISTER_ACCOUNT_URL)
startActivity(openRegisterPage)
}
/**
* Obtain the oauth client credentials for this app. This is only necessary the first time the
* app is run on a given server instance. So, after the first authentication, they are
* saved in SharedPreferences and every subsequent run they are simply fetched from there.
*/
private fun onButtonClick() {
binding.loginButton.isEnabled = false
binding.domainTextInputLayout.error = null
val domain = canonicalizeDomain(binding.domainEditText.text.toString())
try {
HttpUrl.Builder().host(domain).scheme("https").build()
} catch (e: IllegalArgumentException) {
setLoading(false)
binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain)
return
}
if (shouldRickRoll(this, domain)) {
rickRoll(this)
return
}
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
// Before we open browser page we save the data.
// Even if we don't open other apps user may go to password manager or somewhere else
// and we will need to pick up the process where we left off.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
}
}
private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String) {
// To authorize this app and log in it's necessary to redirect to the domain given,
// login there, and the server will redirect back to the app with its response.
val url = HttpUrl.Builder()
.scheme("https")
.host(domain)
.addPathSegments(MastodonApi.ENDPOINT_AUTHORIZE)
.addQueryParameter("client_id", clientId)
.addQueryParameter("redirect_uri", oauthRedirectUri)
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", OAUTH_SCOPES)
.build()
doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri()))
}
override fun onStart() {
super.onStart()
// first show or user cancelled login
setLoading(false)
}
private suspend fun fetchOauthToken(code: String) {
/* restore variables from SharedPreferences */
val domain = preferences.getNonNullString(DOMAIN, "")
val clientId = preferences.getNonNullString(CLIENT_ID, "")
val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "")
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
accountManager.addAccount(accessToken.accessToken, domain)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
}
private fun setLoading(loadingState: Boolean) {
if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE
binding.loginInputLayout.visibility = View.GONE
} else {
binding.loginLoadingLayout.visibility = View.GONE
binding.loginInputLayout.visibility = View.VISIBLE
binding.loginButton.isEnabled = true
}
}
private fun isAdditionalLogin(): Boolean {
return intent.getBooleanExtra(LOGIN_MODE, false)
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow"
private const val LOGIN_MODE = "LOGIN_MODE"
private const val DOMAIN = "domain"
private const val CLIENT_ID = "clientId"
private const val CLIENT_SECRET = "clientSecret"
@JvmStatic
fun getIntent(context: Context, mode: Boolean): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)
loginIntent.putExtra(LOGIN_MODE, mode)
return loginIntent
}
/** Make sure the user-entered text is just a fully-qualified domain name. */
private fun canonicalizeDomain(domain: String): String {
// Strip any schemes out.
var s = domain.replaceFirst("http://", "")
s = s.replaceFirst("https://", "")
// If a username was included (e.g. username@example.com), just take what's after the '@'.
val at = s.lastIndexOf('@')
if (at != -1) {
s = s.substring(at + 1)
}
return s.trim { it <= ' ' }
}
}
}

View file

@ -0,0 +1,162 @@
package com.keylesspalace.tusky.components.login
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.databinding.LoginWebviewBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.parcelize.Parcelize
/** Contract for starting [LoginWebViewActivity]. */
class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
override fun createIntent(context: Context, input: LoginData): Intent {
val intent = Intent(context, LoginWebViewActivity::class.java)
intent.putExtra(DATA_EXTRA, input)
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): LoginResult {
// Can happen automatically on up or back press
return if (resultCode == Activity.RESULT_CANCELED) {
LoginResult.Cancel
} else {
intent!!.getParcelableExtra(RESULT_EXTRA)!!
}
}
companion object {
private const val RESULT_EXTRA = "result"
private const val DATA_EXTRA = "data"
fun parseData(intent: Intent): LoginData {
return intent.getParcelableExtra(DATA_EXTRA)!!
}
fun makeResultIntent(result: LoginResult): Intent {
val intent = Intent()
intent.putExtra(RESULT_EXTRA, result)
return intent
}
}
}
@Parcelize
data class LoginData(
val url: Uri,
val oauthRedirectUrl: Uri,
) : Parcelable
sealed class LoginResult : Parcelable {
@Parcelize
data class Ok(val code: String) : LoginResult()
@Parcelize
data class Err(val errorMessage: String) : LoginResult()
@Parcelize
object Cancel : LoginResult()
}
/** Activity to do Oauth process using WebView. */
class LoginWebViewActivity : BaseActivity(), Injectable {
private val binding by viewBinding(LoginWebviewBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = OauthLogin.parseData(intent)
setContentView(binding.root)
setSupportActionBar(binding.loginToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowTitleEnabled(false)
val webView = binding.loginWebView
webView.settings.allowContentAccess = false
webView.settings.allowFileAccess = false
webView.settings.databaseEnabled = false
webView.settings.displayZoomControls = false
webView.settings.javaScriptCanOpenWindowsAutomatically = false
// Javascript needs to be enabled because otherwise 2FA does not work in some instances
@SuppressLint("SetJavaScriptEnabled")
webView.settings.javaScriptEnabled = true
webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}"
val oauthUrl = data.oauthRedirectUrl
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError
) {
Log.d("LoginWeb", "Failed to load ${data.url}: $error")
finish()
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val url = request.url
return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) {
val error = url.getQueryParameter("error")
if (error != null) {
sendResult(LoginResult.Err(error))
} else {
val code = url.getQueryParameter("code").orEmpty()
sendResult(LoginResult.Ok(code))
}
true
} else {
false
}
}
}
webView.setBackgroundColor(Color.TRANSPARENT)
if (savedInstanceState == null) {
webView.loadUrl(data.url.toString())
} else {
webView.restoreState(savedInstanceState)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
binding.loginWebView.saveState(outState)
}
override fun onDestroy() {
if (isFinishing) {
// We don't want to keep user session in WebView, we just want our own accessToken
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
}
super.onDestroy()
}
override fun requiresLogin() = false
private fun sendResult(result: LoginResult) {
setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result))
finish()
}
}

View file

@ -24,7 +24,6 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Build; import android.os.Build;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
@ -46,9 +45,9 @@ import androidx.work.WorkRequest;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.FutureTarget;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
@ -67,6 +66,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -88,8 +88,6 @@ public class NotificationHelper {
public static final String REPLY_ACTION = "REPLY_ACTION"; public static final String REPLY_ACTION = "REPLY_ACTION";
public static final String COMPOSE_ACTION = "COMPOSE_ACTION";
public static final String KEY_REPLY = "KEY_REPLY"; public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID"; public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
@ -108,10 +106,6 @@ public class NotificationHelper {
public static final String KEY_MENTIONS = "KEY_MENTIONS"; public static final String KEY_MENTIONS = "KEY_MENTIONS";
public static final String KEY_CITED_TEXT = "KEY_CITED_TEXT";
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
/** /**
* notification channels used on Android O+ * notification channels used on Android O+
**/ **/
@ -206,21 +200,24 @@ public class NotificationHelper {
.setLabel(context.getString(R.string.label_quick_reply)) .setLabel(context.getString(R.string.label_quick_reply))
.build(); .build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account); PendingIntent quickReplyPendingIntent = getStatusReplyIntent(context, body, account);
NotificationCompat.Action quickReplyAction = NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent) context.getString(R.string.action_quick_reply),
quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput) .addRemoteInput(replyRemoteInput)
.build(); .build();
builder.addAction(quickReplyAction); builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account); PendingIntent composeIntent = getStatusComposeIntent(context, body, account);
NotificationCompat.Action composeAction = NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp, new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent) context.getString(R.string.action_compose_shortcut),
composeIntent)
.setShowsUserInterface(true)
.build(); .build();
builder.addAction(composeAction); builder.addAction(composeAction);
@ -237,7 +234,6 @@ public class NotificationHelper {
} }
// Summary // Summary
// =======
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true); final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
if (currentNotifications.length() != 1) { if (currentNotifications.length() != 1) {
@ -275,7 +271,7 @@ public class NotificationHelper {
summaryStackBuilder.addNextIntent(summaryResultIntent); summaryStackBuilder.addNextIntent(summaryResultIntent);
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000), PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
// we have to switch account here // we have to switch account here
Intent eventResultIntent = new Intent(context, MainActivity.class); Intent eventResultIntent = new Intent(context, MainActivity.class);
@ -285,18 +281,18 @@ public class NotificationHelper {
eventStackBuilder.addNextIntent(eventResultIntent); eventStackBuilder.addNextIntent(eventResultIntent);
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(), PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class); Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
deleteIntent.putExtra(ACCOUNT_ID, account.getId()); deleteIntent.putExtra(ACCOUNT_ID, account.getId());
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent, PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(false));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body)) NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
.setSmallIcon(R.drawable.ic_notify) .setSmallIcon(R.drawable.ic_notify)
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
.setDeleteIntent(deletePendingIntent) .setDeleteIntent(deletePendingIntent)
.setColor(BuildConfig.FLAVOR == "green" ? Color.parseColor("#19A341") : ContextCompat.getColor(context, R.color.chinwag_green)) .setColor(ContextCompat.getColor(context, R.color.notification_color))
.setGroup(account.getAccountId()) .setGroup(account.getAccountId())
.setAutoCancel(true) .setAutoCancel(true)
.setShortcutId(Long.toString(account.getId())) .setShortcutId(Long.toString(account.getId()))
@ -307,11 +303,9 @@ public class NotificationHelper {
return builder; return builder;
} }
private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) { private static PendingIntent getStatusReplyIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus(); Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId(); String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -326,9 +320,7 @@ public class NotificationHelper {
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames)); mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class) Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action) .setAction(REPLY_ACTION)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId()) .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier()) .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName()) .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
@ -341,7 +333,50 @@ public class NotificationHelper {
return PendingIntent.getBroadcast(context.getApplicationContext(), return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId, notificationId,
replyIntent, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT); pendingIntentFlags(true));
}
private static PendingIntent getStatusComposeIntent(Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
List<Status.Mention> mentions = actionableStatus.getMentions();
Set<String> mentionedUsernames = new LinkedHashSet<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
String mentionedUsername = mention.getUsername();
if (!mentionedUsername.equals(account.getUsername())) {
mentionedUsernames.add(mention.getUsername());
}
}
ComposeActivity.ComposeOptions composeOptions = new ComposeActivity.ComposeOptions();
composeOptions.setInReplyToId(inReplyToId);
composeOptions.setReplyVisibility(replyVisibility);
composeOptions.setContentWarning(contentWarning);
composeOptions.setReplyingStatusAuthor(citedLocalAuthor);
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
Intent composeIntent = ComposeActivity.startIntent(
context,
composeOptions,
notificationId,
account.getId()
);
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context.getApplicationContext(),
notificationId,
composeIntent,
pendingIntentFlags(false));
} }
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) { public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
@ -409,9 +444,7 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.deleteNotificationChannelGroup(account.getIdentifier()); notificationManager.deleteNotificationChannelGroup(account.getIdentifier());
} }
} }
@ -421,7 +454,6 @@ public class NotificationHelper {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// used until Tusky 1.4 // used until Tusky 1.4
//noinspection ConstantConditions
notificationManager.deleteNotificationChannel(CHANNEL_MENTION); notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE); notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
notificationManager.deleteNotificationChannel(CHANNEL_BOOST); notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
@ -440,7 +472,6 @@ public class NotificationHelper {
// on Android >= O, notifications are enabled, if at least one channel is enabled // on Android >= O, notifications are enabled, if at least one channel is enabled
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
if (notificationManager.areNotificationsEnabled()) { if (notificationManager.areNotificationsEnabled()) {
for (NotificationChannel channel : notificationManager.getNotificationChannels()) { for (NotificationChannel channel : notificationManager.getNotificationChannels()) {
if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) { if (channel.getImportance() > NotificationManager.IMPORTANCE_NONE) {
@ -491,7 +522,6 @@ public class NotificationHelper {
accountManager.saveAccount(account); accountManager.saveAccount(account);
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//noinspection ConstantConditions
notificationManager.cancel((int) account.getId()); notificationManager.cancel((int) account.getId());
return true; return true;
}) })
@ -511,7 +541,6 @@ public class NotificationHelper {
// unknown notificationtype // unknown notificationtype
return false; return false;
} }
//noinspection ConstantConditions
NotificationChannel channel = notificationManager.getNotificationChannel(channelId); NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
return channel.getImportance() > NotificationManager.IMPORTANCE_NONE; return channel.getImportance() > NotificationManager.IMPORTANCE_NONE;
} }
@ -674,4 +703,11 @@ public class NotificationHelper {
return null; return null;
} }
public static int pendingIntentFlags(boolean mutable) {
if (mutable) {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
} else {
return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0);
}
}
} }

View file

@ -15,6 +15,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.EmojiCompatFont
@ -220,7 +221,7 @@ class EmojiPreference(
context, context,
0x1f973, // This is the codepoint of the party face emoji :D 0x1f973, // This is the codepoint of the party face emoji :D
launchIntent, launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT NotificationHelper.pendingIntentFlags(false)
) )
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set( mgr.set(

View file

@ -78,7 +78,7 @@ class PreferencesActivity :
NotificationPreferencesFragment.newInstance() NotificationPreferencesFragment.newInstance()
} }
TAB_FILTER_PREFERENCES -> { TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_status_tabs) setTitle(R.string.pref_title_post_tabs)
TabFilterPreferencesFragment.newInstance() TabFilterPreferencesFragment.newInstance()
} }
PROXY_PREFERENCES -> { PROXY_PREFERENCES -> {

View file

@ -86,11 +86,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
listPreference { listPreference {
setDefaultValue("medium") setDefaultValue("medium")
setEntries(R.array.status_text_size_names) setEntries(R.array.post_text_size_names)
setEntryValues(R.array.status_text_size_values) setEntryValues(R.array.post_text_size_values)
key = PrefKeys.STATUS_TEXT_SIZE key = PrefKeys.STATUS_TEXT_SIZE
setSummaryProvider { entry } setSummaryProvider { entry }
setTitle(R.string.pref_status_text_size) setTitle(R.string.pref_post_text_size)
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
} }
@ -138,6 +138,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
isSingleLineTitle = false isSingleLineTitle = false
} }
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
switchPreference { switchPreference {
setDefaultValue(true) setDefaultValue(true)
key = PrefKeys.USE_BLURHASH key = PrefKeys.USE_BLURHASH
@ -179,13 +186,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_title_enable_swipe_for_tabs) setTitle(R.string.pref_title_enable_swipe_for_tabs)
isSingleLineTitle = false isSingleLineTitle = false
} }
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
} }
preferenceCategory(R.string.pref_title_browser_settings) { preferenceCategory(R.string.pref_title_browser_settings) {
@ -199,7 +199,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
preferenceCategory(R.string.pref_title_timeline_filters) { preferenceCategory(R.string.pref_title_timeline_filters) {
preference { preference {
setTitle(R.string.pref_title_status_tabs) setTitle(R.string.pref_title_post_tabs)
setOnPreferenceClickListener { setOnPreferenceClickListener {
activity?.let { activity -> activity?.let { activity ->
val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES) val intent = PreferencesActivity.newIntent(activity, PreferencesActivity.TAB_FILTER_PREFERENCES)
@ -280,23 +280,24 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
} }
private fun updateHttpProxySummary() { private fun updateHttpProxySummary() {
val sharedPreferences = preferenceManager.sharedPreferences preferenceManager.sharedPreferences?.let { sharedPreferences ->
val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false) val httpProxyEnabled = sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)
val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "") val httpServer = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, "")
try { try {
val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1") val httpPort = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1")
.toInt() .toInt()
if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) { if (httpProxyEnabled && httpServer.isNotBlank() && httpPort > 0 && httpPort < 65535) {
httpProxyPref?.summary = "$httpServer:$httpPort" httpProxyPref?.summary = "$httpServer:$httpPort"
return return
}
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
} }
} catch (e: NumberFormatException) {
// user has entered wrong port, fall back to empty summary
}
httpProxyPref?.summary = "" httpProxyPref?.summary = ""
}
} }
companion object { companion object {

View file

@ -23,9 +23,9 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
@ -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.TimestampUtils
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide 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.shouldTrimStatus
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
@ -96,7 +98,7 @@ class StatusViewHolder(
) )
if (status.spoilerText.isBlank()) { if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler) setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
binding.statusContentWarningButton.hide() binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide() binding.statusContentWarningDescription.hide()
} else { } else {
@ -110,35 +112,36 @@ class StatusViewHolder(
val contentShown = viewState.isContentShow(status.id, true) val contentShown = viewState.isContentShow(status.id, true)
binding.statusContentWarningDescription.invalidate() binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown) viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler) setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler)
setContentWarningButtonText(!contentShown) setContentWarningButtonText(!contentShown)
} }
} }
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler) setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler)
} }
} }
} }
private fun setContentWarningButtonText(contentShown: Boolean) { private fun setContentWarningButtonText(contentShown: Boolean) {
if (contentShown) { if (contentShown) {
binding.statusContentWarningButton.setText(R.string.status_content_warning_show_less) binding.statusContentWarningButton.setText(R.string.post_content_warning_show_less)
} else { } else {
binding.statusContentWarningButton.setText(R.string.status_content_warning_show_more) binding.statusContentWarningButton.setText(R.string.post_content_warning_show_more)
} }
} }
private fun setTextVisible( private fun setTextVisible(
expanded: Boolean, expanded: Boolean,
content: Spanned, content: Spanned,
mentions: List<Status.Mention>?, mentions: List<Status.Mention>,
tags: List<HashTag>?,
emojis: List<Emoji>, emojis: List<Emoji>,
listener: LinkListener listener: LinkListener
) { ) {
if (expanded) { if (expanded) {
val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis) val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis)
LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener) setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener)
} else { } else {
LinkHelper.setClickableMentions(binding.statusContent, mentions, listener) setClickableMentions(binding.statusContent, mentions, listener)
} }
if (binding.statusContent.text.isNullOrBlank()) { if (binding.statusContent.text.isNullOrBlank()) {
binding.statusContent.hide() binding.statusContent.hide()
@ -174,10 +177,10 @@ class StatusViewHolder(
binding.buttonToggleContent.show() binding.buttonToggleContent.show()
if (collapsed) { if (collapsed) {
binding.buttonToggleContent.setText(R.string.status_content_show_more) binding.buttonToggleContent.setText(R.string.post_content_show_more)
binding.statusContent.filters = COLLAPSE_INPUT_FILTER binding.statusContent.filters = COLLAPSE_INPUT_FILTER
} else { } else {
binding.buttonToggleContent.setText(R.string.status_content_show_less) binding.buttonToggleContent.setText(R.string.post_content_show_less)
binding.statusContent.filters = NO_INPUT_FILTER binding.statusContent.filters = NO_INPUT_FILTER
} }
} else { } else {

View file

@ -29,8 +29,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.components.report.Screen
@ -152,7 +152,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
private fun showError() { private fun showError() {
if (snackbarErrorRetry?.isShown != true) { if (snackbarErrorRetry?.isShown != true) {
snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_statuses, Snackbar.LENGTH_INDEFINITE) snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE)
snackbarErrorRetry?.setAction(R.string.action_retry) { snackbarErrorRetry?.setAction(R.string.action_retry) {
adapter.retry() adapter.retry()
} }
@ -180,9 +180,9 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) 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?) = viewModel.checkClickedUrl(url) override fun onViewUrl(url: String) = viewModel.checkClickedUrl(url)
companion object { companion object {
fun newInstance() = ReportStatusesFragment() fun newInstance() = ReportStatusesFragment()

View file

@ -29,7 +29,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.databinding.ActivityScheduledTootBinding import com.keylesspalace.tusky.databinding.ActivityScheduledStatusBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
@ -40,7 +40,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable { class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -48,19 +48,19 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
@Inject @Inject
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
private val viewModel: ScheduledTootViewModel by viewModels { viewModelFactory } private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory }
private val adapter = ScheduledTootAdapter(this) private val adapter = ScheduledStatusAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityScheduledTootBinding.inflate(layoutInflater) val binding = ActivityScheduledStatusBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run { supportActionBar?.run {
title = getString(R.string.title_scheduled_toot) title = getString(R.string.title_scheduled_posts)
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
@ -94,7 +94,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
if (loadState.refresh is LoadState.NotLoading) { if (loadState.refresh is LoadState.NotLoading) {
binding.progressBar.hide() binding.progressBar.hide()
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status) binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_posts)
binding.errorMessageView.show() binding.errorMessageView.show()
} else { } else {
binding.errorMessageView.hide() binding.errorMessageView.hide()
@ -121,7 +121,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
this, this,
ComposeActivity.ComposeOptions( ComposeActivity.ComposeOptions(
scheduledTootId = item.id, scheduledTootId = item.id,
tootText = item.params.text, content = item.params.text,
contentWarning = item.params.spoilerText, contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments, mediaAttachments = item.mediaAttachments,
inReplyToId = item.params.inReplyToId, inReplyToId = item.params.inReplyToId,
@ -138,6 +138,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
} }
companion object { companion object {
fun newIntent(context: Context) = Intent(context, ScheduledTootActivity::class.java) fun newIntent(context: Context) = Intent(context, ScheduledStatusActivity::class.java)
} }
} }

View file

@ -20,18 +20,18 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.databinding.ItemScheduledTootBinding import com.keylesspalace.tusky.databinding.ItemScheduledStatusBinding
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BindingHolder
interface ScheduledTootActionListener { interface ScheduledStatusActionListener {
fun edit(item: ScheduledStatus) fun edit(item: ScheduledStatus)
fun delete(item: ScheduledStatus) fun delete(item: ScheduledStatus)
} }
class ScheduledTootAdapter( class ScheduledStatusAdapter(
val listener: ScheduledTootActionListener val listener: ScheduledStatusActionListener
) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledTootBinding>>( ) : PagingDataAdapter<ScheduledStatus, BindingHolder<ItemScheduledStatusBinding>>(
object : DiffUtil.ItemCallback<ScheduledStatus>() { object : DiffUtil.ItemCallback<ScheduledStatus>() {
override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
@ -43,12 +43,12 @@ class ScheduledTootAdapter(
} }
) { ) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledTootBinding> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemScheduledStatusBinding> {
val binding = ItemScheduledTootBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemScheduledStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding) return BindingHolder(binding)
} }
override fun onBindViewHolder(holder: BindingHolder<ItemScheduledTootBinding>, position: Int) { override fun onBindViewHolder(holder: BindingHolder<ItemScheduledStatusBinding>, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
holder.binding.edit.isEnabled = true holder.binding.edit.isEnabled = true
holder.binding.delete.isEnabled = true holder.binding.delete.isEnabled = true

View file

@ -22,16 +22,16 @@ import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
class ScheduledTootPagingSourceFactory( class ScheduledStatusPagingSourceFactory(
private val mastodonApi: MastodonApi private val mastodonApi: MastodonApi
) : () -> ScheduledTootPagingSource { ) : () -> ScheduledStatusPagingSource {
private val scheduledTootsCache = mutableListOf<ScheduledStatus>() private val scheduledTootsCache = mutableListOf<ScheduledStatus>()
private var pagingSource: ScheduledTootPagingSource? = null private var pagingSource: ScheduledStatusPagingSource? = null
override fun invoke(): ScheduledTootPagingSource { override fun invoke(): ScheduledStatusPagingSource {
return ScheduledTootPagingSource(mastodonApi, scheduledTootsCache).also { return ScheduledStatusPagingSource(mastodonApi, scheduledTootsCache).also {
pagingSource = it pagingSource = it
} }
} }
@ -42,9 +42,9 @@ class ScheduledTootPagingSourceFactory(
} }
} }
class ScheduledTootPagingSource( class ScheduledStatusPagingSource(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val scheduledTootsCache: MutableList<ScheduledStatus> private val scheduledStatusesCache: MutableList<ScheduledStatus>
) : PagingSource<String, ScheduledStatus>() { ) : PagingSource<String, ScheduledStatus>() {
override fun getRefreshKey(state: PagingState<String, ScheduledStatus>): String? { override fun getRefreshKey(state: PagingState<String, ScheduledStatus>): String? {
@ -52,11 +52,11 @@ class ScheduledTootPagingSource(
} }
override suspend fun load(params: LoadParams<String>): LoadResult<String, ScheduledStatus> { override suspend fun load(params: LoadParams<String>): LoadResult<String, ScheduledStatus> {
return if (params is LoadParams.Refresh && scheduledTootsCache.isNotEmpty()) { return if (params is LoadParams.Refresh && scheduledStatusesCache.isNotEmpty()) {
LoadResult.Page( LoadResult.Page(
data = scheduledTootsCache, data = scheduledStatusesCache,
prevKey = null, prevKey = null,
nextKey = scheduledTootsCache.lastOrNull()?.id nextKey = scheduledStatusesCache.lastOrNull()?.id
) )
} else { } else {
try { try {
@ -71,7 +71,7 @@ class ScheduledTootPagingSource(
nextKey = result.lastOrNull()?.id nextKey = result.lastOrNull()?.id
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.w("ScheduledTootPgngSrc", "Error loading scheduled statuses", e) Log.w("ScheduledStatuses", "Error loading scheduled statuses", e)
LoadResult.Error(e) LoadResult.Error(e)
} }
} }

View file

@ -28,12 +28,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ScheduledTootViewModel @Inject constructor( class ScheduledStatusViewModel @Inject constructor(
val mastodonApi: MastodonApi, val mastodonApi: MastodonApi,
val eventHub: EventHub val eventHub: EventHub
) : ViewModel() { ) : ViewModel() {
private val pagingSourceFactory = ScheduledTootPagingSourceFactory(mastodonApi) private val pagingSourceFactory = ScheduledStatusPagingSourceFactory(mastodonApi)
val data = Pager( val data = Pager(
config = PagingConfig(pageSize = 20, initialLoadSize = 20), config = PagingConfig(pageSize = 20, initialLoadSize = 20),

View file

@ -86,7 +86,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
return when (position) { return when (position) {
0 -> getString(R.string.title_statuses) 0 -> getString(R.string.title_posts)
1 -> getString(R.string.title_accounts) 1 -> getString(R.string.title_accounts)
2 -> getString(R.string.title_hashtags_dialog) 2 -> getString(R.string.title_hashtags_dialog)
else -> throw IllegalArgumentException("Unknown page index: $position") else -> throw IllegalArgumentException("Unknown page index: $position")

View file

@ -53,17 +53,15 @@ class SearchViewModel @Inject constructor(
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
private val loadedStatuses: MutableList<Pair<Status, StatusViewData.Concrete>> = mutableListOf() private val loadedStatuses: MutableList<StatusViewData.Concrete> = mutableListOf()
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status -> it.statuses.map { status ->
val statusViewData = status.toViewData( status.toViewData(
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler, isExpanded = alwaysOpenSpoiler,
isCollapsed = true isCollapsed = true
) )
Pair(status, statusViewData)
}.apply { }.apply {
loadedStatuses.addAll(this) loadedStatuses.addAll(this)
} }
@ -100,11 +98,11 @@ class SearchViewModel @Inject constructor(
hashtagsPagingSourceFactory.newSearch(query) hashtagsPagingSourceFactory.newSearch(query)
} }
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) { fun removeItem(statusViewData: StatusViewData.Concrete) {
timelineCases.delete(status.first.id) timelineCases.delete(statusViewData.id)
.subscribe( .subscribe(
{ {
if (loadedStatuses.remove(status)) if (loadedStatuses.remove(statusViewData))
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
}, },
{ err -> { err ->
@ -114,82 +112,81 @@ class SearchViewModel @Inject constructor(
.autoDispose() .autoDispose()
} }
fun expandedChange(status: Pair<Status, StatusViewData.Concrete>, expanded: Boolean) { fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) { if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isExpanded = expanded)) loadedStatuses[idx] = statusViewData.copy(isExpanded = expanded)
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
} }
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) { fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
timelineCases.reblog(status.first.id, reblog) timelineCases.reblog(statusViewData.id, reblog)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ setRebloggedForStatus(status, reblog) }, { setRebloggedForStatus(statusViewData, reblog) },
{ t -> Log.d(TAG, "Failed to reblog status ${status.first.id}", t) } { t -> Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) }
) )
.autoDispose() .autoDispose()
} }
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) { private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) {
status.first.reblogged = reblog statusViewData.status.reblogged = reblog
status.first.reblog?.reblogged = reblog statusViewData.status.reblog?.reblogged = reblog
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
fun contentHiddenChange(status: Pair<Status, StatusViewData.Concrete>, isShowing: Boolean) { fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) { if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isShowingContent = isShowing)) loadedStatuses[idx] = statusViewData.copy(isShowingContent = isShowing)
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
} }
fun collapsedChange(status: Pair<Status, StatusViewData.Concrete>, collapsed: Boolean) { fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) { if (idx >= 0) {
loadedStatuses[idx] = Pair(status.first, status.second.copy(isCollapsed = collapsed)) loadedStatuses[idx] = statusViewData.copy(isCollapsed = collapsed)
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
} }
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) { fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList<Int>) {
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices) val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices)
updateStatus(status, votedPoll) updateStatus(statusViewData, votedPoll)
timelineCases.voteInPoll(status.first.id, votedPoll.id, choices) timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ newPoll -> updateStatus(status, newPoll) }, { newPoll -> updateStatus(statusViewData, newPoll) },
{ t -> Log.d(TAG, "Failed to vote in poll: ${status.first.id}", t) } { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) }
) )
.autoDispose() .autoDispose()
} }
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) { private fun updateStatus(statusViewData: StatusViewData.Concrete, newPoll: Poll) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) { if (idx >= 0) {
val newStatus = status.first.copy(poll = newPoll) val newStatus = statusViewData.status.copy(poll = newPoll)
val newViewData = status.second.copy(status = newStatus) loadedStatuses[idx] = statusViewData.copy(status = newStatus)
loadedStatuses[idx] = Pair(newStatus, newViewData)
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
} }
fun favorite(status: Pair<Status, StatusViewData.Concrete>, isFavorited: Boolean) { fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) {
status.first.favourited = isFavorited statusViewData.status.favourited = isFavorited
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
timelineCases.favourite(status.first.id, isFavorited) timelineCases.favourite(statusViewData.id, isFavorited)
.onErrorReturnItem(status.first) .onErrorReturnItem(statusViewData.status)
.subscribe() .subscribe()
.autoDispose() .autoDispose()
} }
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) { fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) {
status.first.bookmarked = isBookmarked statusViewData.status.bookmarked = isBookmarked
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
timelineCases.bookmark(status.first.id, isBookmarked) timelineCases.bookmark(statusViewData.id, isBookmarked)
.onErrorReturnItem(status.first) .onErrorReturnItem(statusViewData.status)
.subscribe() .subscribe()
.autoDispose() .autoDispose()
} }
@ -214,19 +211,15 @@ class SearchViewModel @Inject constructor(
return timelineCases.delete(id) return timelineCases.delete(id)
} }
fun muteConversation(status: Pair<Status, StatusViewData.Concrete>, mute: Boolean) { fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) {
val idx = loadedStatuses.indexOf(status) val idx = loadedStatuses.indexOf(statusViewData)
if (idx >= 0) { if (idx >= 0) {
val newStatus = status.first.copy(muted = mute) val newStatus = statusViewData.status.copy(muted = mute)
val newPair = Pair( loadedStatuses[idx] = statusViewData.copy(status = newStatus)
newStatus,
status.second.copy(status = newStatus)
)
loadedStatuses[idx] = newPair
statusesPagingSourceFactory.invalidate() statusesPagingSourceFactory.invalidate()
} }
timelineCases.muteConversation(status.first.id, mute) timelineCases.muteConversation(statusViewData.id, mute)
.onErrorReturnItem(status.first) .onErrorReturnItem(statusViewData.status)
.subscribe() .subscribe()
.autoDispose() .autoDispose()
} }

View file

@ -21,11 +21,11 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) : class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) :
PagingDataAdapter<Account, AccountViewHolder>(ACCOUNT_COMPARATOR) { PagingDataAdapter<TimelineAccount, AccountViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
@ -44,11 +44,11 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val
companion object { companion object {
val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<Account>() { val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback<TimelineAccount>() {
override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem.deepEquals(newItem) oldItem == newItem
override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }

View file

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

View file

@ -19,12 +19,12 @@ import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class SearchAccountsFragment : SearchFragment<Account>() { class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
override fun createAdapter(): PagingDataAdapter<Account, *> { override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
return SearchAccountsAdapter( return SearchAccountsAdapter(
@ -34,7 +34,7 @@ class SearchAccountsFragment : SearchFragment<Account>() {
) )
} }
override val data: Flow<PagingData<Account>> override val data: Flow<PagingData<TimelineAccount>>
get() = viewModel.accountsFlow get() = viewModel.accountsFlow
companion object { companion object {

View file

@ -15,7 +15,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R 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.account.AccountActivity
import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.databinding.FragmentSearchBinding
@ -113,7 +113,7 @@ abstract class SearchFragment<T : Any> :
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) 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) { override fun onViewUrl(url: String) {
bottomSheetActivity?.viewUrl(url) bottomSheetActivity?.viewUrl(url)

View file

@ -40,7 +40,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose import autodispose2.autoDispose
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity 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.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.openLink
import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.view.showMuteAccountDialog
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.flow.Flow 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 get() = viewModel.statusesFlow
private val searchAdapter private val searchAdapter
get() = super.adapter as SearchStatusesAdapter 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 preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean("animateGifAvatars", false), animateAvatars = preferences.getBoolean("animateGifAvatars", false),
@ -92,37 +91,37 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
searchAdapter.item(position)?.let { searchAdapter.peek(position)?.let {
viewModel.contentHiddenChange(it, isShowing) viewModel.contentHiddenChange(it, isShowing)
} }
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
searchAdapter.item(position)?.first?.let { status -> searchAdapter.peek(position)?.status?.let { status ->
reply(status) reply(status)
} }
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
searchAdapter.item(position)?.let { status -> searchAdapter.peek(position)?.let { status ->
viewModel.favorite(status, favourite) viewModel.favorite(status, favourite)
} }
} }
override fun onBookmark(bookmark: Boolean, position: Int) { override fun onBookmark(bookmark: Boolean, position: Int) {
searchAdapter.item(position)?.let { status -> searchAdapter.peek(position)?.let { status ->
viewModel.bookmark(status, bookmark) viewModel.bookmark(status, bookmark)
} }
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
searchAdapter.item(position)?.first?.let { searchAdapter.peek(position)?.status?.let {
more(it, view, position) more(it, view, position)
} }
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { 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) { when (actionable.attachments[attachmentIndex].type) {
Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> {
val attachments = AttachmentViewData.list(actionable) val attachments = AttachmentViewData.list(actionable)
@ -143,27 +142,27 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
} }
Attachment.Type.UNKNOWN -> { Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context) context?.openLink(actionable.attachments[attachmentIndex].url)
} }
} }
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
searchAdapter.item(position)?.first?.let { status -> searchAdapter.peek(position)?.status?.let { status ->
val actionableStatus = status.actionableStatus val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url)
} }
} }
override fun onOpenReblog(position: Int) { override fun onOpenReblog(position: Int) {
searchAdapter.item(position)?.first?.let { status -> searchAdapter.peek(position)?.status?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id) bottomSheetActivity?.viewAccount(status.account.id)
} }
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
searchAdapter.item(position)?.let { searchAdapter.peek(position)?.let {
viewModel.expandedChange(it, expanded) viewModel.expandedChange(it, expanded)
} }
} }
@ -173,25 +172,25 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
searchAdapter.item(position)?.let { searchAdapter.peek(position)?.let {
viewModel.collapsedChange(it, isCollapsed) viewModel.collapsedChange(it, isCollapsed)
} }
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
searchAdapter.item(position)?.let { searchAdapter.peek(position)?.let {
viewModel.voteInPoll(it, choices) viewModel.voteInPoll(it, choices)
} }
} }
private fun removeItem(position: Int) { private fun removeItem(position: Int) {
searchAdapter.item(position)?.let { searchAdapter.peek(position)?.let {
viewModel.removeItem(it) viewModel.removeItem(it)
} }
} }
override fun onReblog(reblog: Boolean, position: Int) { override fun onReblog(reblog: Boolean, position: Int) {
searchAdapter.item(position)?.let { status -> searchAdapter.peek(position)?.let { status ->
viewModel.reblog(status, reblog) viewModel.reblog(status, reblog)
} }
} }
@ -228,9 +227,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
val accountId = status.actionableStatus.account.id val accountId = status.actionableStatus.account.id
val accountUsername = status.actionableStatus.account.username val accountUsername = status.actionableStatus.account.username
val statusUrl = status.actionableStatus.url val statusUrl = status.actionableStatus.url
val accounts = viewModel.getAllAccountsOrderedByActive()
var openAsTitle: String? = null
val loggedInAccountId = viewModel.activeAccount?.accountId val loggedInAccountId = viewModel.activeAccount?.accountId
val popup = PopupMenu(view.context, view) val popup = PopupMenu(view.context, view)
@ -261,17 +257,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
val openAsItem = popup.menu.findItem(R.id.status_open_as) val openAsItem = popup.menu.findItem(R.id.status_open_as)
when (accounts.size) { val openAsText = bottomSheetActivity?.openAsText
0, 1 -> openAsItem.isVisible = false if (openAsText == null) {
2 -> for (account in accounts) { openAsItem.isVisible = false
if (account !== viewModel.activeAccount) { } else {
openAsTitle = String.format(getString(R.string.action_open_as), account.fullName) openAsItem.title = openAsText
break
}
}
else -> openAsTitle = String.format(getString(R.string.action_open_as), "")
} }
openAsItem.title = openAsTitle
val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions)
val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply { val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply {
@ -289,7 +280,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
popup.setOnMenuItemClickListener { item -> popup.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.status_share_content -> { R.id.post_share_content -> {
val statusToShare: Status = status.actionableStatus val statusToShare: Status = status.actionableStatus
val sendIntent = Intent() val sendIntent = Intent()
@ -300,15 +291,15 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
statusToShare.content.toString() statusToShare.content.toString()
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare)
sendIntent.type = "text/plain" sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_content_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_content_to)))
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_share_link -> { R.id.post_share_link -> {
val sendIntent = Intent() val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND sendIntent.action = Intent.ACTION_SEND
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl)
sendIntent.type = "text/plain" sendIntent.type = "text/plain"
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_status_link_to))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_link_to)))
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_copy_link -> { R.id.status_copy_link -> {
@ -325,7 +316,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
R.id.status_mute_conversation -> { R.id.status_mute_conversation -> {
searchAdapter.item(position)?.let { foundStatus -> searchAdapter.peek(position)?.let { foundStatus ->
viewModel.muteConversation(foundStatus, status.muted != true) viewModel.muteConversation(foundStatus, status.muted != true)
} }
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
@ -396,21 +387,12 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
dialogTitle, false, dialogTitle, false,
object : AccountSelectionListener { object : AccountSelectionListener {
override fun onAccountSelected(account: AccountEntity) { 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) { private fun downloadAllMedia(status: Status) {
Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show()
for ((_, url) in status.attachments) { for ((_, url) in status.attachments) {
@ -442,7 +424,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
private fun showConfirmDeleteDialog(id: String, position: Int) { private fun showConfirmDeleteDialog(id: String, position: Int) {
context?.let { context?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
removeItem(position) removeItem(position)
@ -455,7 +437,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
private fun showConfirmEditDialog(id: String, position: Int, status: Status) { private fun showConfirmEditDialog(id: String, position: Int, status: Status) {
activity?.let { activity?.let {
AlertDialog.Builder(it) AlertDialog.Builder(it)
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -473,7 +455,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
val intent = ComposeActivity.startIntent( val intent = ComposeActivity.startIntent(
requireContext(), requireContext(),
ComposeOptions( ComposeOptions(
tootText = redraftStatus.text ?: "", content = redraftStatus.text ?: "",
inReplyToId = redraftStatus.inReplyToId, inReplyToId = redraftStatus.inReplyToId,
visibility = redraftStatus.visibility, visibility = redraftStatus.visibility,
contentWarning = redraftStatus.spoilerText, contentWarning = redraftStatus.spoilerText,

View file

@ -38,6 +38,7 @@ import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
@ -89,9 +90,9 @@ class TimelineFragment :
private val viewModel: TimelineViewModel by lazy { private val viewModel: TimelineViewModel by lazy {
if (kind == TimelineViewModel.Kind.HOME) { if (kind == TimelineViewModel.Kind.HOME) {
ViewModelProvider(this, viewModelFactory).get(CachedTimelineViewModel::class.java) ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java]
} else { } else {
ViewModelProvider(this, viewModelFactory).get(NetworkTimelineViewModel::class.java) ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java]
} }
} }
@ -103,8 +104,6 @@ class TimelineFragment :
private var isSwipeToRefreshEnabled = true private var isSwipeToRefreshEnabled = true
private var eventRegistered = false
private var layoutManager: LinearLayoutManager? = null private var layoutManager: LinearLayoutManager? = null
private var scrollListener: RecyclerView.OnScrollListener? = null private var scrollListener: RecyclerView.OnScrollListener? = null
private var hideFab = false private var hideFab = false
@ -137,7 +136,7 @@ class TimelineFragment :
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true)
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val statusDisplayOptions = StatusDisplayOptions( val statusDisplayOptions = StatusDisplayOptions(
animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled,
@ -183,7 +182,7 @@ class TimelineFragment :
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {
when (loadState.refresh) { when (loadState.refresh) {
is LoadState.NotLoading -> { is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading) { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show() binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
} }
@ -218,43 +217,14 @@ class TimelineFragment :
} }
}) })
lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.statuses.collectLatest { pagingData -> viewModel.statuses.collectLatest { pagingData ->
adapter.submitData(pagingData) 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()) { if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
hideFab = preferences.getBoolean("fabHide", false) hideFab = preferences.getBoolean("fabHide", false)
scrollListener = object : RecyclerView.OnScrollListener() { scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
@ -276,23 +246,47 @@ class TimelineFragment :
} }
} }
if (!eventRegistered) { eventHub.events
eventHub.events .observeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread()) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.autoDispose(this, Lifecycle.Event.ON_DESTROY) .subscribe { event ->
.subscribe { event -> when (event) {
when (event) { is PreferenceChangedEvent -> {
is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey)
onPreferenceChanged(event.preferenceKey) }
} is StatusComposedEvent -> {
is StatusComposedEvent -> { val status = event.status
val status = event.status handleStatusComposeEvent(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 ->
if (pos in 0 until adapter.itemCount) {
adapter.peek(pos)
} else {
null
}
}
)
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 onRefresh() { override fun onRefresh() {
@ -407,7 +401,7 @@ class TimelineFragment :
} }
private fun onPreferenceChanged(key: String) { private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
when (key) { when (key) {
PrefKeys.FAB_HIDE -> { PrefKeys.FAB_HIDE -> {
hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
@ -417,7 +411,7 @@ class TimelineFragment :
val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
if (enabled != oldMediaPreviewEnabled) { if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled adapter.mediaPreviewEnabled = enabled
adapter.notifyDataSetChanged() adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
} }
} }
@ -463,7 +457,7 @@ class TimelineFragment :
talkBackWasEnabled = a11yManager?.isEnabled == true talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) { if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyDataSetChanged() adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
startUpdateTimestamp() startUpdateTimestamp()
} }
@ -474,14 +468,14 @@ class TimelineFragment :
* Auto dispose observable on pause * Auto dispose observable on pause
*/ */
private fun startUpdateTimestamp() { private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(activity) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) { if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES) Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE) .autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { .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 { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) { return when (viewType) {
VIEW_TYPE_STATUS -> { VIEW_TYPE_STATUS -> {
@ -110,7 +114,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Boolean { ): Boolean {
return oldItem.viewDataId == newItem.viewDataId return oldItem.id == newItem.id
} }
override fun areContentsTheSame( override fun areContentsTheSame(
@ -124,7 +128,7 @@ class TimelinePagingAdapter(
oldItem: StatusViewData, oldItem: StatusViewData,
newItem: StatusViewData newItem: StatusViewData
): Any? { ): Any? {
return if (oldItem === newItem) { return if (oldItem == newItem) {
// If items are equal - update timestamp only // If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED) listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update the whole view holder } else // If items are different - update the whole view holder

View file

@ -23,11 +23,12 @@ import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineAccountEntity
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
@ -41,8 +42,9 @@ data class Placeholder(
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.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 { fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity( return TimelineAccountEntity(
serverId = id, serverId = id,
timelineUserId = accountId, timelineUserId = accountId,
@ -56,25 +58,16 @@ fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
) )
} }
fun TimelineAccountEntity.toAccount(gson: Gson): Account { fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
return Account( return TimelineAccount(
id = serverId, id = serverId,
localUsername = localUsername, localUsername = localUsername,
username = username, username = username,
displayName = displayName, displayName = displayName,
note = SpannedString(""),
url = url, url = url,
avatar = avatar, avatar = avatar,
header = "",
locked = false,
followingCount = 0,
followersCount = 0,
statusesCount = 0,
source = null,
bot = bot, bot = bot,
emojis = gson.fromJson(emojis, emojisListType), emojis = gson.fromJson(emojis, emojisListType)
fields = null,
moved = null
) )
} }
@ -99,6 +92,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
visibility = Status.Visibility.UNKNOWN, visibility = Status.Visibility.UNKNOWN,
attachments = null, attachments = null,
mentions = null, mentions = null,
tags = null,
application = null, application = null,
reblogServerId = null, reblogServerId = null,
reblogAccountId = null, reblogAccountId = null,
@ -138,6 +132,7 @@ fun Status.toEntity(
visibility = actionableStatus.visibility, visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson), attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson), mentions = actionableStatus.mentions.let(gson::toJson),
tags = actionableStatus.tags.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson), application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id, reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id }, reblogAccountId = reblog?.let { this.account.id },
@ -157,6 +152,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList() 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 application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList() val emojis: List<Emoji> = gson.fromJson(status.emojis, emojisListType) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
@ -183,6 +179,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility, visibility = status.visibility,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
tags = tags,
application = application, application = application,
pinned = false, pinned = false,
muted = status.muted, muted = status.muted,
@ -211,6 +208,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility, visibility = status.visibility,
attachments = ArrayList(), attachments = ArrayList(),
mentions = listOf(), mentions = listOf(),
tags = listOf(),
application = null, application = null,
pinned = status.pinned, pinned = status.pinned,
muted = status.muted, muted = status.muted,
@ -239,6 +237,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
visibility = status.visibility, visibility = status.visibility,
attachments = attachments, attachments = attachments,
mentions = mentions, mentions = mentions,
tags = tags,
application = application, application = application,
pinned = status.pinned, pinned = status.pinned,
muted = status.muted, muted = status.muted,

View file

@ -0,0 +1,17 @@
package com.keylesspalace.tusky.components.timeline.util
import retrofit2.HttpException
import java.io.IOException
fun Throwable.isExpected() = this is IOException || this is HttpException
inline fun <T> ifExpected(
t: Throwable,
cb: () -> T
): T {
if (t.isExpected()) {
return cb()
} else {
throw t
}
}

View file

@ -23,13 +23,13 @@ import androidx.room.withTransaction
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusEntity
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.dec
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException import retrofit2.HttpException
@ -101,15 +101,22 @@ class CachedTimelineRemoteMediator(
db.withTransaction { db.withTransaction {
val overlappedStatuses = replaceStatusRange(statuses, state) val overlappedStatuses = replaceStatusRange(statuses, state)
if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.isNotEmpty() && !dbEmpty) { /* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
) )
} }
} }
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return ifExpected(e) {
MediatorResult.Error(e)
}
} }
} }

View file

@ -34,14 +34,13 @@ import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases 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 com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -70,7 +69,14 @@ class CachedTimelineViewModel @Inject constructor(
override val statuses = Pager( override val statuses = Pager(
config = PagingConfig(pageSize = LOAD_AT_ONCE), config = PagingConfig(pageSize = LOAD_AT_ONCE),
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson),
pagingSourceFactory = { db.timelineDao().getStatusesForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyTimelinePagingSource()
} else {
db.timelineDao().getStatuses(activeAccount.id)
}
}
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.map { timelineStatus -> pagingData.map { timelineStatus ->
@ -141,9 +147,11 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id))
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val response = api.homeTimeline(maxId = placeholderId.inc(), sinceId = nextPlaceholderId, limit = 20).await() val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE)
}.await()
val statuses = response.body() val statuses = response.body()
if (!response.isSuccessful || statuses == null) { if (!response.isSuccessful || statuses == null) {
@ -177,14 +185,21 @@ class CachedTimelineViewModel @Inject constructor(
) )
} }
if (overlappedStatuses == 0 && statuses.isNotEmpty()) { /* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) {
/* This overrides the last of the newly loaded statuses with a placeholder
to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id.dec(), loading = false).toEntity(activeAccount.id) Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id)
) )
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
loadMoreFailed(placeholderId, e) ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
} }
} }
} }
@ -214,10 +229,7 @@ class CachedTimelineViewModel @Inject constructor(
override fun fullReload() { override fun fullReload() {
viewModelScope.launch { viewModelScope.launch {
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
db.runInTransaction { db.timelineDao().removeAll(activeAccount.id)
db.timelineDao().removeAllForAccount(activeAccount.id)
db.timelineDao().removeAllUsersForAccount(activeAccount.id)
}
} }
} }

View file

@ -0,0 +1,11 @@
package com.keylesspalace.tusky.components.timeline.viewmodel
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
class EmptyTimelinePagingSource : PagingSource<Int, TimelineStatusWithAccount>() {
override fun getRefreshKey(state: PagingState<Int, TimelineStatusWithAccount>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TimelineStatusWithAccount> = LoadResult.Page(emptyList(), null, null)
}

View file

@ -19,9 +19,9 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import retrofit2.HttpException import retrofit2.HttpException
@ -92,7 +92,7 @@ class NetworkTimelineRemoteMediator(
viewModel.statusData.addAll(0, data) viewModel.statusData.addAll(0, data)
if (insertPlaceholder) { if (insertPlaceholder) {
viewModel.statusData.add(statuses.size, StatusViewData.Placeholder(statuses.last().id.dec(), false)) viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false)
} }
} else { } else {
val linkHeader = statusResponse.headers()["Link"] val linkHeader = statusResponse.headers()["Link"]
@ -107,7 +107,9 @@ class NetworkTimelineRemoteMediator(
viewModel.currentSource?.invalidate() viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return ifExpected(e) {
MediatorResult.Error(e)
}
} }
} }
} }

View file

@ -28,14 +28,16 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.FavoriteEvent
import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PinEvent
import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases 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.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -43,6 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -117,7 +120,7 @@ class NetworkTimelineViewModel @Inject constructor(
override fun removeAllByInstance(instance: String) { override fun removeAllByInstance(instance: String) {
statusData.removeAll { vd -> statusData.removeAll { vd ->
val status = vd.asStatusOrNull()?.status ?: return@removeAll false val status = vd.asStatusOrNull()?.status ?: return@removeAll false
LinkHelper.getDomain(status.account.url) == instance getDomain(status.account.url) == instance
} }
currentSource?.invalidate() currentSource?.invalidate()
} }
@ -133,8 +136,14 @@ class NetworkTimelineViewModel @Inject constructor(
override fun loadMore(placeholderId: String) { override fun loadMore(placeholderId: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val placeholderIndex =
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true)
val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id
val statusResponse = fetchStatusesForKind( val statusResponse = fetchStatusesForKind(
fromId = placeholderId.inc(), fromId = idAbovePlaceholder,
uptoId = null, uptoId = null,
limit = 20 limit = 20
) )
@ -145,32 +154,53 @@ class NetworkTimelineViewModel @Inject constructor(
return@launch return@launch
} }
statusData.removeAt(placeholderIndex)
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
val data: MutableList<StatusViewData> = statuses.map { status ->
val data = statuses.map { status ->
val oldStatus = statusData.find { s ->
s.asStatusOrNull()?.id == status.id
}?.asStatusOrNull()
val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true
status.toViewData( status.toViewData(
isShowingContent = contentShowing, isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = expanded, isExpanded = activeAccount.alwaysOpenSpoiler,
isCollapsed = contentCollapsed isCollapsed = true
) )
}.toMutableList()
if (statuses.isNotEmpty()) {
val firstId = statuses.first().id
val lastId = statuses.last().id
val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false }
val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false }
if (overlappedFrom < overlappedTo) {
data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() }
.filter { (_, oldStatus) -> oldStatus != null }
.forEach { (i, oldStatus) ->
data[i] = data[i].asStatusOrNull()!!
.copy(
isShowingContent = oldStatus!!.isShowingContent,
isExpanded = oldStatus.isExpanded,
isCollapsed = oldStatus.isCollapsed,
)
}
statusData.removeAll { status ->
when (status) {
is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId)
}
}
} else {
data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false)
}
} }
val index = statusData.addAll(placeholderIndex, data)
statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId }
statusData.removeAt(index)
statusData.addAll(index, data)
currentSource?.invalidate() currentSource?.invalidate()
} catch (e: Exception) { } catch (e: Exception) {
loadMoreFailed(placeholderId, e) ifExpected(e) {
loadMoreFailed(placeholderId, e)
}
} }
} }
} }
@ -210,10 +240,12 @@ class NetworkTimelineViewModel @Inject constructor(
} }
override fun fullReload() { override fun fullReload() {
nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id
statusData.clear() statusData.clear()
currentSource?.invalidate() currentSource?.invalidate()
} }
@Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind( suspend fun fetchStatusesForKind(
fromId: String?, fromId: String?,
uptoId: String?, uptoId: String?,

View file

@ -33,6 +33,7 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.ReblogEvent
import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.appstore.UnfollowEvent
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
@ -46,8 +47,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import retrofit2.HttpException
import java.io.IOException
abstract class TimelineViewModel( abstract class TimelineViewModel(
private val timelineCases: TimelineCases, private val timelineCases: TimelineCases,
@ -291,19 +290,6 @@ abstract class TimelineViewModel(
} }
} }
private fun isExpectedRequestException(t: Exception) = t is IOException || t is HttpException
private inline fun ifExpected(
t: Exception,
cb: () -> Unit
) {
if (isExpectedRequestException(t)) {
cb()
} else {
throw t
}
}
companion object { companion object {
private const val TAG = "TimelineVM" private const val TAG = "TimelineVM"
internal const val LOAD_AT_ONCE = 30 internal const val LOAD_AT_ONCE = 30

View file

@ -29,10 +29,9 @@ import java.io.File;
/** /**
* DB version & declare DAO * DB version & declare DAO
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 28) }, version = 31)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -457,4 +456,31 @@ public abstract class AppDatabase extends RoomDatabase {
"ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); "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");
}
};
public static final Migration MIGRATION_30_31 = new Migration(30, 31) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs
database.execSQL("DELETE FROM `TimelineAccountEntity`");
database.execSQL("DELETE FROM `TimelineStatusEntity`");
}
};
} }

View file

@ -27,6 +27,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -119,6 +120,16 @@ class Converters @Inject constructor (
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type) 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 @TypeConverter
fun dateToLong(date: Date): Long { fun dateToLong(date: Date): Long {
return date.time return date.time

View file

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

View file

@ -35,7 +35,7 @@ abstract class TimelineDao {
SELECT s.serverId, s.url, s.timelineUserId, SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, 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, 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.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username', 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 WHERE s.timelineUserId = :account
ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" 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( @Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND """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) 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") @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") @Query("DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId")
abstract fun removeAllUsersForAccount(accountId: Long) abstract suspend fun removeAllAccounts(accountId: Long)
@Query( @Query(
"""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
@ -98,6 +107,16 @@ AND serverId = :statusId"""
) )
abstract fun delete(accountId: Long, statusId: String) abstract fun delete(accountId: Long, statusId: String)
/**
* 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. * Cleans the TimelineStatusEntity table from old status entries.
* @param accountId id of the account for which to clean statuses * @param accountId id of the account for which to clean statuses
@ -108,7 +127,7 @@ AND serverId = :statusId"""
(SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit) (SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :limit)
""" """
) )
abstract suspend fun cleanup(accountId: Long, limit: Int) abstract suspend fun cleanupStatuses(accountId: Long, limit: Int)
/** /**
* Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table * Cleans the TimelineAccountEntity table from accounts that are no longer referenced in the TimelineStatusEntity table
@ -167,6 +186,15 @@ AND timelineUserId = :accountId
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") @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? abstract suspend fun getTopPlaceholderId(accountId: Long): String?
/**
* Returns the id directly above [serverId], or null if [serverId] is the id of the top status
*/
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1")
abstract suspend fun getIdAbove(accountId: Long, serverId: String): String?
/**
* Returns the id of the next placeholder after [serverId]
*/
@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") @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? abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
} }

View file

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

View file

@ -22,23 +22,22 @@ import com.keylesspalace.tusky.EditProfileActivity
import com.keylesspalace.tusky.FiltersActivity import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.LoginActivity
import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.ModalTimelineActivity
import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.ViewThreadActivity import com.keylesspalace.tusky.ViewThreadActivity
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginWebViewActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@ -71,12 +70,6 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesAccountListActivity(): AccountListActivity abstract fun contributesAccountListActivity(): AccountListActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesModalTimelineActivity(): ModalTimelineActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewTagActivity(): ViewTagActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesViewThreadActivity(): ViewThreadActivity abstract fun contributesViewThreadActivity(): ViewThreadActivity
@ -93,7 +86,7 @@ abstract class ActivitiesModule {
abstract fun contributesLoginActivity(): LoginActivity abstract fun contributesLoginActivity(): LoginActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity abstract fun contributesLoginWebViewActivity(): LoginWebViewActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesPreferencesActivity(): PreferencesActivity abstract fun contributesPreferencesActivity(): PreferencesActivity
@ -117,11 +110,14 @@ abstract class ActivitiesModule {
abstract fun contributesInstanceListActivity(): InstanceListActivity abstract fun contributesInstanceListActivity(): InstanceListActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
} }

View file

@ -61,7 +61,8 @@ class AppModule {
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, 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.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), 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, AppDatabase.MIGRATION_30_31
) )
.build() .build()
} }

View file

@ -15,12 +15,12 @@
package com.keylesspalace.tusky.di package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.service.SendTootService import com.keylesspalace.tusky.service.SendStatusService
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@Module @Module
abstract class ServicesModule { abstract class ServicesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesSendTootService(): SendTootService abstract fun contributesSendStatusService(): SendStatusService
} }

View file

@ -10,7 +10,7 @@ import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
@ -85,8 +85,8 @@ abstract class ViewModelModule {
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(ScheduledTootViewModel::class) @ViewModelKey(ScheduledStatusViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel internal abstract fun scheduledStatusViewModel(viewModel: ScheduledStatusViewModel): ViewModel
@Binds @Binds
@IntoMap @IntoMap

View file

@ -45,37 +45,57 @@ data class Account(
localUsername localUsername
} else displayName } else displayName
override fun hashCode(): Int {
return id.hashCode()
}
override fun equals(other: Any?): Boolean {
if (other !is Account) {
return false
}
return other.id == this.id
}
fun deepEquals(other: Account): Boolean {
return id == other.id &&
localUsername == other.localUsername &&
displayName == other.displayName &&
note == other.note &&
url == other.url &&
avatar == other.avatar &&
header == other.header &&
locked == other.locked &&
followersCount == other.followersCount &&
followingCount == other.followingCount &&
statusesCount == other.statusesCount &&
source == other.source &&
bot == other.bot &&
emojis == other.emojis &&
fields == other.fields &&
moved == other.moved
}
fun isRemote(): Boolean = this.username != this.localUsername fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
} }
data class AccountSource( data class AccountSource(

View file

@ -19,7 +19,7 @@ import com.google.gson.annotations.SerializedName
data class Conversation( data class Conversation(
val id: String, val id: String,
val accounts: List<Account>, val accounts: List<TimelineAccount>,
@SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038
val unread: Boolean val unread: Boolean
) )

View file

@ -1,3 +1,3 @@
package com.keylesspalace.tusky.entity 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("contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?, @SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: 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 { override fun hashCode(): Int {
return uri.hashCode() return uri.hashCode()
@ -45,7 +46,31 @@ data class Instance(
} }
} }
data class PollLimits( data class PollConfiguration(
@SerializedName("max_options") val maxOptions: Int?, @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

@ -24,7 +24,7 @@ import com.google.gson.annotations.JsonAdapter
data class Notification( data class Notification(
val type: Type, val type: Type,
val id: String, val id: String,
val account: Account, val account: TimelineAccount,
val status: Status? val status: Status?
) { ) {

View file

@ -16,7 +16,7 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
data class SearchResult( data class SearchResult(
val accounts: List<Account>, val accounts: List<TimelineAccount>,
val statuses: List<Status>, val statuses: List<Status>,
val hashtags: List<HashTag> val hashtags: List<HashTag>
) )

View file

@ -25,7 +25,7 @@ import java.util.Date
data class Status( data class Status(
val id: String, val id: String,
val url: String?, // not present if it's reblog val url: String?, // not present if it's reblog
val account: Account, val account: TimelineAccount,
@SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
@ -42,6 +42,7 @@ data class Status(
val visibility: Visibility, val visibility: Visibility,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>, @SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: List<Mention>, val mentions: List<Mention>,
val tags: List<HashTag>?,
val application: Application?, val application: Application?,
val pinned: Boolean?, val pinned: Boolean?,
val muted: Boolean?, val muted: Boolean?,
@ -148,6 +149,71 @@ data class Status(
return builder.toString() return builder.toString()
} }
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Status
if (id != other.id) return false
if (url != other.url) return false
if (account != other.account) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (reblog != other.reblog) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (reblogsCount != other.reblogsCount) return false
if (favouritesCount != other.favouritesCount) return false
if (reblogged != other.reblogged) return false
if (favourited != other.favourited) return false
if (bookmarked != other.bookmarked) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (visibility != other.visibility) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (application != other.application) return false
if (pinned != other.pinned) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
if (card != other.card) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + (reblog?.hashCode() ?: 0)
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + reblogsCount
result = 31 * result + favouritesCount
result = 31 * result + reblogged.hashCode()
result = 31 * result + favourited.hashCode()
result = 31 * result + bookmarked.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + visibility.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (application?.hashCode() ?: 0)
result = 31 * result + (pinned?.hashCode() ?: 0)
result = 31 * result + (muted?.hashCode() ?: 0)
result = 31 * result + (poll?.hashCode() ?: 0)
result = 31 * result + (card?.hashCode() ?: 0)
return result
}
data class Mention( data class Mention(
val id: String, val id: String,
val url: String, val url: String,

View file

@ -0,0 +1,39 @@
/* 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.entity
import com.google.gson.annotations.SerializedName
/**
* Same as [Account], but only with the attributes required in timelines.
* Prefer this class over [Account] because it uses way less memory & deserializes faster from json.
*/
data class TimelineAccount(
val id: String,
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract
val url: String,
val avatar: String,
val bot: Boolean = false,
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
) {
val name: String
get() = if (displayName.isNullOrEmpty()) {
localUsername
} else displayName
}

View file

@ -42,8 +42,8 @@ import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.databinding.FragmentAccountListBinding
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
followRequestsAdapter.removeItem(position) followRequestsAdapter.removeItem(position)
} }
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> { private fun getFetchCallByListType(fromId: String?): Single<Response<List<TimelineAccount>>> {
return when (type) { return when (type) {
Type.FOLLOWS -> { Type.FOLLOWS -> {
val accountId = requireId(type, id) val accountId = requireId(type, id)
@ -313,7 +313,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
) )
} }
private fun onFetchAccountsSuccess(accounts: List<Account>, linkHeader: String?) { private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
adapter.setBottomLoading(false) adapter.setBottomLoading(false)
val links = HttpHeaderLink.parse(linkHeader) val links = HttpHeaderLink.parse(linkHeader)

View file

@ -41,11 +41,10 @@ import androidx.lifecycle.Lifecycle;
import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity; import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.PostLookupFallbackBehavior; import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.StatusListActivity;
import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.components.compose.ComposeActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions; import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
import com.keylesspalace.tusky.components.report.ReportActivity; import com.keylesspalace.tusky.components.report.ReportActivity;
@ -162,8 +161,6 @@ public abstract class SFragment extends Fragment implements Injectable {
final String accountId = status.getActionableStatus().getAccount().getId(); final String accountId = status.getActionableStatus().getAccount().getId();
final String accountUsername = status.getActionableStatus().getAccount().getUsername(); final String accountUsername = status.getActionableStatus().getAccount().getUsername();
final String statusUrl = status.getActionableStatus().getUrl(); final String statusUrl = status.getActionableStatus().getUrl();
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
String openAsTitle = null;
String loggedInAccountId = null; String loggedInAccountId = null;
AccountEntity activeAccount = accountManager.getActiveAccount(); AccountEntity activeAccount = accountManager.getActiveAccount();
@ -201,24 +198,12 @@ public abstract class SFragment extends Fragment implements Injectable {
Menu menu = popup.getMenu(); Menu menu = popup.getMenu();
MenuItem openAsItem = menu.findItem(R.id.status_open_as); MenuItem openAsItem = menu.findItem(R.id.status_open_as);
switch (accounts.size()) { String openAsText = ((BaseActivity)getActivity()).getOpenAsText();
case 0: if (openAsText == null) {
case 1: openAsItem.setVisible(false);
openAsItem.setVisible(false); } else {
break; openAsItem.setTitle(openAsText);
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;
} }
openAsItem.setTitle(openAsTitle);
MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation); MenuItem muteConversationItem = menu.findItem(R.id.status_mute_conversation);
boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions()); boolean mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.getMentions());
@ -231,7 +216,7 @@ public abstract class SFragment extends Fragment implements Injectable {
popup.setOnMenuItemClickListener(item -> { popup.setOnMenuItemClickListener(item -> {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.status_share_content: { case R.id.post_share_content: {
Status statusToShare = status; Status statusToShare = status;
if (statusToShare.getReblog() != null) if (statusToShare.getReblog() != null)
statusToShare = statusToShare.getReblog(); statusToShare = statusToShare.getReblog();
@ -245,15 +230,15 @@ public abstract class SFragment extends Fragment implements Injectable {
sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare); sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare);
sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl); sendIntent.putExtra(Intent.EXTRA_SUBJECT, statusUrl);
sendIntent.setType("text/plain"); sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_content_to))); startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_content_to)));
return true; return true;
} }
case R.id.status_share_link: { case R.id.post_share_link: {
Intent sendIntent = new Intent(); Intent sendIntent = new Intent();
sendIntent.setAction(Intent.ACTION_SEND); sendIntent.setAction(Intent.ACTION_SEND);
sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl); sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl);
sendIntent.setType("text/plain"); sendIntent.setType("text/plain");
startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_status_link_to))); startActivity(Intent.createChooser(sendIntent, getResources().getText(R.string.send_post_link_to)));
return true; return true;
} }
case R.id.status_copy_link: { case R.id.status_copy_link: {
@ -378,15 +363,14 @@ public abstract class SFragment extends Fragment implements Injectable {
} }
default: default:
case UNKNOWN: { case UNKNOWN: {
LinkHelper.openLink(active.getAttachment().getUrl(), getContext()); LinkHelper.openLink(requireContext(), active.getAttachment().getUrl());
break; break;
} }
} }
} }
protected void viewTag(String tag) { protected void viewTag(String tag) {
Intent intent = new Intent(getContext(), ViewTagActivity.class); Intent intent = StatusListActivity.newHashtagIntent(requireContext(), tag);
intent.putExtra("hashtag", tag);
startActivity(intent); startActivity(intent);
} }
@ -396,7 +380,7 @@ public abstract class SFragment extends Fragment implements Injectable {
protected void showConfirmDeleteDialog(final String id, final int position) { protected void showConfirmDeleteDialog(final String id, final int position) {
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id) timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -419,7 +403,7 @@ public abstract class SFragment extends Fragment implements Injectable {
return; return;
} }
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_post_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id) timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -431,7 +415,7 @@ public abstract class SFragment extends Fragment implements Injectable {
deletedStatus = status.toDeletedStatus(); deletedStatus = status.toDeletedStatus();
} }
ComposeOptions composeOptions = new ComposeOptions(); ComposeOptions composeOptions = new ComposeOptions();
composeOptions.setTootText(deletedStatus.getText()); composeOptions.setContent(deletedStatus.getText());
composeOptions.setInReplyToId(deletedStatus.getInReplyToId()); composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
composeOptions.setVisibility(deletedStatus.getVisibility()); composeOptions.setVisibility(deletedStatus.getVisibility());
composeOptions.setContentWarning(deletedStatus.getSpoilerText()); composeOptions.setContentWarning(deletedStatus.getSpoilerText());
@ -456,18 +440,9 @@ public abstract class SFragment extends Fragment implements Injectable {
.show(); .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) { private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) {
BaseActivity activity = (BaseActivity) getActivity(); 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) { private void downloadAllMedia(Status status) {

View file

@ -330,7 +330,7 @@ public final class ViewThreadFragment extends SFragment implements
// already viewing the status with this 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 // probably just a preview federated and the user is clicking again to view more -> open the browser
// this can happen with some friendica statuses // this can happen with some friendica statuses
LinkHelper.openLink(url, requireContext()); LinkHelper.openLink(requireContext(), url);
return; return;
} }
super.onViewUrl(url); super.onViewUrl(url);

View file

@ -13,10 +13,10 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.interfaces; package com.keylesspalace.tusky.interfaces
public interface LinkListener { interface LinkListener {
void onViewTag(String tag); fun onViewTag(tag: String)
void onViewAccount(String id); fun onViewAccount(id: String)
void onViewUrl(String url); fun onViewUrl(url: String)
} }

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