diff --git a/app/build.gradle b/app/build.gradle index 0f80418f5..f043a0bfc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,7 +19,7 @@ def getGitSha = { } android { - compileSdkVersion 31 + compileSdkVersion 33 defaultConfig { applicationId 'net.accelf.yuito' minSdkVersion 21 @@ -99,118 +99,72 @@ android { } } -ext.coroutinesVersion = "1.6.1" -ext.lifecycleVersion = "2.4.1" -ext.roomVersion = '2.4.2' -ext.retrofitVersion = '2.9.0' -ext.okhttpVersion = '4.9.3' -ext.glideVersion = '4.13.1' -ext.daggerVersion = '2.42' -ext.materialdrawerVersion = '8.4.5' -ext.emoji2_version = '1.1.0' -ext.filemojicompat_version = '3.2.2' - repositories { maven { url 'https://maven.accelf.net/' } } -// if libraries are changed here, they should also be changed in LicenseActivity +// library versions are in PROJECT_ROOT/gradle/libs.versions.toml dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" + implementation libs.kotlinx.coroutines.android + implementation libs.kotlinx.coroutines.rx3 - implementation "androidx.core:core-ktx:1.7.0" - implementation "androidx.appcompat:appcompat:1.4.1" - implementation "androidx.fragment:fragment-ktx:1.4.1" - implementation "androidx.browser:browser:1.4.0" - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation "androidx.recyclerview:recyclerview:1.2.1" - implementation "androidx.exifinterface:exifinterface:1.3.3" - implementation "androidx.cardview:cardview:1.0.0" - implementation "androidx.preference:preference-ktx:1.2.0" - implementation "androidx.sharetarget:sharetarget:1.2.0-rc01" - implementation "androidx.emoji2:emoji2:$emoji2_version" - implementation "androidx.emoji2:emoji2-views:$emoji2_version" - implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" - implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion" - implementation "androidx.constraintlayout:constraintlayout:2.1.3" - implementation "androidx.paging:paging-runtime-ktx:3.1.1" - implementation "androidx.viewpager2:viewpager2:1.0.0" - implementation "androidx.work:work-runtime:2.7.1" - implementation "androidx.room:room-ktx:$roomVersion" - implementation "androidx.room:room-paging:$roomVersion" - kapt "androidx.room:room-compiler:$roomVersion" - implementation 'androidx.core:core-splashscreen:1.0.0-beta02' + implementation libs.bundles.androidx + implementation libs.bundles.room + kapt libs.androidx.room.compiler - implementation "com.google.android.material:material:1.6.0" + implementation libs.android.material - implementation "com.google.code.gson:gson:2.9.0" + implementation libs.gson - implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" - implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" - implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" - implementation "at.connyduck:networkresult-calladapter:1.0.0" + implementation libs.bundles.retrofit + implementation libs.networkresult.calladapter - implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" - implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" + implementation libs.bundles.okhttp - implementation "org.conscrypt:conscrypt-android:2.5.2" + implementation libs.conscrypt.android - implementation "com.github.bumptech.glide:glide:$glideVersion" - implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" - kapt "com.github.bumptech.glide:compiler:$glideVersion" + implementation libs.bundles.glide + kapt libs.glide.compiler - implementation "com.github.penfeizhou.android.animation:glide-plugin:2.22.0" + implementation libs.bundles.rxjava3 - implementation "io.reactivex.rxjava3:rxjava:3.1.3" - implementation "io.reactivex.rxjava3:rxandroid:3.0.0" - implementation "io.reactivex.rxjava3:rxkotlin:3.0.1" + implementation libs.bundles.autodispose - implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1" - implementation "com.uber.autodispose2:autodispose:2.1.1" + implementation libs.bundles.dagger + kapt libs.bundles.dagger.processors - implementation "com.google.dagger:dagger:$daggerVersion" - kapt "com.google.dagger:dagger-compiler:$daggerVersion" - implementation "com.google.dagger:dagger-android:$daggerVersion" - implementation "com.google.dagger:dagger-android-support:$daggerVersion" - kapt "com.google.dagger:dagger-android-processor:$daggerVersion" + implementation libs.sparkbutton - implementation "com.github.connyduck:sparkbutton:4.1.0" + implementation libs.photoview - implementation "com.github.chrisbanes:PhotoView:2.3.0" + implementation libs.bundles.material.drawer + implementation libs.material.typeface, { + artifact { + type = "aar" + } + } - implementation "com.mikepenz:materialdrawer:$materialdrawerVersion" - implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" - implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar' + implementation libs.image.cropper - implementation "com.github.CanHub:Android-Image-Cropper:4.2.1" + implementation libs.bundles.filemojicompat - implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" - implementation "de.c1710:filemojicompat:$filemojicompat_version" - implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version" + implementation libs.bouncycastle + implementation libs.unified.push - implementation "org.bouncycastle:bcprov-jdk15on:1.70" - implementation "com.github.UnifiedPush:android-connector:2.0.0" + testImplementation libs.androidx.test.junit + testImplementation libs.robolectric + testImplementation libs.bundles.mockito + testImplementation libs.mockwebserver + testImplementation libs.androidx.core.testing + testImplementation libs.kotlinx.coroutines.test + testImplementation libs.androidx.work.testing - testImplementation "androidx.test.ext:junit:1.1.3" - testImplementation "org.robolectric:robolectric:4.4" - testImplementation "org.mockito:mockito-inline:4.4.0" - testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" + androidTestImplementation libs.espresso.core + androidTestImplementation libs.androidx.room.testing + androidTestImplementation libs.androidx.test.junit - testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion" - - androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" - androidTestImplementation "androidx.room:room-testing:$roomVersion" - androidTestImplementation "androidx.test.ext:junit:1.1.3" - testImplementation "androidx.arch.core:core-testing:2.1.0" - - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - - implementation 'net.accelf:easter:1.0.2' - implementation 'org.jsoup:jsoup:1.13.1' + implementation libs.accelfeaster + implementation libs.jsoup } diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/40.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/40.json new file mode 100644 index 000000000..54d5a2bf8 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/40.json @@ -0,0 +1,929 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "0423fb3f7d09db5f12023f2f4e7297b5", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0423fb3f7d09db5f12023f2f4e7297b5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/41.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/41.json new file mode 100644 index 000000000..2bc6256f1 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/41.json @@ -0,0 +1,935 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "1de8f20c7f28e1f11b33e7a55137feef", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `scheduledAt` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1de8f20c7f28e1f11b33e7a55137feef')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/42.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/42.json new file mode 100644 index 000000000..84d58e108 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/42.json @@ -0,0 +1,959 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "2a851b591f39b1dfe9bb0eb62cf49ef3", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `quote` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2a851b591f39b1dfe9bb0eb62cf49ef3')" + ] + } +} \ No newline at end of file diff --git a/app/src/green/res/drawable/ic_launcher_background.xml b/app/src/green/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 7fef14448..000000000 --- a/app/src/green/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/green/res/values/flavor-colors.xml b/app/src/green/res/values/flavor-colors.xml index e1f58f2ea..a5120eade 100644 --- a/app/src/green/res/values/flavor-colors.xml +++ b/app/src/green/res/values/flavor-colors.xml @@ -3,4 +3,6 @@ #19A341 + #097b44 + #39ff9e \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0a52dde7c..a36513400 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,12 +5,10 @@ + - + android:theme="@style/TuskyBaseTheme" + android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> @@ -174,7 +173,7 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index d34dd6df8..eaf2db8b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -132,7 +132,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { - onBackPressed(); + getOnBackPressedDispatcher().onBackPressed(); return true; } return super.onOptionsItemSelected(item); diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 0e029d952..628d91ab4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -27,6 +27,7 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider import autodispose2.autoDispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.openLink import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers @@ -35,8 +36,8 @@ import java.net.URISyntaxException import javax.inject.Inject /** this is the base class for all activities that open links - * links are checked against the api if they are mastodon links so they can be openend in Tusky - * Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierachy + * links are checked against the api if they are mastodon links so they can be opened in Tusky + * Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierarchy */ abstract class BottomSheetActivity : BaseActivity() { diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index ea47fe139..6071a6479 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -28,6 +28,7 @@ import android.widget.ImageView import androidx.activity.viewModels import androidx.core.view.isVisible import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -37,6 +38,7 @@ import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -50,6 +52,7 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.launch import javax.inject.Inject class EditProfileActivity : BaseActivity(), Injectable { @@ -58,8 +61,6 @@ class EditProfileActivity : BaseActivity(), Injectable { const val AVATAR_SIZE = 400 const val HEADER_WIDTH = 1500 const val HEADER_HEIGHT = 500 - - private const val MAX_ACCOUNT_FIELDS = 4 } @Inject @@ -71,6 +72,8 @@ class EditProfileActivity : BaseActivity(), Injectable { private val accountFieldEditAdapter = AccountFieldEditAdapter() + private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS + private enum class PickType { AVATAR, HEADER @@ -112,7 +115,7 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.addFieldButton.setOnClickListener { accountFieldEditAdapter.addField() - if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) { + if (accountFieldEditAdapter.itemCount >= maxAccountFields) { it.isVisible = false } @@ -134,7 +137,8 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.lockedCheckBox.isChecked = me.locked accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) - binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS + binding.addFieldButton.isVisible = + (me.source?.fields?.size ?: 0) < maxAccountFields if (viewModel.avatarData.value == null) { Glide.with(this) @@ -165,13 +169,12 @@ class EditProfileActivity : BaseActivity(), Injectable { } } - viewModel.obtainInstance() - viewModel.instanceData.observe(this) { result -> - if (result is Success) { - val instance = result.data - if (instance?.maxBioChars != null && instance.maxBioChars > 0) { - binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars - } + lifecycleScope.launch { + viewModel.instanceData.collect { instanceInfo -> + maxAccountFields = instanceInfo.maxFields + accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength) + binding.addFieldButton.isVisible = + accountFieldEditAdapter.itemCount < maxAccountFields } } diff --git a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt index d6de5d8ec..3a61df90f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/FiltersActivity.kt @@ -1,26 +1,25 @@ package com.keylesspalace.tusky import android.os.Bundle +import android.text.format.DateUtils import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityFiltersBinding -import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.view.getSecondsForDurationIndex +import com.keylesspalace.tusky.view.setupEditDialogForFilter +import com.keylesspalace.tusky.view.showAddFilterDialog import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await -import okhttp3.ResponseBody -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import java.io.IOException import javax.inject.Inject @@ -47,7 +46,7 @@ class FiltersActivity : BaseActivity() { setDisplayShowHomeEnabled(true) } binding.addFilterButton.setOnClickListener { - showAddFilterDialog() + showAddFilterDialog(this) } title = intent?.getStringExtra(FILTERS_TITLE) @@ -55,15 +54,10 @@ class FiltersActivity : BaseActivity() { loadFilters() } - private fun updateFilter(filter: Filter, itemIndex: Int) { - api.updateFilter(filter.id, filter.phrase, filter.context, filter.irreversible, filter.wholeWord, filter.expiresAt) - .enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Toast.makeText(this@FiltersActivity, "Error updating filter '${filter.phrase}'", Toast.LENGTH_SHORT).show() - } - - override fun onResponse(call: Call, response: Response) { - val updatedFilter = response.body()!! + fun updateFilter(id: String, phrase: String, filterContext: List, irreversible: Boolean, wholeWord: Boolean, expiresInSeconds: Int?, itemIndex: Int) { + lifecycleScope.launch { + api.updateFilter(id, phrase, filterContext, irreversible, wholeWord, expiresInSeconds).fold( + { updatedFilter -> if (updatedFilter.context.contains(context)) { filters[itemIndex] = updatedFilter } else { @@ -71,25 +65,30 @@ class FiltersActivity : BaseActivity() { } refreshFilterDisplay() eventHub.dispatch(PreferenceChangedEvent(context)) + }, + { + Toast.makeText(this@FiltersActivity, "Error updating filter '$phrase'", Toast.LENGTH_SHORT).show() } - }) + ) + } } - private fun deleteFilter(itemIndex: Int) { + fun deleteFilter(itemIndex: Int) { val filter = filters[itemIndex] if (filter.context.size == 1) { - // This is the only context for this filter; delete it - api.deleteFilter(filters[itemIndex].id).enqueue(object : Callback { - override fun onFailure(call: Call, t: Throwable) { - Toast.makeText(this@FiltersActivity, "Error updating filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() - } - - override fun onResponse(call: Call, response: Response) { - filters.removeAt(itemIndex) - refreshFilterDisplay() - eventHub.dispatch(PreferenceChangedEvent(context)) - } - }) + lifecycleScope.launch { + // This is the only context for this filter; delete it + api.deleteFilter(filters[itemIndex].id).fold( + { + filters.removeAt(itemIndex) + refreshFilterDisplay() + eventHub.dispatch(PreferenceChangedEvent(context)) + }, + { + Toast.makeText(this@FiltersActivity, "Error deleting filter '${filters[itemIndex].phrase}'", Toast.LENGTH_SHORT).show() + } + ) + } } else { // Keep the filter, but remove it from this context val oldFilter = filters[itemIndex] @@ -97,69 +96,50 @@ class FiltersActivity : BaseActivity() { oldFilter.id, oldFilter.phrase, oldFilter.context.filter { c -> c != context }, oldFilter.expiresAt, oldFilter.irreversible, oldFilter.wholeWord ) - updateFilter(newFilter, itemIndex) + updateFilter( + newFilter.id, newFilter.phrase, newFilter.context, newFilter.irreversible, newFilter.wholeWord, + getSecondsForDurationIndex(-1, this, oldFilter.expiresAt), itemIndex + ) } } - private fun createFilter(phrase: String, wholeWord: Boolean) { - api.createFilter(phrase, listOf(context), false, wholeWord, "").enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - val filterResponse = response.body() - if (response.isSuccessful && filterResponse != null) { - filters.add(filterResponse) + fun createFilter(phrase: String, wholeWord: Boolean, expiresInSeconds: Int? = null) { + lifecycleScope.launch { + api.createFilter(phrase, listOf(context), false, wholeWord, expiresInSeconds).fold( + { filter -> + filters.add(filter) refreshFilterDisplay() eventHub.dispatch(PreferenceChangedEvent(context)) - } else { + }, + { Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() } - } - - override fun onFailure(call: Call, t: Throwable) { - Toast.makeText(this@FiltersActivity, "Error creating filter '$phrase'", Toast.LENGTH_SHORT).show() - } - }) - } - - private fun showAddFilterDialog() { - val binding = DialogFilterBinding.inflate(layoutInflater) - binding.phraseWholeWord.isChecked = true - AlertDialog.Builder(this@FiltersActivity) - .setTitle(R.string.filter_addition_dialog_title) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - createFilter(binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked) - } - .setNeutralButton(android.R.string.cancel, null) - .show() - } - - private fun setupEditDialogForItem(itemIndex: Int) { - val binding = DialogFilterBinding.inflate(layoutInflater) - val filter = filters[itemIndex] - binding.phraseEditText.setText(filter.phrase) - binding.phraseWholeWord.isChecked = filter.wholeWord - - AlertDialog.Builder(this@FiltersActivity) - .setTitle(R.string.filter_edit_dialog_title) - .setView(binding.root) - .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> - val oldFilter = filters[itemIndex] - val newFilter = Filter( - oldFilter.id, binding.phraseEditText.text.toString(), oldFilter.context, - oldFilter.expiresAt, oldFilter.irreversible, binding.phraseWholeWord.isChecked - ) - updateFilter(newFilter, itemIndex) - } - .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> - deleteFilter(itemIndex) - } - .setNeutralButton(android.R.string.cancel, null) - .show() + ) + } } private fun refreshFilterDisplay() { - binding.filtersView.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, filters.map { filter -> filter.phrase }) - binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForItem(position) } + binding.filtersView.adapter = ArrayAdapter( + this, + android.R.layout.simple_list_item_1, + filters.map { filter -> + if (filter.expiresAt == null) { + filter.phrase + } else { + getString( + R.string.filter_expiration_format, + filter.phrase, + DateUtils.getRelativeTimeSpanString( + filter.expiresAt.time, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + ) + } + } + ) + binding.filtersView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> setupEditDialogForFilter(this, filters[position], position) } } private fun loadFilters() { diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index a9131417c..54fba19aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -20,7 +20,7 @@ import android.util.Log import android.widget.TextView import androidx.annotation.RawRes import com.keylesspalace.tusky.databinding.ActivityLicenseBinding -import com.keylesspalace.tusky.util.IOUtils +import com.keylesspalace.tusky.util.closeQuietly import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -61,7 +61,7 @@ class LicenseActivity : BaseActivity() { Log.w("LicenseActivity", e) } - IOUtils.closeQuietly(br) + br.closeQuietly() textView.text = sb.toString() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index ce4d23d16..5a12a888e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.content.pm.PackageManager import android.content.res.ColorStateList import android.graphics.Bitmap import android.graphics.Color @@ -38,10 +40,12 @@ import android.view.View import android.view.WindowManager import android.widget.ImageView import androidx.activity.viewModels +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat @@ -85,6 +89,7 @@ import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity @@ -203,6 +208,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje if (accountRequested && accountId != activeAccount.id) { accountManager.setActiveAccount(accountId) } + + val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) + if (canHandleMimeType(intent.type)) { // Sharing to Tusky from an external app if (accountRequested) { @@ -227,9 +235,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } ) } + } else if (openDrafts) { + val intent = DraftsActivity.newIntent(this) + startActivity(intent) } else if (accountRequested && savedInstanceState == null) { - // user clicked a notification, show notification tab - showNotificationTab = true + // user clicked a notification, show follow requests for type FOLLOW_REQUEST, + // otherwise show notification tab + if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) { + val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true) + startActivityWithSlideInAnimation(intent) + } else { + showNotificationTab = true + } } } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own @@ -292,6 +309,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when { + binding.mainDrawerLayout.isOpen -> { + binding.mainDrawerLayout.close() + } + binding.viewPager.currentItem != 0 -> { + binding.viewPager.currentItem = 0 + } + else -> { + finish() + } + } + } + } + ) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } } override fun onPause() { @@ -337,20 +381,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } - override fun onBackPressed() { - when { - binding.mainDrawerLayout.isOpen -> { - binding.mainDrawerLayout.close() - } - binding.viewPager.currentItem != 0 -> { - binding.viewPager.currentItem = 0 - } - else -> { - super.onBackPressed() - } - } - } - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { when (keyCode) { KeyEvent.KEYCODE_MENU -> { @@ -426,7 +456,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje closeDrawerOnProfileListClick = true } - header.accountHeaderBackground.setColorFilter(ContextCompat.getColor(this, R.color.headerBackgroundFilter)) + header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent)) val animateAvatars = preferences.getBoolean("animateGifAvatars", false) @@ -606,7 +636,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun tintCheckIcon(item: MenuItem) { if (item.isChecked) { @Suppress("DEPRECATION") - item.icon.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN) + item.icon?.setColorFilter(ContextCompat.getColor(this, R.color.tusky_green_light), PorterDuff.Mode.SRC_IN) } else { ThemeUtils.setDrawableTint(this, item.icon, android.R.attr.textColorTertiary) } @@ -1000,6 +1030,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.clear() header.profiles = profiles header.setActiveProfile(accountManager.activeAccount!!.id) + binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername(this)) { + accountManager.activeAccount!!.fullName + } else null } override fun getActionButton() = binding.composeButton @@ -1011,6 +1044,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 const val REDIRECT_URL = "redirectUrl" + const val OPEN_DRAFTS = "draft" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index 99a144fc6..6f669f8d1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -18,17 +18,25 @@ package com.keylesspalace.tusky import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuItem import androidx.activity.viewModels import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.Lifecycle import autodispose2.androidx.lifecycle.autoDispose import com.keylesspalace.tusky.appstore.EventHub +import at.connyduck.calladapter.networkresult.fold +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import kotlinx.coroutines.launch import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import net.accelf.yuito.QuickTootViewModel import javax.inject.Inject @@ -44,16 +52,21 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private val quickTootViewModel: QuickTootViewModel by viewModels{ viewModelFactory } + private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) + private lateinit var kind: Kind + private var hashtag: String? = null + private var followTagItem: MenuItem? = null + private var unfollowTagItem: MenuItem? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityStatuslistBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) - val kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) + kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) val listId = intent.getStringExtra(EXTRA_LIST_ID) - val hashtag = intent.getStringExtra(EXTRA_HASHTAG) + hashtag = intent.getStringExtra(EXTRA_HASHTAG) val title = when (kind) { Kind.FAVOURITES -> getString(R.string.title_favourites) @@ -88,6 +101,70 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { binding.floatingBtn.setOnClickListener(binding.viewQuickToot::onFABClicked) } + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val tag = hashtag + if (kind == Kind.TAG && tag != null) { + lifecycleScope.launch { + mastodonApi.tag(tag).fold( + { tagEntity -> + menuInflater.inflate(R.menu.view_hashtag_toolbar, menu) + followTagItem = menu.findItem(R.id.action_follow_hashtag) + unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag) + followTagItem?.isVisible = tagEntity.following == false + unfollowTagItem?.isVisible = tagEntity.following == true + followTagItem?.setOnMenuItemClickListener { followTag() } + unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() } + }, + { + Log.w(TAG, "Failed to query tag #$tag", it) + } + ) + } + } + + return super.onCreateOptionsMenu(menu) + } + + private fun followTag(): Boolean { + val tag = hashtag + if (tag != null) { + lifecycleScope.launch { + mastodonApi.followTag(tag).fold( + { + followTagItem?.isVisible = false + unfollowTagItem?.isVisible = true + }, + { + Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to follow #$tag", it) + } + ) + } + } + + return true + } + + private fun unfollowTag(): Boolean { + val tag = hashtag + if (tag != null) { + lifecycleScope.launch { + mastodonApi.unfollowTag(tag).fold( + { + followTagItem?.isVisible = true + unfollowTagItem?.isVisible = false + }, + { + Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Log.e(TAG, "Failed to unfollow #$tag", it) + } + ) + } + } + + return true + } + override fun androidInjector() = dispatchingAndroidInjector companion object { @@ -96,6 +173,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { private const val EXTRA_LIST_ID = "id" private const val EXTRA_LIST_TITLE = "title" private const val EXTRA_HASHTAG = "tag" + const val TAG = "StatusListActivity" fun newFavouritesIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 5e9eac69b..79b9f7ec9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -20,9 +20,9 @@ import android.os.Bundle import android.util.Log import android.view.View import android.widget.FrameLayout +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatEditText -import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -74,6 +74,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } + private val onFabDismissedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + toggleFab(false) + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -149,6 +155,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT) updateAvailableTabs() + + onBackPressedDispatcher.addCallback(onFabDismissedCallback) } override fun onTabAdded(tab: TabData) { @@ -209,6 +217,8 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene binding.actionButton.visible(!expand) binding.sheet.visible(expand) binding.scrim.visible(expand) + + onFabDismissedCallback.isEnabled = expand } private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { @@ -338,14 +348,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene tabsChanged = true } - override fun onBackPressed() { - if (binding.actionButton.isVisible) { - super.onBackPressed() - } else { - toggleFab(false) - } - } - override fun onPause() { super.onPause() if (tabsChanged) { diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index fda2c82b6..2a96cb7fe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -47,6 +47,7 @@ import autodispose2.autoDispose import com.bumptech.glide.Glide import com.bumptech.glide.request.FutureTarget import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID +import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.fragment.ViewImageFragment diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java deleted file mode 100644 index e45b783a8..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java +++ /dev/null @@ -1,130 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky; - -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.fragment.app.FragmentTransaction; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.widget.Toolbar; -import android.view.Menu; -import android.view.MenuItem; - -import com.keylesspalace.tusky.fragment.ViewThreadFragment; -import com.keylesspalace.tusky.util.LinkHelper; - -import javax.inject.Inject; - -import dagger.android.AndroidInjector; -import dagger.android.DispatchingAndroidInjector; -import dagger.android.HasAndroidInjector; - -public class ViewThreadActivity extends BottomSheetActivity implements HasAndroidInjector { - - public static final int REVEAL_BUTTON_HIDDEN = 1; - public static final int REVEAL_BUTTON_REVEAL = 2; - public static final int REVEAL_BUTTON_HIDE = 3; - - public static Intent startIntent(Context context, String id, String url) { - Intent intent = new Intent(context, ViewThreadActivity.class); - intent.putExtra(ID_EXTRA, id); - intent.putExtra(URL_EXTRA, url); - return intent; - } - - private static final String ID_EXTRA = "id"; - private static final String URL_EXTRA = "url"; - private static final String FRAGMENT_TAG = "ViewThreadFragment_"; - - private int revealButtonState = REVEAL_BUTTON_HIDDEN; - - @Inject - public DispatchingAndroidInjector dispatchingAndroidInjector; - - private ViewThreadFragment fragment; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_view_thread); - - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setTitle(R.string.title_view_thread); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setDisplayShowHomeEnabled(true); - } - - String id = getIntent().getStringExtra(ID_EXTRA); - - fragment = (ViewThreadFragment)getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG + id); - if(fragment == null) { - fragment = ViewThreadFragment.newInstance(id); - } - - FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); - fragmentTransaction.replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id); - fragmentTransaction.commit(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.view_thread_toolbar, menu); - MenuItem menuItem = menu.findItem(R.id.action_reveal); - menuItem.setVisible(revealButtonState != REVEAL_BUTTON_HIDDEN); - menuItem.setIcon(revealButtonState == REVEAL_BUTTON_REVEAL ? - R.drawable.ic_eye_24dp : R.drawable.ic_hide_media_24dp); - return super.onCreateOptionsMenu(menu); - } - - public void setRevealButtonState(int state) { - switch (state) { - case REVEAL_BUTTON_HIDDEN: - case REVEAL_BUTTON_REVEAL: - case REVEAL_BUTTON_HIDE: - this.revealButtonState = state; - invalidateOptionsMenu(); - break; - default: - throw new IllegalArgumentException("Invalid reveal button state: " + state); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_open_in_web: { - openLink(getIntent().getStringExtra(URL_EXTRA)); - return true; - } - case R.id.action_reveal: { - fragment.onRevealPressed(); - return true; - } - } - return super.onOptionsItemSelected(item); - } - - @Override - public AndroidInjector androidInjector() { - return dispatchingAndroidInjector; - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index 7ba5537b8..30cf63097 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -27,6 +27,8 @@ import com.keylesspalace.tusky.util.BindingHolder class AccountFieldEditAdapter : RecyclerView.Adapter>() { private val fieldData = mutableListOf() + private var maxNameLength: Int? = null + private var maxValueLength: Int? = null fun setFields(fields: List) { fieldData.clear() @@ -41,6 +43,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter { return fieldData.map { StringField(it.first, it.second) @@ -60,10 +68,20 @@ class AccountFieldEditAdapter : RecyclerView.Adapter, position: Int) { - holder.binding.accountFieldName.setText(fieldData[position].first) - holder.binding.accountFieldValue.setText(fieldData[position].second) + holder.binding.accountFieldNameText.setText(fieldData[position].first) + holder.binding.accountFieldValueText.setText(fieldData[position].second) - holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher { + holder.binding.accountFieldNameTextLayout.isCounterEnabled = maxNameLength != null + maxNameLength?.let { + holder.binding.accountFieldNameTextLayout.counterMaxLength = it + } + + holder.binding.accountFieldValueTextLayout.isCounterEnabled = maxValueLength != null + maxValueLength?.let { + holder.binding.accountFieldValueTextLayout.counterMaxLength = it + } + + holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(newText: Editable) { fieldData[holder.bindingAdapterPosition].first = newText.toString() } @@ -73,7 +91,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter. */ + +package com.keylesspalace.tusky.adapter + +import android.content.Context +import android.graphics.Typeface +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import com.keylesspalace.tusky.util.ThemeUtils +import java.util.Locale + +class LocaleAdapter(context: Context, resource: Int, locales: List) : ArrayAdapter(context, resource, locales) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return (super.getView(position, convertView, parent) as TextView).apply { + setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) + typeface = Typeface.DEFAULT_BOLD + text = super.getItem(position)?.language?.uppercase() + } + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return (super.getDropDownView(position, convertView, parent) as TextView).apply { + setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary)) + val locale = super.getItem(position) + text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})" + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 9aacfdc77..b75ac7bf7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -178,12 +178,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } return; } - NotificationViewData.Concrete concreteNotificaton = + NotificationViewData.Concrete concreteNotification = (NotificationViewData.Concrete) notification; switch (viewHolder.getItemViewType()) { case VIEW_TYPE_STATUS: { StatusViewHolder holder = (StatusViewHolder) viewHolder; - StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); + StatusViewData.Concrete status = concreteNotification.getStatusViewData(); if (status == null) { /* in some very rare cases servers sends null status even though they should not, * we have to handle it somehow */ @@ -194,8 +194,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); } - if (concreteNotificaton.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); + if (concreteNotification.getType() == Notification.Type.POLL) { + holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); } else { holder.hideStatusInfo(); } @@ -203,7 +203,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } case VIEW_TYPE_STATUS_NOTIFICATION: { StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); + StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); if (payloadForHolder == null) { if (statusViewData == null) { /* in some very rare cases servers sends null status even though they should not, @@ -217,19 +217,19 @@ public class NotificationsAdapter extends RecyclerView.Adapter { holder.setUsername(status.getAccount().getUsername()); holder.setCreatedAt(status.getCreatedAt()); - if (concreteNotificaton.getType() == Notification.Type.STATUS || - concreteNotificaton.getType() == Notification.Type.UPDATE) { + if (concreteNotification.getType() == Notification.Type.STATUS || + concreteNotification.getType() == Notification.Type.UPDATE) { holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); } else { holder.setAvatars(status.getAccount().getAvatar(), - concreteNotificaton.getAccount().getAvatar()); + concreteNotification.getAccount().getAvatar()); } } - holder.setMessage(concreteNotificaton, statusListener); + holder.setMessage(concreteNotification, statusListener); holder.setupButtons(notificationActionListener, - concreteNotificaton.getAccount().getId(), - concreteNotificaton.getId()); + concreteNotification.getAccount().getId(), + concreteNotification.getId()); } else { if (payloadForHolder instanceof List) for (Object item : (List) payloadForHolder) { @@ -243,16 +243,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_FOLLOW: { if (payloadForHolder == null) { FollowViewHolder holder = (FollowViewHolder) viewHolder; - holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP); - holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); + holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); + holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); } break; } case VIEW_TYPE_FOLLOW_REQUEST: { if (payloadForHolder == null) { FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; - holder.setupWithAccount(concreteNotificaton.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(accountActionListener, concreteNotificaton.getAccount().getId()); + holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); + holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); } break; } @@ -499,7 +499,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { Drawable icon = ContextCompat.getDrawable(context, drawable); if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP); + icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); } return icon; } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index ef366795f..6d70d0e1c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding @@ -97,7 +96,7 @@ class PollAdapter : RecyclerView.Adapter>() { } resultTextView.background.level = level - resultTextView.background.setTint(ContextCompat.getColor(resultTextView.context, optionColor)) + resultTextView.background.setTint(resultTextView.context.getColor(optionColor)) resultTextView.setOnClickListener(resultClickListener) } SINGLE -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 74782e194..9d77b7bae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -29,10 +29,10 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.resource.bitmap.CenterCrop; -import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.google.android.material.button.MaterialButton; +import com.google.android.material.imageview.ShapeableImageView; +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.ShapeAppearanceModel; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding; @@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; +import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -58,8 +59,6 @@ import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; -import net.accelf.yuito.QuoteInlineHelper; - import java.text.NumberFormat; import java.util.Date; import java.util.List; @@ -70,6 +69,8 @@ import kotlin.collections.CollectionsKt; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; +import net.accelf.yuito.QuoteInlineHelper; + public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static class Key { public static final String KEY_CREATED = "created"; @@ -106,7 +107,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private LinearLayout cardView; private LinearLayout cardInfo; - private ImageView cardImage; + private ShapeableImageView cardImage; private TextView cardTitle; private TextView cardDescription; private TextView cardUrl; @@ -644,7 +645,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (i < attachments.size()) { Attachment attachment = attachments.get(i); mediaLabel.setVisibility(View.VISIBLE); - mediaDescriptions[i] = getAttachmentDescription(context, attachment); + mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context); updateMediaLabel(i, sensitive, showingContent); // Set the icon next to the label. @@ -671,24 +672,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } }); view.setOnLongClickListener(v -> { - CharSequence description = getAttachmentDescription(view.getContext(), attachment); + CharSequence description = AttachmentHelper.getFormattedDescription(attachment, view.getContext()); Toast.makeText(view.getContext(), description, Toast.LENGTH_LONG).show(); return true; }); } - private static CharSequence getAttachmentDescription(Context context, Attachment attachment) { - String duration = ""; - if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) { - duration = formatDuration(attachment.getMeta().getDuration()) + " "; - } - if (TextUtils.isEmpty(attachment.getDescription())) { - return duration + context.getString(R.string.description_post_media_no_description_placeholder); - } else { - return duration + attachment.getDescription(); - } - } - protected void hideSensitiveMediaWarning() { sensitiveMediaWarning.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.GONE); @@ -721,7 +710,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { replyButton.setClickable(!isNotestock); if (reblogButton != null) { reblogButton.setEventListener((button, buttonState) -> { - // return true to play animaion + // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmReblogs()) { @@ -738,7 +727,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } favouriteButton.setEventListener((button, buttonState) -> { - // return true to play animaion + // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (statusDisplayOptions.confirmFavourites()) { @@ -817,9 +806,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private void showConfirmFavouriteDialog(StatusActionListener listener, - String statusContent, - boolean buttonState, - int position) { + String statusContent, + boolean buttonState, + int position) { int okButtonTextId = buttonState ? R.string.action_unfavourite : R.string.action_favourite; new AlertDialog.Builder(favouriteButton.getContext()) .setMessage(statusContent) @@ -994,16 +983,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { int resource; switch (visibility) { case PUBLIC: - resource = R.string.description_visiblity_public; + resource = R.string.description_visibility_public; break; case UNLISTED: - resource = R.string.description_visiblity_unlisted; + resource = R.string.description_visibility_unlisted; break; case PRIVATE: - resource = R.string.description_visiblity_private; + resource = R.string.description_visibility_private; break; case DIRECT: - resource = R.string.description_visiblity_direct; + resource = R.string.description_visibility_direct; break; default: return ""; @@ -1178,13 +1167,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { // If media previews are disabled, show placeholder for cards as well if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { - int topLeftRadius = 0; - int topRightRadius = 0; - int bottomRightRadius = 0; - int bottomLeftRadius = 0; - int radius = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_radius); + ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); if (card.getWidth() > card.getHeight()) { cardView.setOrientation(LinearLayout.VERTICAL); @@ -1194,8 +1179,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; - topLeftRadius = radius; - topRightRadius = radius; + cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); + cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius); } else { cardView.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; @@ -1203,19 +1188,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - topLeftRadius = radius; - bottomLeftRadius = radius; + cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); + cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius); } - RequestBuilder builder = Glide.with(cardImage).load(card.getImage()); + cardImage.setShapeAppearanceModel(cardImageShape.build()); + + cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + + RequestBuilder builder = Glide.with(cardImage.getContext()) + .load(card.getImage()) + .dontTransform(); if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); } - builder.transform( - new CenterCrop(), - new GranularRoundedCorners(topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius) - ) - .into(cardImage); + builder.into(cardImage); } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { int radius = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_radius); @@ -1226,11 +1213,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - Glide.with(cardImage).load(decodeBlurHash(card.getBlurhash())) - .transform( - new CenterCrop(), - new GranularRoundedCorners(radius, 0, 0, radius) - ) + + ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() + .setTopLeftCorner(CornerFamily.ROUNDED, radius) + .setBottomLeftCorner(CornerFamily.ROUNDED, radius) + .build(); + cardImage.setShapeAppearanceModel(cardImageShape); + + cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); + + Glide.with(cardImage.getContext()) + .load(decodeBlurHash(card.getBlurhash())) + .dontTransform() .into(cardImage); } else { cardView.setOrientation(LinearLayout.HORIZONTAL); @@ -1239,16 +1233,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; - cardImage.setImageResource(R.drawable.card_image_placeholder); + + cardImage.setShapeAppearanceModel(new ShapeAppearanceModel()); + + cardImage.setScaleType(ImageView.ScaleType.CENTER); + + Glide.with(cardImage.getContext()) + .load(ContextCompat.getDrawable(cardImage.getContext(), R.drawable.card_image_placeholder)) + .into(cardImage); } View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl(), ""); - View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url())); - cardInfo.setOnClickListener(visitLink); + cardView.setOnClickListener(visitLink); // View embedded photos in our image viewer instead of opening the browser - cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbed_url()) ? - openImage : + cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? + v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : visitLink); cardView.setClipToOutline(true); @@ -1278,13 +1278,4 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } - - private static String formatDuration(double durationInSeconds) { - int seconds = (int) Math.round(durationInSeconds) % 60; - int minutes = (int) durationInSeconds % 3600 / 60; - int hours = (int) durationInSeconds / 3600; - - return String.format("%d:%02d:%02d", hours, minutes, seconds); - } - } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 89dfbe986..398ec2ccf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,8 +1,6 @@ package com.keylesspalace.tusky.adapter; import android.content.Context; -import android.content.Intent; -import android.graphics.drawable.Drawable; import android.text.method.LinkMovementMethod; import android.view.View; import android.widget.TextView; @@ -12,7 +10,6 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.ViewThreadActivity; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CardViewMode; @@ -22,15 +19,13 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -class StatusDetailedViewHolder extends StatusBaseViewHolder { - private TextView reblogs; - private TextView favourites; - private View infoDivider; +public class StatusDetailedViewHolder extends StatusBaseViewHolder { + private final TextView reblogs; + private final TextView favourites; + private final View infoDivider; - StatusDetailedViewHolder(View view) { + public StatusDetailedViewHolder(View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt deleted file mode 100644 index 8abbbd5f6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ThreadAdapter.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ -package com.keylesspalace.tusky.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.viewdata.StatusViewData - -class ThreadAdapter( - private val statusDisplayOptions: StatusDisplayOptions, - private val statusActionListener: StatusActionListener -) : RecyclerView.Adapter() { - private val statuses = mutableListOf() - var detailedStatusPosition: Int = RecyclerView.NO_POSITION - private set - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { - return when (viewType) { - VIEW_TYPE_STATUS -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status, parent, false) - StatusViewHolder(view) - } - VIEW_TYPE_STATUS_DETAILED -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_status_detailed, parent, false) - StatusDetailedViewHolder(view) - } - else -> error("Unknown item type: $viewType") - } - } - - override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { - val status = statuses[position] - viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) - } - - override fun getItemViewType(position: Int): Int { - return if (position == detailedStatusPosition) { - VIEW_TYPE_STATUS_DETAILED - } else { - VIEW_TYPE_STATUS - } - } - - override fun getItemCount(): Int = statuses.size - - fun setStatuses(statuses: List?) { - this.statuses.clear() - this.statuses.addAll(statuses!!) - notifyDataSetChanged() - } - - fun addItem(position: Int, statusViewData: StatusViewData.Concrete) { - statuses.add(position, statusViewData) - notifyItemInserted(position) - } - - fun clearItems() { - val oldSize = statuses.size - statuses.clear() - detailedStatusPosition = RecyclerView.NO_POSITION - notifyItemRangeRemoved(0, oldSize) - } - - fun addAll(position: Int, statuses: List) { - this.statuses.addAll(position, statuses) - notifyItemRangeInserted(position, statuses.size) - } - - fun addAll(statuses: List) { - val end = statuses.size - this.statuses.addAll(statuses) - notifyItemRangeInserted(end, statuses.size) - } - - fun removeItem(position: Int) { - statuses.removeAt(position) - notifyItemRemoved(position) - } - - fun clear() { - statuses.clear() - detailedStatusPosition = RecyclerView.NO_POSITION - notifyDataSetChanged() - } - - fun setItem(position: Int, status: StatusViewData.Concrete, notifyAdapter: Boolean) { - statuses[position] = status - if (notifyAdapter) { - notifyItemChanged(position) - } - } - - fun getItem(position: Int): StatusViewData.Concrete? = statuses.getOrNull(position) - - fun setDetailedStatusPosition(position: Int) { - if (position != detailedStatusPosition && - detailedStatusPosition != RecyclerView.NO_POSITION - ) { - val prior = detailedStatusPosition - detailedStatusPosition = position - notifyItemChanged(prior) - } else { - detailedStatusPosition = position - } - } - - companion object { - private const val VIEW_TYPE_STATUS = 0 - private const val VIEW_TYPE_STATUS_DETAILED = 1 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index da35d7d61..30ebf436e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -31,7 +31,6 @@ import androidx.annotation.ColorInt import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityOptionsCompat -import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -176,7 +175,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI */ private fun loadResources() { toolbarColor = ThemeUtils.getColor(this, R.attr.colorSurface) - statusBarColorTransparent = ContextCompat.getColor(this, R.color.transparent_statusbar_background) + statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index 760db8294..baeeea43f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -35,7 +35,7 @@ class AccountPagerAdapter( 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) - 3 -> AccountMediaFragment.newInstance(accountId, false) + 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 57876d852..69d651d51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright 2022 Tusky Contributors * * This file is a part of Tusky. * @@ -15,41 +15,35 @@ package com.keylesspalace.tusky.components.account.media -import android.graphics.Color import android.os.Bundle import android.util.Log import android.view.View -import android.view.ViewGroup -import android.widget.ImageView import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import autodispose2.androidx.lifecycle.autoDispose -import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.RefreshableFragment -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.SquareImageView +import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.SingleObserver -import io.reactivex.rxjava3.disposables.Disposable -import retrofit2.Response +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import java.io.IOException -import java.util.Random import javax.inject.Inject /** @@ -58,192 +52,98 @@ import javax.inject.Inject * Fragment with multiple columns of media previews for the specified account. */ -class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, Injectable { +class AccountMediaFragment : + Fragment(R.layout.fragment_timeline), + RefreshableFragment, + Injectable { @Inject - lateinit var api: MastodonApi + lateinit var viewModelFactory: ViewModelFactory + + @Inject + lateinit var accountManager: AccountManager private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var accountId: String + private val viewModel: AccountMediaViewModel by viewModels { viewModelFactory } - private val adapter = MediaGridAdapter() - private val statuses = mutableListOf() - private var fetchingStatus = FetchingStatus.NOT_FETCHING - - private var isSwipeToRefreshEnabled: Boolean = true - private var needToRefresh = false - - private val callback = object : SingleObserver>> { - override fun onError(t: Throwable) { - fetchingStatus = FetchingStatus.NOT_FETCHING - - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.visibility = View.GONE - binding.topProgressBar.hide() - binding.statusView.show() - if (t is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { - doInitialLoadingIfNeeded() - } - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { - doInitialLoadingIfNeeded() - } - } - } - - Log.d(TAG, "Failed to fetch account media", t) - } - - override fun onSuccess(response: Response>) { - fetchingStatus = FetchingStatus.NOT_FETCHING - if (isAdded) { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.visibility = View.GONE - binding.topProgressBar.hide() - - val body = response.body() - body?.let { fetched -> - statuses.addAll(0, fetched) - // flatMap requires iterable but I don't want to box each array into list - val result = mutableListOf() - for (status in fetched) { - result.addAll(AttachmentViewData.list(status)) - } - adapter.addTop(result) - if (result.isNotEmpty()) - binding.recyclerView.scrollToPosition(0) - - if (statuses.isEmpty()) { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) - } - } - } - } - - override fun onSubscribe(d: Disposable) {} - } - - private val bottomCallback = object : SingleObserver>> { - override fun onError(t: Throwable) { - fetchingStatus = FetchingStatus.NOT_FETCHING - - Log.d(TAG, "Failed to fetch account media", t) - } - - override fun onSuccess(response: Response>) { - fetchingStatus = FetchingStatus.NOT_FETCHING - val body = response.body() - body?.let { fetched -> - Log.d(TAG, "fetched ${fetched.size} statuses") - if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}") - statuses.addAll(fetched) - Log.d(TAG, "now there are ${statuses.size} statuses") - // flatMap requires iterable but I don't want to box each array into list - val result = mutableListOf() - for (status in fetched) { - result.addAll(AttachmentViewData.list(status)) - } - adapter.addBottom(result) - } - } - - override fun onSubscribe(d: Disposable) { } - } + private lateinit var adapter: AccountMediaGridAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) == true - accountId = arguments?.getString(ACCOUNT_ID_ARG)!! + viewModel.accountId = arguments?.getString(ACCOUNT_ID_ARG)!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + + val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) + val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) + + adapter = AccountMediaGridAdapter( + alwaysShowSensitiveMedia = alwaysShowSensitiveMedia, + useBlurhash = useBlurhash, + context = view.context, + onAttachmentClickListener = ::onAttachmentClick + ) val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) - val layoutManager = GridLayoutManager(view.context, columnCount) + val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing) - adapter.baseItemColor = ThemeUtils.getColor(view.context, android.R.attr.windowBackground) + binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0)) - binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount) binding.recyclerView.adapter = adapter - if (isSwipeToRefreshEnabled) { - binding.swipeRefreshLayout.setOnRefreshListener { - refresh() - } - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - } + binding.swipeRefreshLayout.isEnabled = false + binding.statusView.visibility = View.GONE - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - - override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) { - if (dy > 0) { - val itemCount = layoutManager.itemCount - val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() - if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { - statuses.lastOrNull()?.let { (id) -> - Log.d(TAG, "Requesting statuses with max_id: $id, (bottom)") - fetchingStatus = FetchingStatus.FETCHING_BOTTOM - api.accountStatuses(accountId, id, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(bottomCallback) - } - } - } + viewLifecycleOwner.lifecycleScope.launch { + viewModel.media.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + adapter.addLoadStateListener { loadState -> + binding.progressBar.visible(loadState.refresh == LoadState.Loading && adapter.itemCount == 0) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.statusView.show() + val errorState = loadState.refresh as LoadState.Error + if (errorState.error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { adapter.retry() } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { adapter.retry() } + } + Log.w(TAG, "error loading account media", errorState.error) + } else { + binding.recyclerView.show() + binding.statusView.hide() } - }) - - doInitialLoadingIfNeeded() - } - - private fun refresh() { - binding.statusView.hide() - if (fetchingStatus != FetchingStatus.NOT_FETCHING) return - if (statuses.isEmpty()) { - fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, null, true, null) - } else { - fetchingStatus = FetchingStatus.REFRESHING - api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) - }.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) - - if (!isSwipeToRefreshEnabled) - binding.topProgressBar.show() - } - - private fun doInitialLoadingIfNeeded() { - if (isAdded) { - binding.statusView.hide() } - if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { - fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, null, true, null) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY) - .subscribe(callback) - } else if (needToRefresh) - refresh() - needToRefresh = false } - private fun viewMedia(items: List, currentIndex: Int, view: View?) { + private fun onAttachmentClick(selected: AttachmentViewData, view: View) { + if (!selected.isRevealed) { + viewModel.revealAttachment(selected) + return + } + val attachmentsFromSameStatus = viewModel.attachmentData.filter { attachmentViewData -> + attachmentViewData.statusId == selected.statusId + } + val currentIndex = attachmentsFromSameStatus.indexOf(selected) - when (items[currentIndex].attachment.type) { + when (selected.attachment.type) { Attachment.Type.IMAGE, Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.AUDIO -> { - val intent = ViewMediaActivity.newIntent(context, items, currentIndex) - if (view != null && activity != null) { - val url = items[currentIndex].attachment.url + val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex) + if (activity != null) { + val url = selected.attachment.url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url) startActivity(intent, options.toBundle()) @@ -252,96 +152,26 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr } } Attachment.Type.UNKNOWN -> { - context?.openLink(items[currentIndex].attachment.url) - } - } - } - - private enum class FetchingStatus { - NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING - } - - inner class MediaGridAdapter : - RecyclerView.Adapter() { - - var baseItemColor = Color.BLACK - - private val items = mutableListOf() - private val itemBgBaseHSV = FloatArray(3) - private val random = Random() - - fun addTop(newItems: List) { - items.addAll(0, newItems) - notifyItemRangeInserted(0, newItems.size) - } - - fun addBottom(newItems: List) { - if (newItems.isEmpty()) return - - val oldLen = items.size - items.addAll(newItems) - notifyItemRangeInserted(oldLen, newItems.size) - } - - override fun onAttachedToRecyclerView(recycler_view: RecyclerView) { - val hsv = FloatArray(3) - Color.colorToHSV(baseItemColor, hsv) - super.onAttachedToRecyclerView(recycler_view) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { - val view = SquareImageView(parent.context) - view.scaleType = ImageView.ScaleType.CENTER_CROP - return MediaViewHolder(view) - } - - override fun getItemCount(): Int = items.size - - override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { - itemBgBaseHSV[2] = random.nextFloat() * (1f - 0.3f) + 0.3f - holder.imageView.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) - val item = items[position] - - Glide.with(holder.imageView) - .load(item.attachment.previewUrl) - .centerInside() - .into(holder.imageView) - } - - inner class MediaViewHolder(val imageView: ImageView) : - RecyclerView.ViewHolder(imageView), - View.OnClickListener { - init { - itemView.setOnClickListener(this) - } - - // saving some allocations - override fun onClick(v: View?) { - viewMedia(items, bindingAdapterPosition, imageView) + context?.openLink(selected.attachment.url) } } } override fun refreshContent() { - if (isAdded) - refresh() - else - needToRefresh = true + adapter.refresh() } companion object { - @JvmStatic - fun newInstance(accountId: String, enableSwipeToRefresh: Boolean = true): AccountMediaFragment { + + fun newInstance(accountId: String): AccountMediaFragment { val fragment = AccountMediaFragment() - val args = Bundle() + val args = Bundle(1) args.putString(ACCOUNT_ID_ARG, accountId) - args.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = args return fragment } private const val ACCOUNT_ID_ARG = "account_id" private const val TAG = "AccountMediaFragment" - private const val ARG_ENABLE_SWIPE_TO_REFRESH = "arg.enable.swipe.to.refresh" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt new file mode 100644 index 000000000..e5a0b592d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -0,0 +1,126 @@ +package com.keylesspalace.tusky.components.account.media + +import android.content.Context +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.setPadding +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.decodeBlurHash +import com.keylesspalace.tusky.util.getFormattedDescription +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import java.util.Random + +class AccountMediaGridAdapter( + private val alwaysShowSensitiveMedia: Boolean, + private val useBlurhash: Boolean, + context: Context, + private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit +) : PagingDataAdapter>( + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + return oldItem.attachment.id == newItem.attachment.id + } + + override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + return oldItem == newItem + } + } +) { + + private val baseItemBackgroundColor = ThemeUtils.getColor(context, R.attr.colorSurface) + private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) + private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) + + private val itemBgBaseHSV = FloatArray(3) + private val random = Random() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false) + Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV) + itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f + binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val context = holder.binding.root.context + getItem(position)?.let { item -> + + val imageView = holder.binding.accountMediaImageView + val overlay = holder.binding.accountMediaImageViewOverlay + + val blurhash = item.attachment.blurhash + val placeholder = if (useBlurhash && blurhash != null) { + decodeBlurHash(context, blurhash) + } else { + null + } + + if (item.attachment.type == Attachment.Type.AUDIO) { + overlay.hide() + + imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding)) + + Glide.with(imageView) + .load(R.drawable.ic_music_box_preview_24dp) + .centerInside() + .into(imageView) + + imageView.contentDescription = item.attachment.getFormattedDescription(context) + } else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) { + overlay.show() + overlay.setImageDrawable(mediaHiddenDrawable) + + imageView.setPadding(0) + + Glide.with(imageView) + .load(placeholder) + .centerInside() + .into(imageView) + + imageView.contentDescription = imageView.context.getString(R.string.post_media_hidden_title) + } else { + if (item.attachment.type == Attachment.Type.VIDEO || item.attachment.type == Attachment.Type.GIFV) { + overlay.show() + overlay.setImageDrawable(videoIndicator) + } else { + overlay.hide() + } + + imageView.setPadding(0) + + Glide.with(imageView) + .asBitmap() + .load(item.attachment.previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView) + + imageView.contentDescription = item.attachment.getFormattedDescription(context) + } + + holder.binding.root.setOnClickListener { + onAttachmentClickListener(item, imageView) + } + + holder.binding.root.setOnLongClickListener { view -> + val description = item.attachment.getFormattedDescription(view.context) + Toast.makeText(view.context, description, Toast.LENGTH_LONG).show() + true + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt new file mode 100644 index 000000000..60c767436 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt @@ -0,0 +1,37 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.viewdata.AttachmentViewData + +class AccountMediaPagingSource( + private val viewModel: AccountMediaViewModel +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + + return if (params is LoadParams.Refresh) { + val list = viewModel.attachmentData.toList() + LoadResult.Page(list, null, list.lastOrNull()?.statusId) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt new file mode 100644 index 000000000..81865b0fe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -0,0 +1,79 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import retrofit2.HttpException + +@OptIn(ExperimentalPagingApi::class) +class AccountMediaRemoteMediator( + private val api: MastodonApi, + private val viewModel: AccountMediaViewModel +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + + try { + val statusResponse = when (loadType) { + LoadType.REFRESH -> { + api.accountStatuses(viewModel.accountId, onlyMedia = true) + } + LoadType.PREPEND -> { + return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val maxId = state.lastItemOrNull()?.statusId + if (maxId != null) { + api.accountStatuses(viewModel.accountId, maxId = maxId, onlyMedia = true) + } else { + return MediatorResult.Success(endOfPaginationReached = false) + } + } + } + + val statuses = statusResponse.body() + if (!statusResponse.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(statusResponse)) + } + + val attachments = statuses.flatMap { status -> + AttachmentViewData.list(status) + } + + if (loadType == LoadType.REFRESH) { + viewModel.attachmentData.clear() + } + + viewModel.attachmentData.addAll(attachments) + + viewModel.currentSource?.invalidate() + return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) + } catch (e: Exception) { + return ifExpected(e) { + MediatorResult.Error(e) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt new file mode 100644 index 000000000..5c3528e9d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -0,0 +1,64 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.viewdata.AttachmentViewData +import javax.inject.Inject + +class AccountMediaViewModel @Inject constructor ( + api: MastodonApi +) : ViewModel() { + + lateinit var accountId: String + + val attachmentData: MutableList = mutableListOf() + + var currentSource: AccountMediaPagingSource? = null + + @OptIn(ExperimentalPagingApi::class) + val media = Pager( + config = PagingConfig( + pageSize = LOAD_AT_ONCE, + prefetchDistance = LOAD_AT_ONCE * 2 + ), + pagingSourceFactory = { + AccountMediaPagingSource( + viewModel = this + ).also { source -> + currentSource = source + } + }, + remoteMediator = AccountMediaRemoteMediator(api, this) + ).flow + .cachedIn(viewModelScope) + + fun revealAttachment(viewData: AttachmentViewData) { + val position = attachmentData.indexOfFirst { oldViewData -> oldViewData.id == viewData.id } + attachmentData[position] = viewData.copy(isRevealed = true) + currentSource?.invalidate() + } + + companion object { + private const val LOAD_AT_ONCE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt new file mode 100644 index 000000000..34ad159e8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt @@ -0,0 +1,47 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.account.media + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class GridSpacingItemDecoration( + private val spanCount: Int, + private val spacing: Int, + private val topOffset: Int +) : ItemDecoration() { + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) // item position + if (position < topOffset) return + + val column = (position - topOffset) % spanCount // item column + + outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing) + outRect.right = + spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing) + if (position - topOffset >= spanCount) { + outRect.top = spacing // item top + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt similarity index 92% rename from app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt rename to app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt index d7e753bbb..b696bfc11 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt @@ -1,4 +1,4 @@ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.account.media import android.content.Context import android.util.AttributeSet diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 70ebfc7dc..6ebe76b7a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.visible import java.lang.ref.WeakReference interface AnnouncementActionListener : LinkListener { @@ -73,6 +74,9 @@ class AnnouncementAdapter( return } + // hide button if announcement badge limit is already reached + addReactionChip.visible(item.reactions.size < 8) + item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 59a5729b7..6122d8996 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -36,10 +36,12 @@ import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.widget.AdapterView import android.widget.ImageButton import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.ColorInt @@ -54,7 +56,6 @@ import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -68,10 +69,12 @@ import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.EmojiAdapter +import com.keylesspalace.tusky.adapter.LocaleAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog +import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog +import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView @@ -89,8 +92,6 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.afterTextChanged -import com.keylesspalace.tusky.util.combineLiveData -import com.keylesspalace.tusky.util.combineOptionalLiveData import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans @@ -99,16 +100,19 @@ import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.util.withLifecycleContext import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException -import java.util.* +import java.text.DecimalFormat +import java.util.Locale import javax.inject.Inject import kotlin.math.max import kotlin.math.min @@ -120,7 +124,8 @@ class ComposeActivity : OnEmojiSelectedListener, Injectable, OnReceiveContentListener, - ComposeScheduleView.OnTimeSetListener { + ComposeScheduleView.OnTimeSetListener, + CaptionDialog.Listener { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -144,8 +149,7 @@ class ComposeActivity : private val binding by viewBinding(ActivityComposeBinding::inflate) - private val maxUploadMediaNumber = 4 - private var mediaCount = 0 + private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> if (success) { @@ -153,7 +157,7 @@ class ComposeActivity : } } private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> - if (mediaCount + uris.size > maxUploadMediaNumber) { + if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) { Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() } else { uris.forEach { uri -> @@ -175,6 +179,7 @@ class ComposeActivity : uriNew, size, itemOld.description, + null, // Intentionally reset focus when cropping itemOld ) } @@ -218,8 +223,12 @@ class ComposeActivity : val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> - makeCaptionDialog(item.description, item.uri) { newDescription -> - viewModel.updateDescription(item.localId, newDescription) + CaptionDialog.newInstance(item.localId, item.description, item.uri) + .show(supportFragmentManager, "caption_dialog") + }, + onAddFocus = { item -> + makeFocusDialog(item.focus, item.uri) { newFocus -> + viewModel.updateFocus(item.localId, newFocus) } }, onEditImage = this::editImageInQueue, @@ -230,17 +239,25 @@ class ComposeActivity : binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null - subscribeToUpdates(mediaAdapter) setupButtons() - - photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY) + subscribeToUpdates(mediaAdapter) /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ - val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) - viewModel.setup(composeOptions) + viewModel.setup(composeOptions, useCachedData(preferences, composeOptions?.tootRightNow == true)) + + if (accountManager.shouldDisplaySelfUsername(this)) { + binding.composeUsernameView.text = getString( + R.string.compose_active_account_description, + activeAccount.fullName + ) + binding.composeUsernameView.show() + } else { + binding.composeUsernameView.hide() + } + setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent) val statusContent = composeOptions?.content @@ -248,35 +265,54 @@ class ComposeActivity : binding.composeEditField.setText(statusContent) } - viewModel.loadInstanceDataFromNetwork(loadInstanceData(preferences, composeOptions?.tootRightNow == true)) - if (!composeOptions?.scheduledAt.isNullOrEmpty()) { binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } + setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) setupComposeField(preferences, viewModel.startingText) setupDefaultTagViews(preferences) setupContentWarningField(composeOptions?.contentWarning) setupPollView() applyShareIntent(intent, savedInstanceState) - viewModel.setupComplete.value = true + + /* Finally, overwrite state with data from saved instance state. */ + savedInstanceState?.let { + photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY) + + (it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply { + setStatusVisibility(this) + } + + it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply { + viewModel.contentWarningChanged(this) + } + + it.getString(SCHEDULED_TIME_KEY)?.let { time -> + viewModel.updateScheduledAt(time) + } + } if (composeOptions?.tootRightNow == true && calculateTextLength() > 0) { onSendClicked() } + + binding.composeEditField.post { + binding.composeEditField.requestFocus() + } } - private fun loadInstanceData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean { + private fun useCachedData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean { if (tootRightNow) { - return false // from Quick Toot + return true // from Quick Toot } if (!preferences.getBoolean("limitedBandwidthActive", false)) { - return true // Limited Bandwidth Mode disabled + return false // Limited Bandwidth Mode disabled } if (!preferences.getBoolean("limitedBandwidthOnlyMobileNetwork", true)) { - return false // Limited Bandwidth Mode enabled && Only Mobile Network disabled + return true // Limited Bandwidth Mode enabled && Only Mobile Network disabled } - return !(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered + return (getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).isActiveNetworkMetered } private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) { @@ -417,36 +453,48 @@ class ComposeActivity : } private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { - withLifecycleContext { - viewModel.instanceInfo.observe { instanceData -> + lifecycleScope.launch { + viewModel.instanceInfo.collect { instanceData -> maximumTootCharacters = instanceData.maxChars charactersReservedPerUrl = instanceData.charactersReservedPerUrl + maxUploadMediaNumber = instanceData.maxMediaAttachments updateVisibleCharactersLeft() } - viewModel.emoji.observe { emoji -> setEmojiList(emoji) } - combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> + } + + lifecycleScope.launch { + viewModel.emoji.collect(::setEmojiList) + } + + lifecycleScope.launch { + viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive -> updateSensitiveMediaToggle(markSensitive, showContentWarning) showContentWarning(showContentWarning) - }.subscribe() - viewModel.statusVisibility.observe { visibility -> - setStatusVisibility(visibility) - } - lifecycleScope.launch { - viewModel.media.collect { media -> - mediaAdapter.submitList(media) - if (media.size != mediaCount) { - mediaCount = media.size - binding.composeMediaPreviewBar.visible(media.isNotEmpty()) - updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false) - } - } - } + }.collect() + } - viewModel.poll.observe { poll -> + lifecycleScope.launch { + viewModel.statusVisibility.collect(::setStatusVisibility) + } + + lifecycleScope.launch { + viewModel.media.collect { media -> + mediaAdapter.submitList(media) + + binding.composeMediaPreviewBar.visible(media.isNotEmpty()) + updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value) + } + } + + lifecycleScope.launch { + viewModel.poll.collect { poll -> binding.pollPreview.visible(poll != null) poll?.let(binding.pollPreview::setPoll) } - viewModel.scheduledAt.observe { scheduledAt -> + } + + lifecycleScope.launch { + viewModel.scheduledAt.collect { scheduledAt -> if (scheduledAt == null) { binding.composeScheduleView.resetSchedule() } else { @@ -454,25 +502,26 @@ class ComposeActivity : } updateScheduleButton() } - combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll -> + } + + lifecycleScope.launch { + viewModel.media.combine(viewModel.poll) { media, poll -> val active = poll == null && - media!!.size != 4 && + media.size < maxUploadMediaNumber && (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) enableButton(binding.composeAddMediaButton, active, active) - enablePollButton(media.isNullOrEmpty()) - }.subscribe() - viewModel.uploadError.observe { throwable -> - Log.w(TAG, "media upload failed", throwable) + enablePollButton(media.isEmpty()) + }.collect() + } + + lifecycleScope.launch { + viewModel.uploadError.collect { throwable -> if (throwable is UploadServerError) { displayTransientError(throwable.errorMessage) } else { displayTransientError(R.string.error_media_upload_sending) } } - viewModel.setupComplete.observe { - // Focus may have changed during view model setup, ensure initial focus is on the edit field - binding.composeEditField.requestFocus() - } } } @@ -532,6 +581,61 @@ class ComposeActivity : binding.actionPhotoTake.setOnClickListener { initiateCameraApp() } binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED + ) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + handleCloseButton() + } + } + ) + } + + private fun setupLanguageSpinner(initialLanguage: String?) { + val locales = Locale.getAvailableLocales() + .filter { it.country.isNullOrEmpty() && it.script.isNullOrEmpty() && it.variant.isNullOrEmpty() } // Only "base" languages, "en" but not "en_DK" + var currentLocaleIndex = locales.indexOfFirst { it.language == initialLanguage } + if (currentLocaleIndex < 0) { + Log.e(TAG, "Error looking up language tag '$initialLanguage', falling back to english") + currentLocaleIndex = locales.indexOfFirst { it.language == "en" } + } + + val context = this + binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).language + } + + override fun onNothingSelected(parent: AdapterView<*>) { + parent.setSelection(locales.indexOfFirst { it.language == getInitialLanguage() }) + } + } + binding.composePostLanguageButton.apply { + adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales) + setSelection(currentLocaleIndex) + } + } + + private fun getInitialLanguage(language: String? = null): String { + return if (language.isNullOrEmpty()) { + // Setting the application ui preference sets the default locale + Locale.getDefault().language + } else { + language + } } private fun setupActionBar() { @@ -628,6 +732,9 @@ class ComposeActivity : override fun onSaveInstanceState(outState: Bundle) { outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri) + outState.putSerializable(VISIBILITY_KEY, viewModel.statusVisibility.value) + outState.putBoolean(CONTENT_WARNING_VISIBLE_KEY, viewModel.showContentWarning.value) + outState.putString(SCHEDULED_TIME_KEY, viewModel.scheduledAt.value) super.onSaveInstanceState(outState) } @@ -654,12 +761,12 @@ class ComposeActivity : @ColorInt val color = if (contentWarningShown) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) binding.composeHideMediaButton.isClickable = false - ContextCompat.getColor(this, R.color.transparent_tusky_blue) + getColor(R.color.transparent_tusky_blue) } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) - ContextCompat.getColor(this, R.color.tusky_blue) + getColor(R.color.tusky_blue) } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) ThemeUtils.getColor(this, android.R.attr.textColorTertiary) @@ -673,7 +780,7 @@ class ComposeActivity : @ColorInt val color = if (binding.composeScheduleView.time == null) { ThemeUtils.getColor(this, android.R.attr.textColorTertiary) } else { - ContextCompat.getColor(this, R.color.tusky_blue) + getColor(R.color.tusky_blue) } binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } @@ -785,13 +892,17 @@ class ComposeActivity : addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } - private fun openPollDialog() { + private fun openPollDialog() = lifecycleScope.launch { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - val instanceParams = viewModel.instanceInfo.value!! + val instanceParams = viewModel.instanceInfo.first() showAddPollDialog( - this, viewModel.poll.value, instanceParams.pollMaxOptions, - instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, - viewModel::updatePoll + context = this@ComposeActivity, + poll = viewModel.poll.value, + maxOptionCount = instanceParams.pollMaxOptions, + maxOptionLength = instanceParams.pollMaxLength, + minDuration = instanceParams.pollMinDuration, + maxDuration = instanceParams.pollMaxDuration, + onUpdatePoll = viewModel::updatePoll ) } @@ -845,18 +956,22 @@ class ComposeActivity : if (binding.checkboxUseDefaultText.isChecked) { length += 1 + binding.editTextDefaultText.length() } - if (viewModel.showContentWarning.value!!) { + if (viewModel.showContentWarning.value) { length += binding.composeContentWarningField.length() } return length } + @VisibleForTesting + val selectedLanguage: String? + get() = viewModel.postLanguage + private fun updateVisibleCharactersLeft() { val remainingLength = maximumTootCharacters - calculateTextLength() binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) val textColor = if (remainingLength < 0) { - ContextCompat.getColor(this, R.color.tusky_red) + getColor(R.color.tusky_red) } else { ThemeUtils.getColor(this, android.R.attr.textColorTertiary) } @@ -899,7 +1014,7 @@ class ComposeActivity : enableButtons(false) var contentText = binding.composeEditField.text.toString() var spoilerText = "" - if (viewModel.showContentWarning.value!!) { + if (viewModel.showContentWarning.value) { spoilerText = binding.composeContentWarningField.text.toString() } val characterCount = calculateTextLength() @@ -918,9 +1033,8 @@ class ComposeActivity : ) } - viewModel.sendStatus(contentText, spoilerText).observe( - this - ) { + lifecycleScope.launch { + viewModel.sendStatus(contentText, spoilerText) finishingUploadDialog?.dismiss() deleteDraftAndFinish() } @@ -1016,13 +1130,17 @@ class ComposeActivity : private fun pickMedia(uri: Uri) { lifecycleScope.launch { viewModel.pickMedia(uri).onFailure { throwable -> - val errorId = when (throwable) { - is VideoSizeException -> R.string.error_video_upload_size - is AudioSizeException -> R.string.error_audio_upload_size - is VideoOrImageException -> R.string.error_media_upload_image_or_video - else -> R.string.error_media_upload_opening + val errorString = when (throwable) { + is FileSizeException -> { + val decimalFormat = DecimalFormat("0.##") + val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024) + val formattedSize = decimalFormat.format(allowedSizeInMb) + getString(R.string.error_multimedia_size_limit, formattedSize) + } + is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video) + else -> getString(R.string.error_media_upload_opening) } - displayTransientError(errorId) + displayTransientError(errorString) } } } @@ -1033,7 +1151,7 @@ class ComposeActivity : binding.composeContentWarningBar.show() binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length) binding.composeContentWarningField.requestFocus() - ContextCompat.getColor(this, R.color.tusky_blue) + getColor(R.color.tusky_blue) } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() @@ -1051,23 +1169,6 @@ class ComposeActivity : return super.onOptionsItemSelected(item) } - override fun onBackPressed() { - // Acting like a teen: deliberately ignoring parent. - if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED - ) { - composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN - addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN - emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN - scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN - return - } - - handleCloseButton() - } - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { Log.d(TAG, event.toString()) if (event.action == KeyEvent.ACTION_DOWN) { @@ -1080,7 +1181,7 @@ class ComposeActivity : } if (keyCode == KeyEvent.KEYCODE_BACK) { - onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } } @@ -1091,8 +1192,15 @@ class ComposeActivity : val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() if (viewModel.didChange(contentText, contentWarning)) { + + val warning = if (!viewModel.media.value.isEmpty()) { + R.string.compose_save_draft_loses_media + } else { + R.string.compose_save_draft + } + AlertDialog.Builder(this) - .setMessage(R.string.compose_save_draft) + .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> saveDraftAndFinish(contentText, contentWarning) } @@ -1146,7 +1254,8 @@ class ComposeActivity : val mediaSize: Long, val uploadPercent: Int = 0, val id: String? = null, - val description: String? = null + val description: String? = null, + val focus: Attachment.Focus? = null ) { enum class Type { IMAGE, VIDEO, AUDIO; @@ -1167,6 +1276,14 @@ class ComposeActivity : scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN } + override fun onUpdateDescription(localId: Int, description: String) { + lifecycleScope.launch { + if (!viewModel.updateDescription(localId, description)) { + Toast.makeText(this@ComposeActivity, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() + } + } + } + @Parcelize data class ComposeOptions( // Let's keep fields var until all consumers are Kotlin @@ -1191,6 +1308,7 @@ class ComposeActivity : var sensitive: Boolean? = null, var poll: NewPoll? = null, var modifiedInitialState: Boolean? = null, + var language: String? = null, var tootRightNow: Boolean? = null, ) : Parcelable @@ -1202,6 +1320,9 @@ class ComposeActivity : private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID" private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" + private const val VISIBILITY_KEY = "VISIBILITY" + private const val SCHEDULED_TIME_KEY = "SCHEDULE" + private const val CONTENT_WARNING_VISIBLE_KEY = "CONTENT_WARNING_VISIBLE" @JvmField val CAN_USE_UNLEAKABLE = arrayOf("itabashi.0j0.jp", "odakyu.app") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt index 7b3d208b9..99e68db7d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt @@ -97,11 +97,11 @@ class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { return if (i > 0 && text[i - 1] == ' ') { text } else if (text is Spanned) { - val s = SpannableString(text.toString() + " ") + val s = SpannableString("$text ") TextUtils.copySpansFrom(text, 0, text.length, Object::class.java, s, 0) s } else { - text.toString() + " " + "$text " } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 6eaf78a8d..927d28c7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -18,55 +18,45 @@ package com.keylesspalace.tusky.components.compose import android.net.Uri import android.util.Log import androidx.core.net.toUri -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper -import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment -import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend -import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.randomAlphanumericString -import com.keylesspalace.tusky.util.toLiveData -import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.rxSingle import kotlinx.coroutines.withContext import javax.inject.Inject +@OptIn(FlowPreview::class) class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, private val mediaUploader: MediaUploader, private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, - private val instanceInfoRepo: InstanceInfoRepository + instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null internal var startingText: String? = null + internal var postLanguage: String? = null private var draftId: Int = 0 private var scheduledTootId: String? = null private var startingContentWarning: String = "" @@ -78,49 +68,41 @@ class ComposeViewModel @Inject constructor( private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false + private var hasScheduledTimeChanged: Boolean = false - val instanceInfo: MutableLiveData = MutableLiveData() + private val useCache = MutableStateFlow(true) + val instanceInfo = useCache.map { when (it) { + true -> instanceInfoRepo.getCachedInstanceInfo() + false -> instanceInfoRepo.getInstanceInfo() + } }.shareIn(viewModelScope, SharingStarted.Eagerly, 1) + val emoji = useCache.map { when (it) { + true -> instanceInfoRepo.getCachedEmojis() + false -> instanceInfoRepo.getEmojis() + } }.shareIn(viewModelScope, SharingStarted.Eagerly, 1) - val emoji: MutableLiveData?> = MutableLiveData() - val markMediaAsSensitive = - mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + val markMediaAsSensitive: MutableStateFlow = + MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) - val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) - val showContentWarning = mutableLiveData(false) - val setupComplete = mutableLiveData(false) - val poll: MutableLiveData = mutableLiveData(null) - val scheduledAt: MutableLiveData = mutableLiveData(null) + val statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) + val showContentWarning: MutableStateFlow = MutableStateFlow(false) + val poll: MutableStateFlow = MutableStateFlow(null) + val scheduledAt: MutableStateFlow = MutableStateFlow(null) val media: MutableStateFlow> = MutableStateFlow(emptyList()) - val uploadError = MutableLiveData() + val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val domain = accountManager.activeAccount?.domain!! private val mediaToJob = mutableMapOf() - private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() - // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null - fun loadInstanceDataFromNetwork(loadActually: Boolean) { - viewModelScope.launch { - emoji.postValue(when (loadActually) { - true -> instanceInfoRepo.getEmojis() - false -> instanceInfoRepo.getCachedEmojis() - }) - } - viewModelScope.launch { - instanceInfo.postValue(when (loadActually) { - true -> instanceInfoRepo.getInstanceInfo() - false -> instanceInfoRepo.getCachedInstanceInfo() - }) - } - } + private var setupComplete = false - suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result = withContext(Dispatchers.IO) { + suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result = withContext(Dispatchers.IO) { try { - val (type, uri, size) = mediaUploader.prepareMedia(mediaUri) + val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first()) val mediaItems = media.value if (type != QueuedMedia.Type.IMAGE && mediaItems.isNotEmpty() && @@ -128,7 +110,7 @@ class ComposeViewModel @Inject constructor( ) { Result.failure(VideoOrImageException()) } else { - val queuedMedia = addMediaToQueue(type, uri, size, description) + val queuedMedia = addMediaToQueue(type, uri, size, description, focus) Result.success(queuedMedia) } } catch (e: Exception) { @@ -141,6 +123,7 @@ class ComposeViewModel @Inject constructor( uri: Uri, mediaSize: Long, description: String? = null, + focus: Attachment.Focus? = null, replaceItem: QueuedMedia? = null ): QueuedMedia { var stashMediaItem: QueuedMedia? = null @@ -151,7 +134,8 @@ class ComposeViewModel @Inject constructor( uri = uri, type = type, mediaSize = mediaSize, - description = description + description = description, + focus = focus ) stashMediaItem = mediaItem @@ -168,10 +152,10 @@ class ComposeViewModel @Inject constructor( mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaUploader - .uploadMedia(mediaItem) + .uploadMedia(mediaItem, instanceInfo.first()) .catch { error -> media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } - uploadError.postValue(error) + uploadError.emit(error) } .collect { event -> val item = media.value.find { it.localId == mediaItem.localId } @@ -196,7 +180,7 @@ class ComposeViewModel @Inject constructor( return mediaItem } - private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) { + private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { media.update { mediaValue -> val mediaItem = QueuedMedia( localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, @@ -205,7 +189,8 @@ class ComposeViewModel @Inject constructor( mediaSize = 0, uploadPercent = -1, id = id, - description = description + description = description, + focus = focus ) mediaValue + mediaItem } @@ -227,13 +212,14 @@ class ComposeViewModel @Inject constructor( startingText?.startsWith(content.toString()) ?: false ) - val contentWarningChanged = showContentWarning.value!! && + val contentWarningChanged = showContentWarning.value && !contentWarning.isNullOrEmpty() && !startingContentWarning.startsWith(contentWarning.toString()) val mediaChanged = media.value.isNotEmpty() val pollChanged = poll.value != null + val didScheduledTimeChange = hasScheduledTimeChanged - return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged + return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange } fun contentWarningChanged(value: Boolean) { @@ -259,9 +245,11 @@ class ComposeViewModel @Inject constructor( suspend fun saveDraft(content: String, contentWarning: String) { val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() + val mediaFocus: MutableList = mutableListOf() media.value.forEach { item -> mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) + mediaFocus.add(item.focus) } draftHelper.saveDraft( @@ -270,53 +258,55 @@ class ComposeViewModel @Inject constructor( inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, - sensitive = markMediaAsSensitive.value!!, - visibility = statusVisibility.value!!, + sensitive = markMediaAsSensitive.value, + visibility = statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, + mediaFocus = mediaFocus, poll = poll.value, - failedToSend = false + failedToSend = false, + scheduledAt = scheduledAt.value, + language = postLanguage, ) } /** * Send status to the server. * Uses current state plus provided arguments. - * @return LiveData which will signal once the screen can be closed or null if there are errors */ - fun sendStatus( + suspend fun sendStatus( content: String, spoilerText: String - ): LiveData { + ) { - val deletionObservable = if (isEditingScheduledToot) { - rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { } - } else { - Observable.just(Unit) - }.toLiveData() + if (!scheduledTootId.isNullOrEmpty()) { + api.deleteScheduledStatus(scheduledTootId!!) + } - val sendFlow = media + media .filter { items -> items.all { it.uploadPercent == -1 } } - .map { + .first { val mediaIds: MutableList = mutableListOf() val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() + val mediaFocus: MutableList = mutableListOf() val mediaProcessed: MutableList = mutableListOf() - for (item in media.value) { + media.value.forEach { item -> mediaIds.add(item.id!!) mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") + mediaFocus.add(item.focus) mediaProcessed.add(false) } - val tootToSend = StatusToSend( text = content, warningText = spoilerText, - visibility = statusVisibility.value!!.serverString(), - sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), + visibility = statusVisibility.value.serverString(), + sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), mediaIds = mediaIds, mediaUris = mediaUris.map { it.toString() }, mediaDescriptions = mediaDescriptions, + mediaFocus = mediaFocus, scheduledAt = scheduledAt.value, inReplyToId = inReplyToId, poll = poll.value, @@ -327,20 +317,21 @@ class ComposeViewModel @Inject constructor( draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0, - mediaProcessed = mediaProcessed + mediaProcessed = mediaProcessed, + language = postLanguage, ) serviceClient.sendToot(tootToSend) + true } - - return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> } } - suspend fun updateDescription(localId: Int, description: String): Boolean { + // Updates a QueuedMedia item arbitrarily, then sends description and focus to server + private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean { val newMediaList = media.updateAndGet { mediaValue -> mediaValue.map { mediaItem -> if (mediaItem.localId == localId) { - mediaItem.copy(description = description) + mutator(mediaItem) } else { mediaItem } @@ -349,7 +340,9 @@ class ComposeViewModel @Inject constructor( val updatedItem = newMediaList.find { it.localId == localId } if (updatedItem?.id != null) { - return api.updateMedia(updatedItem.id, description) + val focus = updatedItem.focus + val focusString = if (focus != null) "${focus.x},${focus.y}" else null + return api.updateMedia(updatedItem.id, updatedItem.description, focusString) .fold({ true }, { throwable -> @@ -360,6 +353,18 @@ class ComposeViewModel @Inject constructor( return true } + suspend fun updateDescription(localId: Int, description: String): Boolean { + return updateMediaItem(localId, { mediaItem -> + mediaItem.copy(description = description) + }) + } + + suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean { + return updateMediaItem(localId, { mediaItem -> + mediaItem.copy(focus = focus) + }) + } + fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { @@ -381,7 +386,7 @@ class ComposeViewModel @Inject constructor( }) } ':' -> { - val emojiList = emoji.value ?: return emptyList() + val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList() val incomplete = token.substring(1) return emojiList.filter { emoji -> @@ -399,9 +404,9 @@ class ComposeViewModel @Inject constructor( } } - fun setup(composeOptions: ComposeActivity.ComposeOptions?) { + fun setup(composeOptions: ComposeActivity.ComposeOptions?, useCache: Boolean) { - if (setupComplete.value == true) { + if (setupComplete) { return } @@ -434,7 +439,7 @@ class ComposeViewModel @Inject constructor( // when coming from DraftActivity viewModelScope.launch { draftAttachments.forEach { attachment -> - pickMedia(attachment.uri, attachment.description) + pickMedia(attachment.uri, attachment.description, attachment.focus) } } } else composeOptions?.mediaAttachments?.forEach { a -> @@ -444,12 +449,13 @@ class ComposeViewModel @Inject constructor( Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO } - addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) + addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId startingText = composeOptions?.content + postLanguage = composeOptions?.language val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { @@ -480,6 +486,10 @@ class ComposeViewModel @Inject constructor( } replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusAuthor = composeOptions?.replyingStatusAuthor + + this.useCache.update { useCache } + + setupComplete = true } fun updatePoll(newPoll: NewPoll) { @@ -487,6 +497,10 @@ class ComposeViewModel @Inject constructor( } fun updateScheduledAt(newScheduledAt: String?) { + if (newScheduledAt != scheduledAt.value) { + hasScheduledTimeChanged = true + } + scheduledAt.value = newScheduledAt } @@ -495,8 +509,6 @@ class ComposeViewModel @Inject constructor( } } -fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } - /** * Thrown when trying to add an image when video is already present or the other way around */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt index a0215847e..4976ae0c5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -20,8 +20,8 @@ import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat import android.graphics.BitmapFactory import android.net.Uri -import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.calculateInSampleSize +import com.keylesspalace.tusky.util.closeQuietly import com.keylesspalace.tusky.util.getImageOrientation import com.keylesspalace.tusky.util.reorientBitmap import java.io.File @@ -51,7 +51,7 @@ fun downsizeImage( val options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeStream(decodeBoundsInputStream, null, options) - IOUtils.closeQuietly(decodeBoundsInputStream) + decodeBoundsInputStream.closeQuietly() // Get EXIF data, for orientation info. val orientation = getImageOrientation(uri, contentResolver) /* Unfortunately, there isn't a determined worst case compression ratio for image @@ -78,7 +78,7 @@ fun downsizeImage( } catch (error: OutOfMemoryError) { return false } finally { - IOUtils.closeQuietly(decodeBitmapInputStream) + decodeBitmapInputStream.closeQuietly() } ?: return false val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index be54a1aa9..2855e6969 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( context: Context, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, + private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit, private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit, private val onRemove: (ComposeActivity.QueuedMedia) -> Unit ) : RecyclerView.Adapter() { @@ -44,15 +45,19 @@ class MediaPreviewAdapter( val item = differ.currentList[position] val popup = PopupMenu(view.context, view) val addCaptionId = 1 - val editImageId = 2 - val removeId = 3 + val addFocusId = 2 + val editImageId = 3 + val removeId = 4 popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) popup.menu.add(0, editImageId, 0, R.string.action_edit_image) + } popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { addCaptionId -> onAddCaption(item) + addFocusId -> onAddFocus(item) editImageId -> onEditImage(item) removeId -> onRemove(item) } @@ -78,11 +83,24 @@ class MediaPreviewAdapter( // TODO: Fancy waveform display? holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - Glide.with(holder.itemView.context) + val imageView = holder.progressImageView + val focus = item.focus + + if (focus != null) + imageView.setFocalPoint(focus) + else + imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. + + var glide = Glide.with(holder.itemView.context) .load(item.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() - .into(holder.progressImageView) + .centerInside() + + if (focus != null) + glide = glide.addListener(imageView) + + glide.into(imageView) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index a8a7c88f6..7b2008ad9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -27,6 +27,7 @@ import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN @@ -70,8 +71,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) -class AudioSizeException : Exception() -class VideoSizeException : Exception() +class FileSizeException(val allowedSizeInBytes: Int) : Exception() class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() @@ -82,10 +82,10 @@ class MediaUploader @Inject constructor( ) { @OptIn(ExperimentalCoroutinesApi::class) - fun uploadMedia(media: QueuedMedia): Flow { + fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow { return flow { - if (shouldResizeMedia(media)) { - emit(downsize(media)) + if (shouldResizeMedia(media, instanceInfo)) { + emit(downsize(media, instanceInfo)) } else { emit(media) } @@ -94,7 +94,7 @@ class MediaUploader @Inject constructor( .flowOn(Dispatchers.IO) } - fun prepareMedia(inUri: Uri): PreparedMedia { + fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia { var mediaSize = MEDIA_SIZE_UNKNOWN var uri = inUri val mimeType: String? @@ -164,8 +164,8 @@ class MediaUploader @Inject constructor( if (mimeType != null) { return when (mimeType.substring(0, mimeType.indexOf('/'))) { "video" -> { - if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { - throw VideoSizeException() + if (mediaSize > instanceInfo.videoSizeLimit) { + throw FileSizeException(instanceInfo.videoSizeLimit) } PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) } @@ -173,8 +173,8 @@ class MediaUploader @Inject constructor( PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) } "audio" -> { - if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { - throw AudioSizeException() + if (mediaSize > instanceInfo.videoSizeLimit) { + throw FileSizeException(instanceInfo.videoSizeLimit) } PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) } @@ -225,7 +225,13 @@ class MediaUploader @Inject constructor( null } - mediaUploadApi.uploadMedia(body, description).fold({ result -> + val focus = if (media.focus != null) { + MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}") + } else { + null + } + + mediaUploadApi.uploadMedia(body, description, focus).fold({ result -> if (media.uri.scheme == "file") { media.uri.path?.let { File(it).delete() @@ -244,22 +250,18 @@ class MediaUploader @Inject constructor( } } - private fun downsize(media: QueuedMedia): QueuedMedia { + private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia { val file = createNewImageFile(context) - downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file) + downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file) return media.copy(uri = file.toUri(), mediaSize = file.length()) } - private fun shouldResizeMedia(media: QueuedMedia): Boolean { + private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean { return media.type == QueuedMedia.Type.IMAGE && - (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) + (media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit) } private companion object { private const val TAG = "MediaUploader" - private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB - private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB - private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB - private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index a87b1b238..005e67297 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -77,7 +77,7 @@ fun showAddPollDialog( } val pollDurationId = durations.indexOfLast { - it <= poll?.expiresIn ?: 0 + it <= (poll?.expiresIn ?: 0) } binding.pollDurationSpinner.setSelection(pollDurationId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index 71789611b..d5ece95f4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -15,19 +15,22 @@ package com.keylesspalace.tusky.components.compose.dialog -import android.app.Activity -import android.content.DialogInterface +import android.app.Dialog +import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Bundle import android.text.InputFilter import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import android.view.WindowManager import android.widget.EditText import android.widget.LinearLayout -import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy @@ -35,84 +38,123 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R -import kotlinx.coroutines.launch // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 -fun T.makeCaptionDialog( - existingDescription: String?, - previewUri: Uri, - onUpdateDescription: suspend (String) -> Boolean -) where T : Activity, T : LifecycleOwner { - val dialogLayout = LinearLayout(this) - val padding = Utils.dpToPx(this, 8) - dialogLayout.setPadding(padding, padding, padding, padding) +class CaptionDialog : DialogFragment() { - dialogLayout.orientation = LinearLayout.VERTICAL - val imageView = PhotoView(this).apply { - maximumScale = 6f - } + private lateinit var listener: Listener + private lateinit var input: EditText - val margin = Utils.dpToPx(this, 4) - dialogLayout.addView(imageView) - (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f - imageView.layoutParams.height = 0 - (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + val dialogLayout = LinearLayout(context) + val padding = Utils.dpToPx(context, 8) + dialogLayout.setPadding(padding, padding, padding, padding) - val input = EditText(this) - input.hint = resources.getQuantityString( - R.plurals.hint_describe_for_visually_impaired, - MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT - ) - dialogLayout.addView(input) - (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) - input.setLines(2) - input.inputType = ( - InputType.TYPE_CLASS_TEXT - or InputType.TYPE_TEXT_FLAG_MULTI_LINE - or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES - ) - input.setText(existingDescription) - input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) - - val okListener = { dialog: DialogInterface, _: Int -> - lifecycleScope.launch { - if (!onUpdateDescription(input.text.toString())) { - showFailedCaptionMessage() - } + dialogLayout.orientation = LinearLayout.VERTICAL + val imageView = PhotoView(context).apply { + maximumScale = 6f } - dialog.dismiss() + + val margin = Utils.dpToPx(context, 4) + dialogLayout.addView(imageView) + (imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f + imageView.layoutParams.height = 0 + (imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0) + + input = EditText(context) + input.hint = resources.getQuantityString( + R.plurals.hint_describe_for_visually_impaired, + MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT + ) + dialogLayout.addView(input) + (input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin) + input.setLines(2) + input.inputType = ( + InputType.TYPE_CLASS_TEXT + or InputType.TYPE_TEXT_FLAG_MULTI_LINE + or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + ) + input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) + input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) + + val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") + val dialog = AlertDialog.Builder(context) + .setView(dialogLayout) + .setPositiveButton(android.R.string.ok) { _, _ -> + listener.onUpdateDescription(localId, input.text.toString()) + } + .setNegativeButton(android.R.string.cancel, null) + .create() + + isCancelable = false + val window = dialog.window + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + + val previewUri = arguments?.getParcelable(PREVIEW_URI_ARG) ?: error("Preview Uri is null") + // Load the image and manually set it into the ImageView because it doesn't have a fixed size. + Glide.with(this) + .load(previewUri) + .downsample(DownsampleStrategy.CENTER_INSIDE) + .into(object : CustomTarget(4096, 4096) { + override fun onLoadCleared(placeholder: Drawable?) { + imageView.setImageDrawable(placeholder) + } + + override fun onResourceReady( + resource: Drawable, + transition: Transition?, + ) { + imageView.setImageDrawable(resource) + } + }) + + return dialog } - val dialog = AlertDialog.Builder(this) - .setView(dialogLayout) - .setPositiveButton(android.R.string.ok, okListener) - .setNegativeButton(android.R.string.cancel, null) - .create() + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(DESCRIPTION_KEY, input.text.toString()) + super.onSaveInstanceState(outState) + } - val window = dialog.window - window?.setSoftInputMode( - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + savedInstanceState?.getString(DESCRIPTION_KEY)?.let { + input.setText(it) + } + return super.onCreateView(inflater, container, savedInstanceState) + } - dialog.show() + override fun onAttach(context: Context) { + super.onAttach(context) + listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener") + } - // Load the image and manually set it into the ImageView because it doesn't have a fixed size. - Glide.with(this) - .load(previewUri) - .downsample(DownsampleStrategy.CENTER_INSIDE) - .into(object : CustomTarget(4096, 4096) { - override fun onLoadCleared(placeholder: Drawable?) { - imageView.setImageDrawable(placeholder) - } + interface Listener { + fun onUpdateDescription(localId: Int, description: String) + } - override fun onResourceReady(resource: Drawable, transition: Transition?) { - imageView.setImageDrawable(resource) - } - }) -} - -private fun Activity.showFailedCaptionMessage() { - Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show() + companion object { + fun newInstance( + localId: Int, + existingDescription: String?, + previewUri: Uri, + ) = CaptionDialog().apply { + arguments = bundleOf( + LOCAL_ID_ARG to localId, + EXISTING_DESCRIPTION_ARG to existingDescription, + PREVIEW_URI_ARG to previewUri, + ) + } + + private const val DESCRIPTION_KEY = "description" + private const val EXISTING_DESCRIPTION_ARG = "existing_description" + private const val PREVIEW_URI_ARG = "preview_uri" + private const val LOCAL_ID_ARG = "local_id" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt new file mode 100644 index 000000000..4764ec544 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -0,0 +1,105 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.compose.dialog + +import android.app.Activity +import android.content.DialogInterface +import android.graphics.drawable.Drawable +import android.net.Uri +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.DialogFocusBinding +import com.keylesspalace.tusky.entity.Attachment.Focus +import kotlinx.coroutines.launch + +fun T.makeFocusDialog( + existingFocus: Focus?, + previewUri: Uri, + onUpdateFocus: suspend (Focus) -> Boolean +) where T : Activity, T : LifecycleOwner { + val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center + + val dialogBinding = DialogFocusBinding.inflate(layoutInflater) + + dialogBinding.focusIndicator.setFocus(focus) + + Glide.with(this) + .load(previewUri) + .downsample(DownsampleStrategy.CENTER_INSIDE) + .listener(object : RequestListener { + override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean { + return false + } + + override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + val width = resource!!.intrinsicWidth + val height = resource.intrinsicHeight + + dialogBinding.focusIndicator.setImageSize(width, height) + + // We want the dialog to be a little taller than the image, so you can slide your thumb past the image border, + // but if it's *too* much taller that looks weird. See if a threshold has been crossed: + if (width > height) { + val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight() + + if (dialogBinding.imageView.height > maxHeight) { + val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight) + dialogBinding.imageView.layoutParams = verticalShrinkLayout + dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout + } + } + return false // Pass through + } + }) + .into(dialogBinding.imageView) + + val okListener = { dialog: DialogInterface, _: Int -> + lifecycleScope.launch { + if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) { + showFailedFocusMessage() + } + } + dialog.dismiss() + } + + val dialog = AlertDialog.Builder(this) + .setView(dialogBinding.root) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, null) + .create() + + val window = dialog.window + window?.setSoftInputMode( + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) + + dialog.show() +} + +private fun Activity.showFailedFocusMessage() { + Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt new file mode 100644 index 000000000..9a3e4b00a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -0,0 +1,130 @@ +package com.keylesspalace.tusky.components.compose.view + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Point +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import com.keylesspalace.tusky.entity.Attachment +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +class FocusIndicatorView +@JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private var focus: Attachment.Focus? = null + private var imageSize: Point? = null + private var circleRadius: Float? = null + + fun setImageSize(width: Int, height: Int) { + this.imageSize = Point(width, height) + if (focus != null) + invalidate() + } + + fun setFocus(focus: Attachment.Focus) { + this.focus = focus + if (imageSize != null) + invalidate() + } + + // Assumes setFocus called first + fun getFocus(): Attachment.Focus { + return focus!! + } + + // This needs to be consistent every time it is consulted over the lifetime of the object, + // so base it on the view width/height whenever the first access occurs. + private fun getCircleRadius(): Float { + val circleRadius = this.circleRadius + if (circleRadius != null) + return circleRadius + val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f + this.circleRadius = newCircleRadius + return newCircleRadius + } + + // Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y) + private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { + val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame + val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1 + return min(1.0f, max(-1.0f, result)) // Clamp + } + + private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { + val offset = (outerLimit - innerLimit) / 2 + return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1 + } + + @SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_CANCEL) + return false + + val imageSize = this.imageSize + if (imageSize == null) + return false + + // Convert touch xy to point inside image + focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height)) + invalidate() + return true + } + + private val transparentDarkGray = 0x40000000 + private val strokeWidth = 4.0f * this.resources.displayMetrics.density + + private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG) + + private val curtainPath = Path() + + init { + curtainPaint.color = transparentDarkGray + curtainPaint.style = Paint.Style.FILL + + strokePaint.style = Paint.Style.STROKE + strokePaint.strokeWidth = strokeWidth + strokePaint.color = Color.WHITE + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val imageSize = this.imageSize + val focus = this.focus + + if (imageSize != null && focus != null) { + val x = axisFromFocus(focus.x, imageSize.x, this.width) + val y = axisFromFocus(-focus.y, imageSize.y, this.height) + val circleRadius = getCircleRadius() + + curtainPath.reset() // Draw a flood fill with a hole cut out of it + curtainPath.fillType = Path.FillType.WINDING + curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW) + curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW) + canvas.drawPath(curtainPath, curtainPaint) + + canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle + canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot + } + } + + // Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked + fun maxAttractiveHeight(): Int { + val height = this.imageSize!!.y + val circleRadius = getCircleRadius() + + // Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth + return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java index fde993d1e..3be800e78 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java @@ -30,9 +30,10 @@ import androidx.appcompat.widget.AppCompatImageView; import android.util.AttributeSet; import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.view.MediaPreviewImageView; import at.connyduck.sparkbutton.helpers.Utils; -public final class ProgressImageView extends AppCompatImageView { +public final class ProgressImageView extends MediaPreviewImageView { private int progress = -1; private final RectF progressRect = new RectF(); @@ -58,15 +59,14 @@ public final class ProgressImageView extends AppCompatImageView { } private void init() { - circlePaint.setColor(ContextCompat.getColor(getContext(), R.color.tusky_blue)); + circlePaint.setColor(getContext().getColor(R.color.tusky_blue)); circlePaint.setStrokeWidth(Utils.dpToPx(getContext(), 4)); circlePaint.setStyle(Paint.Style.STROKE); clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); markBgPaint.setStyle(Paint.Style.FILL); - markBgPaint.setColor(ContextCompat.getColor(getContext(), - R.color.tusky_grey_10)); + markBgPaint.setColor(getContext().getColor(R.color.tusky_grey_10)); captionDrawable = AppCompatResources.getDrawable(getContext(), R.drawable.spellcheck); } @@ -81,8 +81,7 @@ public final class ProgressImageView extends AppCompatImageView { } public void setChecked(boolean checked) { - this.markBgPaint.setColor(ContextCompat.getColor(getContext(), - checked ? R.color.tusky_blue : R.color.tusky_grey_10)); + this.markBgPaint.setColor(getContext().getColor(checked ? R.color.tusky_blue : R.color.tusky_grey_10)); invalidate(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index beec1ba03..949fe6bf1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -94,7 +94,8 @@ data class ConversationStatusEntity( val expanded: Boolean, val collapsed: Boolean, val muted: Boolean, - val poll: Poll? + val poll: Poll?, + val language: String?, ) { fun toViewData(): StatusViewData.Concrete { @@ -126,6 +127,7 @@ data class ConversationStatusEntity( muted = muted, poll = poll, card = null, + language = language, quote = null, ), isExpanded = expanded, @@ -168,7 +170,8 @@ fun Status.toEntity() = expanded = false, collapsed = true, muted = muted ?: false, - poll = poll + poll = poll, + language = language, ) fun Conversation.toEntity(accountId: Long, order: Int) = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index fae55f0ba..f9082e8a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -85,6 +85,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity( expanded = expanded, collapsed = collapsed, muted = muted, - poll = poll + poll = poll, + language = status.language, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index a6cd3fcd7..61023ddbe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -25,9 +25,10 @@ import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.DraftAttachment import com.keylesspalace.tusky.db.DraftEntity +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.util.IOUtils +import com.keylesspalace.tusky.util.copyToFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -59,8 +60,11 @@ class DraftHelper @Inject constructor( visibility: Status.Visibility, mediaUris: List, mediaDescriptions: List, + mediaFocus: List, poll: NewPoll?, - failedToSend: Boolean + failedToSend: Boolean, + scheduledAt: String?, + language: String?, ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") @@ -77,11 +81,11 @@ class DraftHelper @Inject constructor( val uris = mediaUris.map { uriString -> uriString.toUri() - }.mapNotNull { uri -> + }.mapIndexedNotNull { index, uri -> if (uri.isInFolder(draftDirectory)) { uri } else { - uri.copyToFolder(draftDirectory) + uri.copyToFolder(draftDirectory, index) } } @@ -101,6 +105,7 @@ class DraftHelper @Inject constructor( DraftAttachment( uriString = uris[i].toString(), description = mediaDescriptions[i], + focus = mediaFocus[i], type = types[i] ) ) @@ -116,7 +121,9 @@ class DraftHelper @Inject constructor( visibility = visibility, attachments = attachments, poll = poll, - failedToSend = failedToSend + failedToSend = failedToSend, + scheduledAt = scheduledAt, + language = language, ) draftDao.insertOrReplace(draft) @@ -153,7 +160,7 @@ class DraftHelper @Inject constructor( return File(filePath).parentFile == folder } - private fun Uri.copyToFolder(folder: File): Uri? { + private fun Uri.copyToFolder(folder: File, index: Int): Uri? { val contentResolver = context.contentResolver val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) @@ -165,7 +172,7 @@ class DraftHelper @Inject constructor( map.getExtensionFromMimeType(mimeType) } - val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) + val filename = String.format("Tusky_Draft_Media_%s_%d.%s", timeStamp, index, fileExtension) val file = File(folder, filename) if (scheme == "https") { @@ -187,7 +194,7 @@ class DraftHelper @Inject constructor( return null } } else { - IOUtils.copyToFile(contentResolver, this, file) + this.copyToFile(contentResolver, file) } return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index acee683b6..98a288b42 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.drafts import android.view.ViewGroup import android.widget.ImageView -import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -26,6 +25,7 @@ import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.DraftAttachment +import com.keylesspalace.tusky.view.MediaPreviewImageView class DraftMediaAdapter( private val attachmentClick: () -> Unit @@ -42,24 +42,34 @@ class DraftMediaAdapter( ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { - return DraftMediaViewHolder(AppCompatImageView(parent.context)) + return DraftMediaViewHolder(MediaPreviewImageView(parent.context)) } override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { getItem(position)?.let { attachment -> if (attachment.type == DraftAttachment.Type.AUDIO) { + holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) } else { - Glide.with(holder.itemView.context) + if (attachment.focus != null) + holder.imageView.setFocalPoint(attachment.focus) + else + holder.imageView.clearFocus() + var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() - .into(holder.imageView) + .centerInside() + + if (attachment.focus != null) + glide = glide.addListener(holder.imageView) + + glide.into(holder.imageView) } } } - inner class DraftMediaViewHolder(val imageView: ImageView) : + inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) : RecyclerView.ViewHolder(imageView) { init { val thumbnailViewSize = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index db6a8a313..dfa361684 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -106,7 +106,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener { draftAttachments = draft.attachments, poll = draft.poll, sensitive = draft.sensitive, - visibility = draft.visibility + visibility = draft.visibility, + scheduledAt = draft.scheduledAt, + language = draft.language, ) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN @@ -143,7 +145,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener { draftAttachments = draft.attachments, poll = draft.poll, sensitive = draft.sensitive, - visibility = draft.visibility + visibility = draft.visibility, + scheduledAt = draft.scheduledAt, + language = draft.language, ) startActivity(ComposeActivity.startIntent(this, composeOptions)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index 05e10b6bc..bb97621c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -21,5 +21,12 @@ data class InstanceInfo( val pollMaxLength: Int, val pollMinDuration: Int, val pollMaxDuration: Int, - val charactersReservedPerUrl: Int + val charactersReservedPerUrl: Int, + val videoSizeLimit: Int, + val imageSizeLimit: Int, + val imageMatrixLimit: Int, + val maxMediaAttachments: Int, + val maxFields: Int, + val maxFieldNameLength: Int?, + val maxFieldValueLength: Int? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index 89b19a5b1..3085263c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -45,7 +45,7 @@ class InstanceInfoRepository @Inject constructor( */ suspend fun getEmojis(): List = withContext(Dispatchers.IO) { api.getCustomEmojis() - .onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) } + .onSuccess { emojiList -> dao.upsert(EmojisEntity(instanceName, emojiList)) } .getOrElse { throwable -> Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable) getCachedEmojis() @@ -72,9 +72,16 @@ class InstanceInfoRepository @Inject constructor( minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, - version = instance.version + version = instance.version, + videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit, + imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit, + imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit, + maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, + maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength ) - dao.insertOrReplace(instanceEntity) + dao.upsert(instanceEntity) instanceEntity }, { throwable -> @@ -99,6 +106,10 @@ class InstanceInfoRepository @Inject constructor( private const val DEFAULT_MIN_POLL_DURATION = 300 private const val DEFAULT_MAX_POLL_DURATION = 604800 + private const val DEFAULT_VIDEO_SIZE_LIMIT = 41943040 // 40MiB + private const val DEFAULT_IMAGE_SIZE_LIMIT = 10485760 // 10MiB + private const val DEFAULT_IMAGE_MATRIX_LIMIT = 16777216 // 4096^2 Pixels + @JvmField val CAN_USE_QUOTE_ID = arrayOf( "odakyu.app", @@ -120,6 +131,9 @@ class InstanceInfoRepository @Inject constructor( // Mastodon only counts URLs as this long in terms of status character limits const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 + const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4 + const val DEFAULT_MAX_ACCOUNT_FIELDS = 4 + fun InstanceInfoEntity?.toInstanceInfo(): InstanceInfo = InstanceInfo( maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, @@ -127,7 +141,14 @@ class InstanceInfoRepository @Inject constructor( pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL + charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = this?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = this?.maxFieldNameLength, + maxFieldValueLength = this?.maxFieldValueLength, ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index b81f2374a..fd93a01ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -209,7 +209,7 @@ class LoginActivity : BaseActivity(), Injectable { .addQueryParameter("response_type", "code") .addQueryParameter("scope", OAUTH_SCOPES) .build() - doWebViewAuth.launch(LoginData(url.toString().toUri(), oauthRedirectUri.toUri())) + doWebViewAuth.launch(LoginData(domain, url.toString().toUri(), oauthRedirectUri.toUri())) } override fun onStart() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index 07bd56529..b69f81e76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -1,3 +1,18 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.components.login import android.annotation.SuppressLint @@ -16,15 +31,22 @@ import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import javax.inject.Inject /** Contract for starting [LoginWebViewActivity]. */ class OauthLogin : ActivityResultContract() { @@ -61,6 +83,7 @@ class OauthLogin : ActivityResultContract() { @Parcelize data class LoginData( + val domain: String, val url: Uri, val oauthRedirectUrl: Uri, ) : Parcelable @@ -80,6 +103,11 @@ sealed class LoginResult : Parcelable { class LoginWebViewActivity : BaseActivity(), Injectable { private val binding by viewBinding(ActivityLoginWebviewBinding::inflate) + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: LoginWebViewViewModel by viewModels { viewModelFactory } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -103,7 +131,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable { webView.settings.databaseEnabled = false webView.settings.displayZoomControls = false webView.settings.javaScriptCanOpenWindowsAutomatically = false - // Javascript needs to be enabled because otherwise 2FA does not work in some instances + // JavaScript needs to be enabled because otherwise 2FA does not work in some instances @SuppressLint("SetJavaScriptEnabled") webView.settings.javaScriptEnabled = true webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}" @@ -161,6 +189,25 @@ class LoginWebViewActivity : BaseActivity(), Injectable { } else { webView.restoreState(savedInstanceState) } + + binding.loginRules.text = getString(R.string.instance_rule_info, data.domain) + + viewModel.init(data.domain) + + lifecycleScope.launch { + viewModel.instanceRules.collect { instanceRules -> + binding.loginRules.visible(instanceRules.isNotEmpty()) + binding.loginRules.setOnClickListener { + AlertDialog.Builder(this@LoginWebViewActivity) + .setTitle(getString(R.string.instance_rule_title, data.domain)) + .setMessage( + instanceRules.joinToString(separator = "\n\n") { "• $it" } + ) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt new file mode 100644 index 000000000..39dd311aa --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -0,0 +1,47 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.login + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoginWebViewViewModel @Inject constructor( + private val api: MastodonApi +) : ViewModel() { + + val instanceRules: MutableStateFlow> = MutableStateFlow(emptyList()) + + private var domain: String? = null + + fun init(domain: String) { + if (this.domain == null) { + this.domain = domain + viewModelScope.launch { + api.getInstance(domain).fold({ instance -> + instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() + }, { throwable -> + Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) + }) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 45ecd0f65..beac22aad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -38,7 +38,6 @@ import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.RemoteInput; import androidx.core.app.TaskStackBuilder; -import androidx.core.content.ContextCompat; import androidx.work.Constraints; import androidx.work.NetworkType; import androidx.work.PeriodicWorkRequest; @@ -57,7 +56,6 @@ import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.PollOption; import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver; import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver; import com.keylesspalace.tusky.util.StringUtils; @@ -86,6 +84,8 @@ public class NotificationHelper { */ public static final String ACCOUNT_ID = "account_id"; + public static final String TYPE = "type"; + private static final String TAG = "NotificationHelper"; public static final String REPLY_ACTION = "REPLY_ACTION"; @@ -270,6 +270,7 @@ public class NotificationHelper { private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) { Intent summaryResultIntent = new Intent(context, MainActivity.class); summaryResultIntent.putExtra(ACCOUNT_ID, account.getId()); + summaryResultIntent.putExtra(TYPE, body.getType().name()); TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); summaryStackBuilder.addParentStack(MainActivity.class); summaryStackBuilder.addNextIntent(summaryResultIntent); @@ -280,6 +281,7 @@ public class NotificationHelper { // we have to switch account here Intent eventResultIntent = new Intent(context, MainActivity.class); eventResultIntent.putExtra(ACCOUNT_ID, account.getId()); + eventResultIntent.putExtra(TYPE, body.getType().name()); TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); eventStackBuilder.addParentStack(MainActivity.class); eventStackBuilder.addNextIntent(eventResultIntent); @@ -296,7 +298,7 @@ public class NotificationHelper { .setSmallIcon(R.drawable.ic_notify) .setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent) .setDeleteIntent(deletePendingIntent) - .setColor(ContextCompat.getColor(context, R.color.notification_color)) + .setColor(context.getColor(R.color.notification_color)) .setGroup(account.getAccountId()) .setAutoCancel(true) .setShortcutId(Long.toString(account.getId())) @@ -367,6 +369,7 @@ public class NotificationHelper { composeOptions.setReplyingStatusContent(citedText); composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setModifiedInitialState(true); + composeOptions.setLanguage(actionableStatus.getLanguage()); Intent composeIntent = ComposeActivity.startIntent( context, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index f458678e7..1054c5682 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -20,6 +20,7 @@ import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.util.Log +import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import androidx.fragment.app.commit import androidx.preference.PreferenceManager @@ -47,7 +48,17 @@ class PreferencesActivity : @Inject lateinit var androidInjector: DispatchingAndroidInjector - private var restartActivitiesOnExit: Boolean = false + private val restartActivitiesOnBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + /* Switching themes won't actually change the theme of activities on the back stack. + * Either the back stack activities need to all be recreated, or do the easier thing, which + * is hijack the back button press and use it to launch a new MainActivity and clear the + * back stack. */ + val intent = Intent(this@PreferencesActivity, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivityWithSlideInAnimation(intent) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -92,7 +103,8 @@ class PreferencesActivity : replace(R.id.fragment_container, fragment, fragmentTag) } - restartActivitiesOnExit = intent.getBooleanExtra("restart", false) + onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) + restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } override fun onResume() { @@ -106,11 +118,11 @@ class PreferencesActivity : } private fun saveInstanceState(outState: Bundle) { - outState.putBoolean("restart", restartActivitiesOnExit) + outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) } override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean("restart", restartActivitiesOnExit) + outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) super.onSaveInstanceState(outState) } @@ -121,16 +133,16 @@ class PreferencesActivity : Log.d("activeTheme", theme) ThemeUtils.setAppNightMode(theme) - restartActivitiesOnExit = true + restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() } "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", - "showCardsInTimelines", "confirmReblogs", "confirmFavourites", + "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, "viewPagerOffScreenLimit" -> { - restartActivitiesOnExit = true + restartActivitiesOnBackPressedCallback.isEnabled = true } "language" -> { - restartActivitiesOnExit = true + restartActivitiesOnBackPressedCallback.isEnabled = true this.restartCurrentActivity() } } @@ -148,20 +160,6 @@ class PreferencesActivity : overridePendingTransition(R.anim.fade_in, R.anim.fade_out) } - override fun onBackPressed() { - /* Switching themes won't actually change the theme of activities on the back stack. - * Either the back stack activities need to all be recreated, or do the easier thing, which - * is hijack the back button press and use it to launch a new MainActivity and clear the - * back stack. */ - if (restartActivitiesOnExit) { - val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivityWithSlideInAnimation(intent) - } else { - super.onBackPressed() - } - } - override fun androidInjector() = androidInjector companion object { @@ -172,6 +170,7 @@ class PreferencesActivity : const val TAB_FILTER_PREFERENCES = 3 const val PROXY_PREFERENCES = 4 private const val EXTRA_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE" + private const val EXTRA_RESTART_ON_BACK = "restart" @JvmStatic fun newIntent(context: Context, preferenceType: Int): Intent { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 946834f3f..ef21d88aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -102,6 +102,16 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.pref_main_nav_position) } + listPreference { + setDefaultValue("disambiguate") + setEntries(R.array.pref_show_self_username_names) + setEntryValues(R.array.pref_show_self_username_values) + key = PrefKeys.SHOW_SELF_USERNAME + setSummaryProvider { entry } + setTitle(R.string.pref_title_show_self_username) + isSingleLineTitle = false + } + switchPreference { setDefaultValue(false) key = PrefKeys.HIDE_TOP_TOOLBAR diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index 2ed261c7e..43ce346d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration @@ -134,7 +135,13 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I } override fun delete(item: ScheduledStatus) { - viewModel.deleteScheduledStatus(item) + AlertDialog.Builder(this) + .setMessage(R.string.delete_scheduled_post_warning) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _, _ -> + viewModel.deleteScheduledStatus(item) + } + .show() } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt index ec8110de0..7b34b7804 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt @@ -57,8 +57,7 @@ class ScheduledStatusAdapter( v.isEnabled = false listener.edit(item) } - holder.binding.delete.setOnClickListener { v: View -> - v.isEnabled = false + holder.binding.delete.setOnClickListener { listener.delete(item) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 16035b044..93d8ab5a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -26,7 +26,6 @@ import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFacto import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.DeletedStatus -import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.NotestockApi @@ -144,11 +143,7 @@ class SearchViewModel @Inject constructor( } fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { - val idx = loadedStatuses.indexOf(statusViewData) - if (idx >= 0) { - loadedStatuses[idx] = statusViewData.copy(isExpanded = expanded) - statusesPagingSourceFactory.invalidate() - } + updateStatusViewData(statusViewData.copy(isExpanded = expanded)) } fun expandedNotestockChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { @@ -170,17 +165,16 @@ class SearchViewModel @Inject constructor( } private fun setRebloggedForStatus(statusViewData: StatusViewData.Concrete, reblog: Boolean) { - statusViewData.status.reblogged = reblog - statusViewData.status.reblog?.reblogged = reblog - statusesPagingSourceFactory.invalidate() + updateStatus( + statusViewData.status.copy( + reblogged = reblog, + reblog = statusViewData.status.reblog?.copy(reblogged = reblog) + ) + ) } fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { - val idx = loadedStatuses.indexOf(statusViewData) - if (idx >= 0) { - loadedStatuses[idx] = statusViewData.copy(isShowingContent = isShowing) - statusesPagingSourceFactory.invalidate() - } + updateStatusViewData(statusViewData.copy(isShowingContent = isShowing)) } fun contentHiddenNotestockChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { @@ -192,11 +186,7 @@ class SearchViewModel @Inject constructor( } fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) { - val idx = loadedStatuses.indexOf(statusViewData) - if (idx >= 0) { - loadedStatuses[idx] = statusViewData.copy(isCollapsed = collapsed) - statusesPagingSourceFactory.invalidate() - } + updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } fun collapsedNotestockChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) { @@ -209,28 +199,16 @@ class SearchViewModel @Inject constructor( fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) - updateStatus(statusViewData, votedPoll) + updateStatus(statusViewData.status.copy(poll = votedPoll)) timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices) .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { newPoll -> updateStatus(statusViewData, newPoll) }, - { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) } - ) + .doOnError { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) } + .subscribe() .autoDispose() } - private fun updateStatus(statusViewData: StatusViewData.Concrete, newPoll: Poll) { - val idx = loadedStatuses.indexOf(statusViewData) - if (idx >= 0) { - val newStatus = statusViewData.status.copy(poll = newPoll) - loadedStatuses[idx] = statusViewData.copy(status = newStatus) - statusesPagingSourceFactory.invalidate() - } - } - fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) { - statusViewData.status.favourited = isFavorited - statusesPagingSourceFactory.invalidate() + updateStatus(statusViewData.status.copy(favourited = isFavorited)) timelineCases.favourite(statusViewData.id, isFavorited) .onErrorReturnItem(statusViewData.status) .subscribe() @@ -238,18 +216,13 @@ class SearchViewModel @Inject constructor( } fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) { - statusViewData.status.bookmarked = isBookmarked - statusesPagingSourceFactory.invalidate() + updateStatus(statusViewData.status.copy(bookmarked = isBookmarked)) timelineCases.bookmark(statusViewData.id, isBookmarked) .onErrorReturnItem(statusViewData.status) .subscribe() .autoDispose() } - fun getAllAccountsOrderedByActive(): List { - return accountManager.getAllAccountsOrderedByActive() - } - fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { timelineCases.mute(accountId, notifications, duration) } @@ -267,18 +240,28 @@ class SearchViewModel @Inject constructor( } fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { - val idx = loadedStatuses.indexOf(statusViewData) - if (idx >= 0) { - val newStatus = statusViewData.status.copy(muted = mute) - loadedStatuses[idx] = statusViewData.copy(status = newStatus) - statusesPagingSourceFactory.invalidate() - } + updateStatus(statusViewData.status.copy(muted = mute)) timelineCases.muteConversation(statusViewData.id, mute) .onErrorReturnItem(statusViewData.status) .subscribe() .autoDispose() } + private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { + val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id } + if (idx >= 0) { + loadedStatuses[idx] = newStatusViewData + statusesPagingSourceFactory.invalidate() + } + } + + private fun updateStatus(newStatus: Status) { + val statusViewData = loadedStatuses.find { it.id == newStatus.id } + if (statusViewData != null) { + updateStatusViewData(statusViewData.copy(status = newStatus)) + } + } + companion object { private const val TAG = "SearchViewModel" private const val DEFAULT_LOAD_SIZE = 20 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt index 5c2ba0066..0f7c001c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchNotestockFragment.kt @@ -236,9 +236,6 @@ class SearchNotestockFragment : SearchFragment(), Statu val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username val statusUrl = status.actionableStatus.url - val accounts = viewModel.getAllAccountsOrderedByActive() - var openAsTitle: String? = null - val loggedInAccountId = viewModel.activeAccount?.accountId val popup = PopupMenu(view.context, view) @@ -270,18 +267,12 @@ class SearchNotestockFragment : SearchFragment(), Statu } val openAsItem = popup.menu.findItem(R.id.status_open_as) - when (accounts.size) { - 0, 1 -> openAsItem.isVisible = false - 2 -> for (account in accounts) { - if (account !== viewModel.activeAccount) { - openAsTitle = - String.format(getString(R.string.action_open_as), account.fullName) - break - } - } - else -> openAsTitle = String.format(getString(R.string.action_open_as), "…") + val openAsText = bottomSheetActivity?.openAsText + if (openAsText == null) { + openAsItem.isVisible = false + } else { + openAsItem.title = openAsText } - openAsItem.title = openAsTitle val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) @@ -308,7 +299,7 @@ class SearchNotestockFragment : SearchFragment(), Statu val stringToShare = statusToShare.account.username + " - " + - statusToShare.content.toString() + statusToShare.content sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.type = "text/plain" startActivity( @@ -413,7 +404,7 @@ class SearchNotestockFragment : SearchFragment(), Statu } != null } - private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { + private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) { bottomSheetActivity?.showAccountChooserDialog( dialogTitle, false, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 2c4f112aa..2b1b89aa6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -223,7 +223,8 @@ class SearchStatusesFragment : SearchFragment(), Status contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, replyingStatusAuthor = actionableStatus.account.localUsername, - replyingStatusContent = status.content.toString() + replyingStatusContent = status.content.toString(), + language = actionableStatus.language, ) ) bottomSheetActivity?.startActivityWithSlideInAnimation(intent) @@ -318,7 +319,7 @@ class SearchStatusesFragment : SearchFragment(), Status val stringToShare = statusToShare.account.username + " - " + - statusToShare.content.toString() + statusToShare.content sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.type = "text/plain" startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_content_to))) @@ -412,7 +413,7 @@ class SearchStatusesFragment : SearchFragment(), Status } != null } - private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence) { + private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) { bottomSheetActivity?.showAccountChooserDialog( dialogTitle, false, object : AccountSelectionListener { @@ -491,7 +492,8 @@ class SearchStatusesFragment : SearchFragment(), Status contentWarning = redraftStatus.spoilerText, mediaAttachments = redraftStatus.attachments, sensitive = redraftStatus.sensitive, - poll = redraftStatus.poll?.toNewPoll(status.createdAt) + poll = redraftStatus.poll?.toNewPoll(status.createdAt), + language = redraftStatus.language, ) ) startActivity(intent) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 2a345fdf5..01b111184 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -100,6 +100,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { pinned = false, card = null, repliesCount = 0, + language = null, quote = null, ) } @@ -143,6 +144,7 @@ fun Status.toEntity( pinned = actionableStatus.pinned == true, card = actionableStatus.card?.let(gson::toJson), repliesCount = actionableStatus.repliesCount, + language = actionableStatus.language, quote = actionableStatus.quote?.let(gson::toJson), ) } @@ -189,6 +191,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { poll = poll, card = card, repliesCount = status.repliesCount, + language = status.language, quote = quote, ) } @@ -220,6 +223,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { poll = null, card = null, repliesCount = status.repliesCount, + language = status.language, quote = null, ) } else { @@ -250,6 +254,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { poll = poll, card = card, repliesCount = status.repliesCount, + language = status.language, quote = quote, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index ebab4440a..b29ab0cf6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -30,7 +30,6 @@ import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.rx3.await import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) @@ -71,7 +70,7 @@ class CachedTimelineRemoteMediator( maxId = cachedTopId, sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten limit = state.config.pageSize - ).await() + ) val statuses = statusResponse.body() if (statusResponse.isSuccessful && statuses != null) { @@ -86,14 +85,14 @@ class CachedTimelineRemoteMediator( val statusResponse = when (loadType) { LoadType.REFRESH -> { - api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize).await() + api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize) } LoadType.PREPEND -> { return MediatorResult.Success(endOfPaginationReached = true) } LoadType.APPEND -> { val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId - api.homeTimeline(maxId = maxId, limit = state.config.pageSize).await() + api.homeTimeline(maxId = maxId, limit = state.config.pageSize) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index ba8bc5ae1..187e2ae28 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -51,7 +51,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import net.accelf.yuito.streaming.StreamingManager import retrofit2.HttpException import javax.inject.Inject @@ -180,7 +179,7 @@ class CachedTimelineViewModel @Inject constructor( sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE ) - }.await() + } val statuses = response.body() if (!response.isSuccessful || statuses == null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index a8dd4f718..95a3f255a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await import net.accelf.yuito.streaming.StreamingManager import retrofit2.HttpException import retrofit2.Response @@ -319,7 +318,7 @@ class NetworkTimelineViewModel @Inject constructor( Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) - }.await() + } } private fun StatusViewData.Concrete.update() { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt rename to app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt index c291bd019..36e36d10f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt @@ -13,16 +13,16 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.view +package com.keylesspalace.tusky.components.viewthread import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Drawable import android.view.View import androidx.core.content.ContextCompat +import androidx.core.view.forEach import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.ThreadAdapter class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { @@ -32,29 +32,25 @@ class ConversationLineItemDecoration(private val context: Context) : RecyclerVie val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) val dividerEnd = dividerStart + divider.intrinsicWidth - val childCount = parent.childCount val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) - for (i in 0 until childCount) { - val child = parent.getChildAt(i) + val items = (parent.adapter as ThreadAdapter).currentList + + parent.forEach { child -> val position = parent.getChildAdapterPosition(child) - val adapter = parent.adapter as ThreadAdapter - val current = adapter.getItem(position) - val dividerTop: Int - val dividerBottom: Int + val current = items.getOrNull(position) + if (current != null) { - val above = adapter.getItem(position - 1) - dividerTop = if (above != null && above.id == current.status.inReplyToId) { + val above = items.getOrNull(position - 1) + val dividerTop = if (above != null && above.id == current.status.inReplyToId) { child.top } else { child.top + avatarMargin } - val below = adapter.getItem(position + 1) - dividerBottom = if (below != null && current.id == below.status.inReplyToId && - adapter.detailedStatusPosition != position - ) { + val below = items.getOrNull(position + 1) + val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) { child.bottom } else { child.top + avatarMargin diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt new file mode 100644 index 000000000..9e0903b06 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -0,0 +1,95 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.viewthread + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.StatusViewData + +class ThreadAdapter( + private val statusDisplayOptions: StatusDisplayOptions, + private val statusActionListener: StatusActionListener +) : ListAdapter(ThreadDifferCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { + return when (viewType) { + VIEW_TYPE_STATUS -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status, parent, false) + StatusViewHolder(view) + } + VIEW_TYPE_STATUS_DETAILED -> { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_status_detailed, parent, false) + StatusDetailedViewHolder(view) + } + else -> error("Unknown item type: $viewType") + } + } + + override fun onBindViewHolder(viewHolder: StatusBaseViewHolder, position: Int) { + val status = getItem(position) + viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions) + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position).isDetailed) { + VIEW_TYPE_STATUS_DETAILED + } else { + VIEW_TYPE_STATUS + } + } + + companion object { + private const val VIEW_TYPE_STATUS = 0 + private const val VIEW_TYPE_STATUS_DETAILED = 1 + + val ThreadDifferCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean { + return false // Items are different always. It allows to refresh timestamp on every view holder update + } + + override fun getChangePayload( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else // If items are different - update the whole view holder + null + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt new file mode 100644 index 000000000..ed0393fab --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt @@ -0,0 +1,62 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.viewthread + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.commit +import com.keylesspalace.tusky.BottomSheetActivity +import com.keylesspalace.tusky.R +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector +import javax.inject.Inject + +class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { + + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_view_thread) + val id = intent.getStringExtra(ID_EXTRA)!! + val url = intent.getStringExtra(URL_EXTRA)!! + val fragment = + supportFragmentManager.findFragmentByTag(FRAGMENT_TAG + id) as ViewThreadFragment? + ?: ViewThreadFragment.newInstance(id, url) + + supportFragmentManager.commit { + replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id) + } + } + + override fun androidInjector() = dispatchingAndroidInjector + + companion object { + + fun startIntent(context: Context, id: String, url: String): Intent { + val intent = Intent(context, ViewThreadActivity::class.java) + intent.putExtra(ID_EXTRA, id) + intent.putExtra(URL_EXTRA, url) + return intent + } + + private const val ID_EXTRA = "id" + private const val URL_EXTRA = "url" + private const val FRAGMENT_TAG = "ViewThreadFragment_" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt new file mode 100644 index 000000000..f50ed2912 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -0,0 +1,343 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.viewthread + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.AccountListActivity +import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ViewThreadViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentViewThreadBinding::bind) + + private lateinit var adapter: ThreadAdapter + private lateinit var thisThreadsStatusId: String + + private var alwaysShowSensitiveMedia = false + private var alwaysOpenSpoiler = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val statusDisplayOptions = StatusDisplayOptions( + animateAvatars = preferences.getBoolean("animateGifAvatars", false), + mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + showBotOverlay = preferences.getBoolean("showBotOverlay", true), + useBlurhash = preferences.getBoolean("useBlurhash", true), + cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, + confirmReblogs = preferences.getBoolean("confirmReblogs", true), + confirmFavourites = preferences.getBoolean("confirmFavourites", false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + quoteEnabled = accountManager.activeAccount!!.domain in CAN_USE_QUOTE_ID + ) + adapter = ThreadAdapter(statusDisplayOptions, this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_view_thread, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + binding.toolbar.setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + binding.toolbar.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.action_reveal -> { + viewModel.toggleRevealButton() + true + } + R.id.action_open_in_web -> { + context?.openLink(requireArguments().getString(URL_EXTRA)!!) + true + } + else -> false + } + } + + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this + ) { index -> adapter.currentList.getOrNull(index) } + ) + val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) + binding.recyclerView.addItemDecoration(divider) + binding.recyclerView.addItemDecoration(ConversationLineItemDecoration(requireContext())) + alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia + alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler + + binding.recyclerView.adapter = adapter + + (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiState.collect { uiState -> + when (uiState) { + is ThreadUiState.Loading -> { + updateRevealButton(RevealButtonState.NO_BUTTON) + binding.recyclerView.hide() + binding.statusView.hide() + binding.progressBar.show() + } + is ThreadUiState.Error -> { + Log.w(TAG, "failed to load status", uiState.throwable) + + updateRevealButton(RevealButtonState.NO_BUTTON) + binding.swipeRefreshLayout.isRefreshing = false + + binding.recyclerView.hide() + binding.statusView.show() + binding.progressBar.hide() + + if (uiState.throwable is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + viewModel.retry(thisThreadsStatusId) + } + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) { + viewModel.retry(thisThreadsStatusId) + } + } + } + is ThreadUiState.Success -> { + adapter.submitList(uiState.statuses) { + if (viewModel.isInitialLoad) { + viewModel.isInitialLoad = false + val detailedPosition = adapter.currentList.indexOfFirst { viewData -> + viewData.isDetailed + } + binding.recyclerView.scrollToPosition(detailedPosition) + } + } + + updateRevealButton(uiState.revealButton) + binding.swipeRefreshLayout.isRefreshing = uiState.refreshing + + binding.recyclerView.show() + binding.statusView.hide() + binding.progressBar.hide() + } + } + } + } + + lifecycleScope.launch { + viewModel.errors.collect { throwable -> + Log.w(TAG, "failed to load status context", throwable) + Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT) + .setAction(R.string.action_retry) { + viewModel.retry(thisThreadsStatusId) + } + .show() + } + } + + viewModel.loadThread(thisThreadsStatusId) + } + + private fun updateRevealButton(state: RevealButtonState) { + val menuItem = binding.toolbar.menu.findItem(R.id.action_reveal) + + menuItem.isVisible = state != RevealButtonState.NO_BUTTON + menuItem.setIcon(if (state == RevealButtonState.REVEAL) R.drawable.ic_eye_24dp else R.drawable.ic_hide_media_24dp) + } + + override fun onRefresh() { + viewModel.refresh(thisThreadsStatusId) + } + + override fun onReply(position: Int) { + super.reply(adapter.currentList[position].status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val status = adapter.currentList[position] + viewModel.reblog(reblog, status) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val status = adapter.currentList[position] + viewModel.favorite(favourite, status) + } + + override fun onQuote(position: Int) { + super.quote(adapter.currentList[position].status) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val status = adapter.currentList[position] + viewModel.bookmark(bookmark, status) + } + + override fun onMore(view: View, position: Int) { + super.more(adapter.currentList[position].status, view, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.currentList[position].status + super.viewMedia(attachmentIndex, list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.currentList[position] + if (thisThreadsStatusId == status.id) { + // If already viewing this thread, don't reopen it. + return + } + super.viewThread(status.actionableId, status.actionable.url) + } + + override fun onViewUrl(url: String, text: String) { + val status: StatusViewData.Concrete? = viewModel.detailedStatus() + if (status != null && status.status.url == url) { + // already viewing the status with this url + // probably just a preview federated and the user is clicking again to view more -> open the browser + // this can happen with some friendica statuses + requireContext().openLink(url) + return + } + super.onViewUrl(url, text) + } + + override fun onOpenReblog(position: Int) { + // there are no reblogs in threads + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + viewModel.changeExpanded(expanded, adapter.currentList[position]) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + viewModel.changeContentShowing(isShowing, adapter.currentList[position]) + } + + override fun onLoadMore(position: Int) { + // only used in timelines + } + + override fun onShowReblogs(position: Int) { + val statusId = adapter.currentList[position].id + val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) + (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onShowFavs(position: Int) { + val statusId = adapter.currentList[position].id + val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) + (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + viewModel.changeContentCollapsed(isCollapsed, adapter.currentList[position]) + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + public override fun removeItem(position: Int) { + val status = adapter.currentList[position] + if (status.isDetailed) { + // the main status we are viewing is being removed, finish the activity + activity?.finish() + return + } + viewModel.removeStatus(status) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val status = adapter.currentList[position] + viewModel.voteInPoll(choices, status) + } + + companion object { + private const val TAG = "ViewThreadFragment" + + private const val ID_EXTRA = "id" + private const val URL_EXTRA = "url" + + fun newInstance(id: String, url: String): ViewThreadFragment { + val arguments = Bundle(2) + val fragment = ViewThreadFragment() + arguments.putString(ID_EXTRA, id) + arguments.putString(URL_EXTRA, url) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt new file mode 100644 index 000000000..c109494cd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -0,0 +1,426 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.viewthread + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrElse +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.PinEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import javax.inject.Inject + +class ViewThreadViewModel @Inject constructor( + private val api: MastodonApi, + private val filterModel: FilterModel, + private val timelineCases: TimelineCases, + eventHub: EventHub, + accountManager: AccountManager +) : ViewModel() { + + private val _uiState: MutableStateFlow = MutableStateFlow(ThreadUiState.Loading) + val uiState: Flow + get() = _uiState + + private val _errors = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val errors: Flow + get() = _errors + + var isInitialLoad: Boolean = true + + private val alwaysShowSensitiveMedia: Boolean + private val alwaysOpenSpoiler: Boolean + + init { + val activeAccount = accountManager.activeAccount + alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false + alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false + + viewModelScope.launch { + eventHub.events + .asFlow() + .collect { event -> + when (event) { + is FavoriteEvent -> handleFavEvent(event) + is ReblogEvent -> handleReblogEvent(event) + is BookmarkEvent -> handleBookmarkEvent(event) + is PinEvent -> handlePinEvent(event) + is BlockEvent -> removeAllByAccountId(event.accountId) + is StatusComposedEvent -> handleStatusComposedEvent(event) + is StatusDeletedEvent -> handleStatusDeletedEvent(event) + } + } + } + + loadFilters() + } + + fun loadThread(id: String) { + viewModelScope.launch { + val contextCall = async { api.statusContext(id) } + val statusCall = async { api.statusAsync(id) } + + val contextResult = contextCall.await() + val statusResult = statusCall.await() + + val status = statusResult.getOrElse { exception -> + _uiState.value = ThreadUiState.Error(exception) + return@launch + } + + contextResult.fold({ statusContext -> + + val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter() + val detailedStatus = status.toViewData(true) + val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() + val statuses = ancestors + detailedStatus + descendants + + _uiState.value = ThreadUiState.Success( + statuses = statuses, + revealButton = statuses.getRevealButtonState(), + refreshing = false + ) + }, { throwable -> + _errors.emit(throwable) + _uiState.value = ThreadUiState.Success( + statuses = listOf(status.toViewData(true)), + revealButton = RevealButtonState.NO_BUTTON, + refreshing = false + ) + }) + } + } + + fun retry(id: String) { + _uiState.value = ThreadUiState.Loading + loadThread(id) + } + + fun refresh(id: String) { + updateSuccess { uiState -> + uiState.copy(refreshing = true) + } + loadThread(id) + } + + fun detailedStatus(): StatusViewData.Concrete? { + return (_uiState.value as ThreadUiState.Success?)?.statuses?.find { status -> + status.isDetailed + } + } + + fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + try { + timelineCases.reblog(status.actionableId, reblog).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to reblog status " + status.actionableId, t) + } + } + } + + fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + try { + timelineCases.favourite(status.actionableId, favorite).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + try { + timelineCases.bookmark(status.actionableId, bookmark).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to favourite status " + status.actionableId, t) + } + } + } + + fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } + + val votedPoll = poll.votedCopy(choices) + updateStatus(status.id) { status -> + status.copy(poll = votedPoll) + } + + try { + timelineCases.voteInPoll(status.actionableId, poll.id, choices).await() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } + } + } + + fun removeStatus(statusToRemove: StatusViewData.Concrete) { + updateSuccess { uiState -> + uiState.copy( + statuses = uiState.statuses.filterNot { status -> status == statusToRemove } + ) + } + } + + fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + updateSuccess { uiState -> + val statuses = uiState.statuses.map { viewData -> + if (viewData.id == status.id) { + viewData.copy(isExpanded = expanded) + } else { + viewData + } + } + uiState.copy( + statuses = statuses, + revealButton = statuses.getRevealButtonState() + ) + } + } + + fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { viewData -> + viewData.copy(isShowingContent = isShowing) + } + } + + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { viewData -> + viewData.copy(isCollapsed = isCollapsed) + } + } + + private fun handleFavEvent(event: FavoriteEvent) { + updateStatus(event.statusId) { status -> + status.copy(favourited = event.favourite) + } + } + + private fun handleReblogEvent(event: ReblogEvent) { + updateStatus(event.statusId) { status -> + status.copy(reblogged = event.reblog) + } + } + + private fun handleBookmarkEvent(event: BookmarkEvent) { + updateStatus(event.statusId) { status -> + status.copy(bookmarked = event.bookmark) + } + } + + private fun handlePinEvent(event: PinEvent) { + updateStatus(event.statusId) { status -> + status.copy(pinned = event.pinned) + } + } + + private fun removeAllByAccountId(accountId: String) { + updateSuccess { uiState -> + uiState.copy( + statuses = uiState.statuses.filter { viewData -> + viewData.status.account.id == accountId + } + ) + } + } + + private fun handleStatusComposedEvent(event: StatusComposedEvent) { + val eventStatus = event.status + updateSuccess { uiState -> + val statuses = uiState.statuses + val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } + val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } + if (detailedIndex != -1 && repliedIndex >= detailedIndex) { + // there is a new reply to the detailed status or below -> display it + val newStatuses = statuses.subList(0, repliedIndex + 1) + + eventStatus.toViewData() + + statuses.subList(repliedIndex + 1, statuses.size) + uiState.copy(statuses = newStatuses) + } else { + uiState + } + } + } + + private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { + updateSuccess { uiState -> + uiState.copy( + statuses = uiState.statuses.filter { status -> + status.id != event.statusId + } + ) + } + } + + fun toggleRevealButton() { + updateSuccess { uiState -> + when (uiState.revealButton) { + RevealButtonState.HIDE -> uiState.copy( + statuses = uiState.statuses.map { viewData -> + viewData.copy(isExpanded = false) + }, + revealButton = RevealButtonState.REVEAL + ) + RevealButtonState.REVEAL -> uiState.copy( + statuses = uiState.statuses.map { viewData -> + viewData.copy(isExpanded = true) + }, + revealButton = RevealButtonState.HIDE + ) + else -> uiState + } + } + } + + private fun List.getRevealButtonState(): RevealButtonState { + val hasWarnings = any { viewData -> + viewData.status.spoilerText.isNotEmpty() + } + + return if (hasWarnings) { + val allExpanded = none { viewData -> + !viewData.isExpanded + } + if (allExpanded) { + RevealButtonState.HIDE + } else { + RevealButtonState.REVEAL + } + } else { + RevealButtonState.NO_BUTTON + } + } + + private fun loadFilters() { + viewModelScope.launch { + val filters = try { + api.getFilters().await() + } catch (t: Exception) { + Log.w(TAG, "Failed to fetch filters", t) + return@launch + } + filterModel.initWithFilters( + filters.filter { filter -> + filter.context.contains(Filter.THREAD) + } + ) + + updateSuccess { uiState -> + val statuses = uiState.statuses.filter() + uiState.copy( + statuses = statuses, + revealButton = statuses.getRevealButtonState() + ) + } + } + } + + private fun List.filter(): List { + return filter { status -> + status.isDetailed || !filterModel.shouldFilterStatus(status.status) + } + } + + private fun Status.toViewData(detailed: Boolean = false): StatusViewData.Concrete { + return toViewData( + isShowingContent = alwaysShowSensitiveMedia || !actionableStatus.sensitive, + isExpanded = alwaysOpenSpoiler, + isCollapsed = !detailed, + isDetailed = detailed + ) + } + + private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) { + _uiState.update { uiState -> + if (uiState is ThreadUiState.Success) { + updater(uiState) + } else { + uiState + } + } + } + + private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { + updateSuccess { uiState -> + uiState.copy( + statuses = uiState.statuses.map { viewData -> + if (viewData.id == statusId) { + updater(viewData) + } else { + viewData + } + } + ) + } + } + + private fun updateStatus(statusId: String, updater: (Status) -> Status) { + updateStatusViewData(statusId) { viewData -> + viewData.copy( + status = updater(viewData.status) + ) + } + } + + companion object { + private const val TAG = "ViewThreadViewModel" + } +} + +sealed interface ThreadUiState { + object Loading : ThreadUiState + class Error(val throwable: Throwable) : ThreadUiState + data class Success( + val statuses: List, + val revealButton: RevealButtonState, + val refreshing: Boolean + ) : ThreadUiState +} + +enum class RevealButtonState { + NO_BUTTON, REVEAL, HIDE +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 9c5e118b3..951cb9776 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -15,9 +15,12 @@ package com.keylesspalace.tusky.db +import android.content.Context import android.util.Log +import androidx.preference.PreferenceManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.PrefKeys import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -225,4 +228,18 @@ class AccountManager @Inject constructor(db: AppDatabase) { identifier == it.identifier } } + + /** + * @return true if the name of the currently-selected account should be displayed in UIs + */ + fun shouldDisplaySelfUsername(context: Context): Boolean { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + val showUsernamePreference = sharedPreferences.getString(PrefKeys.SHOW_SELF_USERNAME, "disambiguate") + if (showUsernamePreference == "always") + return true + if (showUsernamePreference == "never") + return false + + return accounts.size > 1 // "disambiguate" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d945b0c94..be169e49c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 39) + }, version = 42) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -582,4 +582,33 @@ public abstract class AppDatabase extends RoomDatabase { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT"); } }; + + public static final Migration MIGRATION_39_40 = new Migration(39, 40) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER"); + database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER"); + } + }; + + public static final Migration MIGRATION_40_41 = new Migration(40, 41) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT"); + } + }; + + public static final Migration MIGRATION_41_42 = new Migration(41, 42) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT"); + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT"); + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index a1e19c75c..79b7243f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -22,6 +22,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import kotlinx.parcelize.Parcelize @@ -38,7 +39,9 @@ data class DraftEntity( val visibility: Status.Visibility, val attachments: List, val poll: NewPoll?, - val failedToSend: Boolean + val failedToSend: Boolean, + val scheduledAt: String?, + val language: String?, ) /** @@ -50,6 +53,7 @@ data class DraftEntity( data class DraftAttachment( @SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, @SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, + @SerializedName(value = "focus") val focus: Attachment.Focus?, @SerializedName(value = "type", alternate = ["g", "k"]) val type: Type ) : Parcelable { val uri: Uri diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 3687da09e..0bf1dc32c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -20,15 +20,37 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction +import androidx.room.Update @Dao interface InstanceDao { - @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) - suspend fun insertOrReplace(instance: InstanceInfoEntity) + @Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) + suspend fun insertOrIgnore(instance: InstanceInfoEntity): Long - @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) - suspend fun insertOrReplace(emojis: EmojisEntity) + @Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) + suspend fun updateOrIgnore(instance: InstanceInfoEntity) + + @Transaction + suspend fun upsert(instance: InstanceInfoEntity) { + if (insertOrIgnore(instance) == -1L) { + updateOrIgnore(instance) + } + } + + @Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) + suspend fun insertOrIgnore(emojis: EmojisEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) + suspend fun updateOrIgnore(emojis: EmojisEntity) + + @Transaction + suspend fun upsert(emojis: EmojisEntity) { + if (insertOrIgnore(emojis) == -1L) { + updateOrIgnore(emojis) + } + } @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index 01767f321..efcfe5278 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -31,7 +31,14 @@ data class InstanceEntity( val minPollDuration: Int?, val maxPollDuration: Int?, val charactersReservedPerUrl: Int?, - val version: String? + val version: String?, + val videoSizeLimit: Int?, + val imageSizeLimit: Int?, + val imageMatrixLimit: Int?, + val maxMediaAttachments: Int?, + val maxFields: Int?, + val maxFieldNameLength: Int?, + val maxFieldValueLength: Int? ) @TypeConverters(Converters::class) @@ -48,5 +55,12 @@ data class InstanceInfoEntity( val minPollDuration: Int?, val maxPollDuration: Int?, val charactersReservedPerUrl: Int?, - val version: String? + val version: String?, + val videoSizeLimit: Int?, + val imageSizeLimit: Int?, + val imageMatrixLimit: Int?, + val maxMediaAttachments: Int?, + val maxFields: Int?, + val maxFieldNameLength: Int?, + val maxFieldValueLength: Int? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 6072abf8c..8a4ef18b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -36,7 +36,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, -s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, +s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.quote, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index 05c3747cf..6d4d8ede7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -81,6 +81,7 @@ data class TimelineStatusEntity( val contentShowing: Boolean, val pinned: Boolean, val card: String?, + val language: String?, val quote: String?, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index abd777619..76f76ba7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -27,7 +27,6 @@ import com.keylesspalace.tusky.SplashActivity import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.ViewMediaActivity -import com.keylesspalace.tusky.ViewThreadActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity @@ -39,6 +38,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity +import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import dagger.Module import dagger.android.ContributesAndroidInjector import net.accelf.yuito.AccessTokenLoginActivity @@ -78,7 +78,7 @@ abstract class ActivitiesModule { abstract fun contributesStatusListActivity(): StatusListActivity @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesSearchAvtivity(): SearchActivity + abstract fun contributesSearchActivity(): SearchActivity @ContributesAndroidInjector abstract fun contributesAboutActivity(): AboutActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 947a83236..9028dc1c7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -71,7 +71,8 @@ class AppModule { AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, - AppDatabase.MIGRATION_38_39 + AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, + AppDatabase.MIGRATION_41_42, ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index aa2f43915..6487b4e9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -30,9 +30,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchNotestockFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment +import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.NotificationsFragment -import com.keylesspalace.tusky.fragment.ViewThreadFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 2c3a6604a..5e580ae9b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,15 +5,18 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.account.AccountViewModel +import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel +import com.keylesspalace.tusky.components.login.LoginWebViewViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -109,6 +112,21 @@ abstract class ViewModelModule { @ViewModelKey(NetworkTimelineViewModel::class) internal abstract fun networkTimelineViewModel(viewModel: NetworkTimelineViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ViewThreadViewModel::class) + internal abstract fun viewThreadViewModel(viewModel: ViewThreadViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(AccountMediaViewModel::class) + internal abstract fun accountMediaViewModel(viewModel: AccountMediaViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(LoginWebViewViewModel::class) + internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(QuickTootViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 00d5659d5..5837815b0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -48,8 +48,8 @@ data class Announcement( data class Reaction( val name: String, - var count: Int, - var me: Boolean, + val count: Int, + val me: Boolean, val url: String?, @SerializedName("static_url") val staticUrl: String? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index 29fe7f8ee..05cac1a0f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -27,7 +27,7 @@ data class Card( val width: Int, val height: Int, val blurhash: String?, - val embed_url: String? + @SerializedName("embed_url") val embedUrl: String? ) { override fun hashCode() = url.hashCode() diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index 92a35b69c..a653cc587 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -20,14 +20,15 @@ import java.util.ArrayList import java.util.Date data class DeletedStatus( - var text: String?, - @SerializedName("in_reply_to_id") var inReplyToId: String?, + val text: String?, + @SerializedName("in_reply_to_id") val inReplyToId: String?, @SerializedName("spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") var attachments: ArrayList?, + @SerializedName("media_attachments") val attachments: ArrayList?, val poll: Poll?, - @SerializedName("created_at") val createdAt: Date + @SerializedName("created_at") val createdAt: Date, + val language: String?, ) { fun isEmpty(): Boolean { return text == null && attachments == null diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 34b80e83b..af51a04b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -16,12 +16,13 @@ package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName +import java.util.Date data class Filter( val id: String, val phrase: String, val context: List, - @SerializedName("expires_at") val expiresAt: String?, + @SerializedName("expires_at") val expiresAt: Date?, val irreversible: Boolean, @SerializedName("whole_word") val wholeWord: Boolean ) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt index f3a4f65b8..e2401d939 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -1,3 +1,3 @@ package com.keylesspalace.tusky.entity -data class HashTag(val name: String, val url: String) +data class HashTag(val name: String, val url: String, val following: Boolean? = null) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 31fcca0e0..13332f040 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -20,18 +20,21 @@ import com.google.gson.annotations.SerializedName data class Instance( val uri: String, val title: String, - val description: String, - val email: String, + // val description: String, + // val email: String, val version: String, - val urls: Map, - val stats: Map?, - val thumbnail: String?, - val languages: List, - @SerializedName("contact_account") val contactAccount: Account, + // val urls: Map, + // val stats: Map?, + // val thumbnail: String?, + // val languages: List, + // @SerializedName("contact_account") val contactAccount: Account, @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("max_bio_chars") val maxBioChars: Int?, @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, val configuration: InstanceConfiguration?, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + val pleroma: PleromaConfiguration?, + @SerializedName("upload_limit") val uploadLimit: Int?, + val rules: List? ) { override fun hashCode(): Int { return uri.hashCode() @@ -74,3 +77,22 @@ data class MediaAttachmentConfiguration( @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, @SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, ) + +data class PleromaConfiguration( + val metadata: PleromaMetadata? +) + +data class PleromaMetadata( + @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits +) + +data class PleromaFieldLimits( + @SerializedName("max_fields") val maxFields: Int?, + @SerializedName("name_length") val nameLength: Int?, + @SerializedName("value_length") val valueLength: Int? +) + +data class InstanceRules( + val id: String, + val text: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 7dae8b24d..1eb3bafc8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -28,6 +28,7 @@ data class NewStatus( @SerializedName("media_ids") val mediaIds: List?, @SerializedName("scheduled_at") val scheduledAt: String?, val poll: NewPoll?, + val language: String?, @SerializedName("quote_id") val quoteId: String?, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index d3ac6ed45..5d6f5ff07 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -25,7 +25,7 @@ data class Status( val id: String, val url: String?, // not present if it's reblog val account: TimelineAccount, - @SerializedName("in_reply_to_id") var inReplyToId: String?, + @SerializedName("in_reply_to_id") val inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, val reblog: Status?, val content: String, @@ -34,13 +34,13 @@ data class Status( @SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("favourites_count") val favouritesCount: Int, @SerializedName("replies_count") val repliesCount: Int, - var reblogged: Boolean, - var favourited: Boolean, - var bookmarked: Boolean, - var sensitive: Boolean, + val reblogged: Boolean, + val favourited: Boolean, + val bookmarked: Boolean, + val sensitive: Boolean, @SerializedName("spoiler_text", alternate = ["summary"]) val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments", alternate = ["attachment"]) var attachments: ArrayList, + @SerializedName("media_attachments", alternate = ["attachment"]) val attachments: ArrayList, @SerializedName("mentions", alternate = ["tag"]) val mentions: List, val tags: List?, val application: Application?, @@ -48,6 +48,7 @@ data class Status( val muted: Boolean?, val poll: Poll?, val card: Card?, + val language: String?, val quote: Status?, ) { @@ -138,7 +139,8 @@ data class Status( sensitive = sensitive, attachments = attachments, poll = poll, - createdAt = createdAt + createdAt = createdAt, + language = language, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index 465b9f216..497239653 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -78,8 +78,8 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - type = arguments?.getSerializable(ARG_TYPE) as Type - id = arguments?.getString(ARG_ID) + type = requireArguments().getSerializable(ARG_TYPE) as Type + id = requireArguments().getString(ARG_ID) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -100,7 +100,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis) Type.FOLLOW_REQUESTS -> { - val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.get(ARG_ACCOUNT_LOCKED) == true) + val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true) val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index dd4f02a33..59f40f890 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -41,6 +41,7 @@ import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; +import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BottomSheetActivity; import com.keylesspalace.tusky.PostLookupFallbackBehavior; @@ -154,6 +155,7 @@ public abstract class SFragment extends Fragment implements Injectable { composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString()); + composeOptions.setLanguage(actionableStatus.getLanguage()); Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); getActivity().startActivity(intent); @@ -316,6 +318,14 @@ public abstract class SFragment extends Fragment implements Injectable { } case R.id.pin: { timelineCases.pin(status.getId(), !status.isPinned()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError(e -> { + String message = e.getMessage(); + if (message == null) { + message = getString(status.isPinned() ? R.string.failed_to_unpin : R.string.failed_to_pin); + } + Snackbar.make(view, message, Snackbar.LENGTH_LONG).show(); + }) .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .subscribe(); return true; @@ -381,7 +391,7 @@ public abstract class SFragment extends Fragment implements Injectable { urlIndex); if (view != null) { String url = active.getAttachment().getUrl(); - ViewCompat.setTransitionName(view, url); + view.setTransitionName(url); ActivityOptionsCompat options = ActivityOptionsCompat.makeSceneTransitionAnimation(getActivity(), view, url); @@ -452,6 +462,7 @@ public abstract class SFragment extends Fragment implements Injectable { composeOptions.setMediaAttachments(deletedStatus.getAttachments()); composeOptions.setSensitive(deletedStatus.getSensitive()); composeOptions.setModifiedInitialState(true); + composeOptions.setLanguage(deletedStatus.getLanguage()); if (deletedStatus.getPoll() != null) { composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt())); } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 0362da9c7..28cf64ca3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -214,7 +214,7 @@ class ViewImageFragment : ViewMediaFragment() { .dontAnimate() .onlyRetrieveFromCache(true) .centerInside() - .addListener(ImageRequestListener(true, isThumnailRequest = true)) + .addListener(ImageRequestListener(true, isThumbnailRequest = true)) ) else it } @@ -222,10 +222,10 @@ class ViewImageFragment : ViewMediaFragment() { .error( glide.load(url) .centerInside() - .addListener(ImageRequestListener(false, isThumnailRequest = false)) + .addListener(ImageRequestListener(false, isThumbnailRequest = false)) ) .centerInside() - .addListener(ImageRequestListener(true, isThumnailRequest = false)) + .addListener(ImageRequestListener(true, isThumbnailRequest = false)) .into(photoView) } @@ -251,7 +251,7 @@ class ViewImageFragment : ViewMediaFragment() { */ private inner class ImageRequestListener( private val isCacheRequest: Boolean, - private val isThumnailRequest: Boolean + private val isThumbnailRequest: Boolean ) : RequestListener { override fun onLoadFailed( @@ -261,7 +261,7 @@ class ViewImageFragment : ViewMediaFragment() { isFirstResource: Boolean ): Boolean { // If cache for full image failed complete transition - if (isCacheRequest && !isThumnailRequest && shouldStartTransition && + if (isCacheRequest && !isThumbnailRequest && shouldStartTransition && !startedTransition ) { photoActionsListener.onBringUp() @@ -295,7 +295,7 @@ class ViewImageFragment : ViewMediaFragment() { } } else { // This wait for transition. If there's no transition then we should hit - // another branch. take() will unsubscribe after we have it to not leak menmory + // another branch. take() will unsubscribe after we have it to not leak memory transition .take(1) .subscribe { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 89c65e10a..686e4fdfe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -22,7 +22,7 @@ import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.entity.Attachment abstract class ViewMediaFragment : Fragment() { - private var toolbarVisibiltyDisposable: Function0? = null + private var toolbarVisibilityDisposable: Function0? = null abstract fun setupMediaView( url: String, @@ -83,14 +83,14 @@ abstract class ViewMediaFragment : Fragment() { isDescriptionVisible = showingDescription setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible) - toolbarVisibiltyDisposable = (activity as ViewMediaActivity) + toolbarVisibilityDisposable = (activity as ViewMediaActivity) .addToolbarVisibilityListener { isVisible -> onToolbarVisibilityChange(isVisible) } } override fun onDestroyView() { - toolbarVisibiltyDisposable?.invoke() + toolbarVisibilityDisposable?.invoke() super.onDestroyView() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java deleted file mode 100644 index 87be7e03c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ /dev/null @@ -1,693 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.text.TextUtils; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.arch.core.util.Function; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.snackbar.Snackbar; -import com.keylesspalace.tusky.AccountListActivity; -import com.keylesspalace.tusky.BaseActivity; -import com.keylesspalace.tusky.BuildConfig; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.ViewThreadActivity; -import com.keylesspalace.tusky.adapter.ThreadAdapter; -import com.keylesspalace.tusky.appstore.BlockEvent; -import com.keylesspalace.tusky.appstore.BookmarkEvent; -import com.keylesspalace.tusky.appstore.EventHub; -import com.keylesspalace.tusky.appstore.FavoriteEvent; -import com.keylesspalace.tusky.appstore.PinEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.appstore.StatusComposedEvent; -import com.keylesspalace.tusky.appstore.StatusDeletedEvent; -import com.keylesspalace.tusky.components.compose.ComposeViewModelKt; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Filter; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.network.FilterModel; -import com.keylesspalace.tusky.network.MastodonApi; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.ConversationLineItemDecoration; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; - -import javax.inject.Inject; - -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import kotlin.collections.CollectionsKt; - -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -public final class ViewThreadFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, StatusActionListener, Injectable { - private static final String TAG = "ViewThreadFragment"; - - @Inject - public MastodonApi mastodonApi; - @Inject - public EventHub eventHub; - @Inject - public FilterModel filterModel; - - private SwipeRefreshLayout swipeRefreshLayout; - private RecyclerView recyclerView; - private ThreadAdapter adapter; - private String thisThreadsStatusId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - - private int statusIndex = 0; - - private final PairedList statuses = - new PairedList<>(new Function() { - @Override - public StatusViewData.Concrete apply(Status status) { - return ViewDataUtils.statusToViewData( - status, - alwaysShowSensitiveMedia || !status.getActionableStatus().getSensitive(), - alwaysOpenSpoiler, - true - ); - } - }); - - public static ViewThreadFragment newInstance(String id) { - Bundle arguments = new Bundle(1); - ViewThreadFragment fragment = new ViewThreadFragment(); - arguments.putString("id", id); - fragment.setArguments(arguments); - return fragment; - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - thisThreadsStatusId = getArguments().getString("id"); - SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(getActivity()); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - preferences.getBoolean("showCardsInTimelines", false) ? - CardViewMode.INDENTED : - CardViewMode.NONE, - preferences.getBoolean("confirmReblogs", true), - preferences.getBoolean("confirmFavourites", false), - preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain()) - ); - adapter = new ThreadAdapter(statusDisplayOptions, this); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); - - Context context = getContext(); - swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); - swipeRefreshLayout.setOnRefreshListener(this); - swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - recyclerView = rootView.findViewById(R.id.recyclerView); - recyclerView.setHasFixedSize(true); - LinearLayoutManager layoutManager = new LinearLayoutManager(context); - recyclerView.setLayoutManager(layoutManager); - recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(recyclerView, this, statuses::getPairedItemOrNull)); - DividerItemDecoration divider = new DividerItemDecoration( - context, layoutManager.getOrientation()); - recyclerView.addItemDecoration(divider); - - recyclerView.addItemDecoration(new ConversationLineItemDecoration(context)); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - reloadFilters(); - - recyclerView.setAdapter(adapter); - - statuses.clear(); - - ((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - return rootView; - } - - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - onRefresh(); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - handleFavEvent((FavoriteEvent) event); - } else if (event instanceof ReblogEvent) { - handleReblogEvent((ReblogEvent) event); - } else if (event instanceof BookmarkEvent) { - handleBookmarkEvent((BookmarkEvent) event); - } else if (event instanceof PinEvent) { - handlePinEvent(((PinEvent) event)); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof StatusComposedEvent) { - handleStatusComposedEvent((StatusComposedEvent) event); - } else if (event instanceof StatusDeletedEvent) { - handleStatusDeletedEvent((StatusDeletedEvent) event); - } - }); - } - - public void onRevealPressed() { - boolean allExpanded = allExpanded(); - for (int i = 0; i < statuses.size(); i++) { - updateViewData(i, statuses.getPairedItem(i).copyWithExpanded(!allExpanded)); - } - updateRevealIcon(); - } - - private boolean allExpanded() { - boolean allExpanded = true; - for (int i = 0; i < statuses.size(); i++) { - if (!statuses.getPairedItem(i).isExpanded()) { - allExpanded = false; - break; - } - } - return allExpanded; - } - - @Override - public void onRefresh() { - sendStatusRequest(thisThreadsStatusId); - sendThreadRequest(thisThreadsStatusId); - } - - @Override - public void onReply(int position) { - super.reply(statuses.get(position)); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Status status = statuses.get(position); - - timelineCases.reblog(statuses.get(position).getId(), reblog) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - this::replaceStatus, - (t) -> Log.d(TAG, - "Failed to reblog status: " + status.getId(), t) - ); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Status status = statuses.get(position); - - timelineCases.favourite(statuses.get(position).getId(), favourite) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - this::replaceStatus, - (t) -> Log.d(TAG, - "Failed to favourite status: " + status.getId(), t) - ); - } - - @Override - public void onQuote(int position) { - super.quote(statuses.get(position)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Status status = statuses.get(position); - - timelineCases.bookmark(statuses.get(position).getId(), bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - this::replaceStatus, - (t) -> Log.d(TAG, - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void replaceStatus(Status status) { - updateStatus(status.getId(), (__) -> status); - } - - private void updateStatus(String statusId, Function mapper) { - int position = indexOfStatus(statusId); - - if (position >= 0 && position < statuses.size()) { - Status oldStatus = statuses.get(position); - Status newStatus = mapper.apply(oldStatus); - StatusViewData.Concrete oldViewData = statuses.getPairedItem(position); - statuses.set(position, newStatus); - updateViewData(position, oldViewData.copyWithStatus(newStatus)); - } - } - - @Override - public void onMore(@NonNull View view, int position) { - super.more(statuses.get(position), view, position); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { - Status status = statuses.get(position); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); - } - - @Override - public void onViewThread(int position) { - Status status = statuses.get(position); - if (thisThreadsStatusId.equals(status.getId())) { - // If already viewing this thread, don't reopen it. - return; - } - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onViewUrl(String url, String text) { - Status status = null; - if (!statuses.isEmpty()) { - status = statuses.get(statusIndex); - } - if (status != null && status.getUrl().equals(url)) { - // already viewing the status with this url - // probably just a preview federated and the user is clicking again to view more -> open the browser - // this can happen with some friendica statuses - LinkHelper.openLink(requireContext(), url); - return; - } - super.onViewUrl(url, text); - } - - @Override - public void onOpenReblog(int position) { - // there should be no reblogs in the thread but let's implement it to be sure - super.openReblog(statuses.get(position)); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewData( - position, - statuses.getPairedItem(position).copyWithExpanded(expanded) - ); - updateRevealIcon(); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewData( - position, - statuses.getPairedItem(position).copyWithShowingContent(isShowing) - ); - } - - private void updateViewData(int position, StatusViewData.Concrete newViewData) { - statuses.setPairedItem(position, newViewData); - adapter.setItem(position, newViewData, true); - } - - @Override - public void onLoadMore(int position) { - - } - - @Override - public void onShowReblogs(int position) { - String statusId = statuses.get(position).getId(); - Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId); - ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); - } - - @Override - public void onShowFavs(int position) { - String statusId = statuses.get(position).getId(); - Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId); - ((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent); - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - adapter.setItem( - position, - statuses.getPairedItem(position).copyWithCollapsed(isCollapsed), - true - ); - } - - @Override - public void onViewTag(String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(String id) { - super.viewAccount(id); - } - - @Override - public void removeItem(int position) { - if (position == statusIndex) { - //the status got removed, close the activity - getActivity().finish(); - } - statuses.remove(position); - adapter.setStatuses(statuses.getPairedCopy()); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Status status = statuses.get(position).getActionableStatus(); - - setVoteForPoll(status.getId(), status.getPoll().votedCopy(choices)); - - timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(status.getId(), newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - - } - - private void setVoteForPoll(String statusId, Poll newPoll) { - updateStatus(statusId, s -> s.copyWithPoll(newPoll)); - } - - private void removeAllByAccountId(String accountId) { - Status status = null; - if (!statuses.isEmpty()) { - status = statuses.get(statusIndex); - } - // using iterator to safely remove items while iterating - Iterator iterator = statuses.iterator(); - while (iterator.hasNext()) { - Status s = iterator.next(); - if (s.getAccount().getId().equals(accountId) || s.getActionableStatus().getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - statusIndex = statuses.indexOf(status); - if (statusIndex == -1) { - //the status got removed, close the activity - getActivity().finish(); - return; - } - adapter.setDetailedStatusPosition(statusIndex); - adapter.setStatuses(statuses.getPairedCopy()); - } - - private void sendStatusRequest(final String id) { - mastodonApi.status(id) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - status -> { - int position = setStatus(status); - recyclerView.scrollToPosition(position); - }, - throwable -> onThreadRequestFailure(id, throwable) - ); - } - - private void sendThreadRequest(final String id) { - mastodonApi.statusContext(id) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - context -> { - swipeRefreshLayout.setRefreshing(false); - setContext(context.getAncestors(), context.getDescendants()); - }, - throwable -> onThreadRequestFailure(id, throwable) - ); - } - - private void onThreadRequestFailure(final String id, final Throwable throwable) { - View view = getView(); - swipeRefreshLayout.setRefreshing(false); - if (view != null) { - Snackbar.make(view, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry, v -> { - sendThreadRequest(id); - sendStatusRequest(id); - }) - .show(); - } else { - Log.e(TAG, "Network request failed", throwable); - } - } - - private int setStatus(Status status) { - if (statuses.size() > 0 - && statusIndex < statuses.size() - && statuses.get(statusIndex).getId().equals(status.getId())) { - // Do not add this status on refresh, it's already in there. - statuses.set(statusIndex, status); - return statusIndex; - } - int i = statusIndex; - statuses.add(i, status); - adapter.setDetailedStatusPosition(i); - adapter.addItem(i, statuses.getPairedItem(i)); - updateRevealIcon(); - return i; - } - - private void setContext(List unfilteredAncestors, List unfilteredDescendants) { - Status mainStatus = null; - - // In case of refresh, remove old ancestors and descendants first. We'll remove all blindly, - // as we have no guarantee on their order to be the same as before - int oldSize = statuses.size(); - if (oldSize > 1) { - mainStatus = statuses.get(statusIndex); - statuses.clear(); - adapter.clearItems(); - } - - ArrayList ancestors = new ArrayList<>(); - for (Status status : unfilteredAncestors) - if (!filterModel.shouldFilterStatus(status)) - ancestors.add(status); - - // Insert newly fetched ancestors - statusIndex = ancestors.size(); - adapter.setDetailedStatusPosition(statusIndex); - statuses.addAll(0, ancestors); - List ancestorsViewDatas = statuses.getPairedCopy().subList(0, statusIndex); - if (BuildConfig.DEBUG && ancestors.size() != ancestorsViewDatas.size()) { - String error = String.format(Locale.getDefault(), - "Incorrectly got statusViewData sublist." + - " ancestors.size == %d ancestorsViewDatas.size == %d," + - " statuses.size == %d", - ancestors.size(), ancestorsViewDatas.size(), statuses.size()); - throw new AssertionError(error); - } - adapter.addAll(0, ancestorsViewDatas); - - if (mainStatus != null) { - // In case we needed to delete everything (which is way easier than deleting - // everything except one), re-insert the remaining status here. - // Not filtering the main status, since the user explicitly chose to be here - statuses.add(statusIndex, mainStatus); - StatusViewData.Concrete viewData = statuses.getPairedItem(statusIndex); - - adapter.addItem(statusIndex, viewData); - } - - ArrayList descendants = new ArrayList<>(); - for (Status status : unfilteredDescendants) - if (!filterModel.shouldFilterStatus(status)) - descendants.add(status); - - // Insert newly fetched descendants - statuses.addAll(descendants); - List descendantsViewData; - descendantsViewData = statuses.getPairedCopy() - .subList(statuses.size() - descendants.size(), statuses.size()); - if (BuildConfig.DEBUG && descendants.size() != descendantsViewData.size()) { - String error = String.format(Locale.getDefault(), - "Incorrectly got statusViewData sublist." + - " descendants.size == %d descendantsViewData.size == %d," + - " statuses.size == %d", - descendants.size(), descendantsViewData.size(), statuses.size()); - throw new AssertionError(error); - } - adapter.addAll(descendantsViewData); - updateRevealIcon(); - } - - private void handleFavEvent(FavoriteEvent event) { - updateStatus(event.getStatusId(), (s) -> { - s.setFavourited(event.getFavourite()); - return s; - }); - } - - private void handleReblogEvent(ReblogEvent event) { - updateStatus(event.getStatusId(), (s) -> { - s.setReblogged(event.getReblog()); - return s; - }); - } - - private void handleBookmarkEvent(BookmarkEvent event) { - updateStatus(event.getStatusId(), (s) -> { - s.setBookmarked(event.getBookmark()); - return s; - }); - } - - private void handlePinEvent(PinEvent event) { - updateStatus(event.getStatusId(), (s) -> s.copyWithPinned(event.getPinned())); - } - - - private void handleStatusComposedEvent(StatusComposedEvent event) { - Status eventStatus = event.getStatus(); - if (eventStatus.getInReplyToId() == null) return; - - if (eventStatus.getInReplyToId().equals(thisThreadsStatusId)) { - insertStatus(eventStatus, statuses.size()); - } else { - // If new status is a reply to some status in the thread, insert new status after it - // We only check statuses below main status, ones on top don't belong to this thread - for (int i = statusIndex; i < statuses.size(); i++) { - Status status = statuses.get(i); - if (eventStatus.getInReplyToId().equals(status.getId())) { - insertStatus(eventStatus, i + 1); - break; - } - } - } - } - - private void insertStatus(Status status, int at) { - statuses.add(at, status); - adapter.addItem(at, statuses.getPairedItem(at)); - } - - private void handleStatusDeletedEvent(StatusDeletedEvent event) { - int index = this.indexOfStatus(event.getStatusId()); - if (index != -1) { - statuses.remove(index); - adapter.removeItem(index); - } - } - - - private int indexOfStatus(String statusId) { - return CollectionsKt.indexOfFirst(this.statuses, (s) -> s.getId().equals(statusId)); - } - - private void updateRevealIcon() { - ViewThreadActivity activity = ((ViewThreadActivity) getActivity()); - if (activity == null) return; - - boolean hasAnyWarnings = false; - // Statuses are updated from the main thread so nothing should change while iterating - for (int i = 0; i < statuses.size(); i++) { - if (!TextUtils.isEmpty(statuses.get(i).getSpoilerText())) { - hasAnyWarnings = true; - break; - } - } - if (!hasAnyWarnings) { - activity.setRevealButtonState(ViewThreadActivity.REVEAL_BUTTON_HIDDEN); - return; - } - activity.setRevealButtonState(allExpanded() ? ViewThreadActivity.REVEAL_BUTTON_HIDE : - ViewThreadActivity.REVEAL_BUTTON_REVEAL); - } - - private void reloadFilters() { - mastodonApi.getFilters() - .to(autoDisposable(AndroidLifecycleScopeProvider.from(this))) - .subscribe( - (filters) -> { - List relevantFilters = CollectionsKt.filter( - filters, - (f) -> f.getContext().contains(Filter.THREAD) - ); - filterModel.initWithFilters(relevantFilters); - - recyclerView.post(this::applyFilters); - }, - (t) -> Log.e(TAG, "Failed to load filters", t) - ); - } - - private void applyFilters() { - CollectionsKt.removeAll(this.statuses, filterModel::shouldFilterStatus); - adapter.setStatuses(this.statuses.getPairedCopy()); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index a1930da8d..214741a8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -171,12 +171,11 @@ class ViewVideoFragment : ViewMediaFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val attachment = arguments?.getParcelable(ARG_ATTACHMENT) - val url: String if (attachment == null) { throw IllegalArgumentException("attachment has to be set") } - url = attachment.url + val url = attachment.url isAudio = attachment.type == Attachment.Type.AUDIO finalizeViewSetup(url, attachment.previewUrl, attachment.description) } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 062191ad0..16a439d95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -3,6 +3,8 @@ package com.keylesspalace.tusky.network import android.text.TextUtils import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.util.parseAsMastodonHtml +import java.util.Date import java.util.regex.Pattern import javax.inject.Inject @@ -33,11 +35,12 @@ class FilterModel @Inject constructor() { .mapNotNull { it.description } return ( - matcher.reset(status.actionableStatus.content).find() || + matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || ( attachmentsDescriptions.isNotEmpty() && - matcher.reset(attachmentsDescriptions.joinToString("\n")).find() + matcher.reset(attachmentsDescriptions.joinToString("\n")) + .find() ) ) } @@ -53,8 +56,11 @@ class FilterModel @Inject constructor() { } private fun makeFilter(filters: List): Pattern? { - if (filters.isEmpty()) return null - val tokens = filters.map { filterToRegexToken(it) } + val now = Date() + val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } + if (nonExpiredFilters.isEmpty()) return null + val tokens = nonExpiredFilters + .map { filterToRegexToken(it) } return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE) } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index e1d18e9f6..f92729941 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList @@ -78,43 +79,44 @@ interface MastodonApi { suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(): NetworkResult + suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult @GET("api/v1/filters") fun getFilters(): Single> @GET("api/v1/timelines/home") - fun homeTimeline( + @Throws(Exception::class) + suspend fun homeTimeline( @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null - ): Single>> + ): Response> @GET("api/v1/timelines/public") - fun publicTimeline( + suspend fun publicTimeline( @Query("local") local: Boolean? = null, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null - ): Single>> + ): Response> @GET("api/v1/timelines/tag/{hashtag}") - fun hashtagTimeline( + suspend fun hashtagTimeline( @Path("hashtag") hashtag: String, @Query("any[]") any: List?, @Query("local") local: Boolean?, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? - ): Single>> + ): Response> @GET("api/v1/timelines/list/{listId}") - fun listTimeline( + suspend fun listTimeline( @Path("listId") listId: String, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? - ): Single>> + ): Response> @GET("api/v1/notifications") fun notifications( @@ -145,7 +147,8 @@ interface MastodonApi { @PUT("api/v1/media/{mediaId}") suspend fun updateMedia( @Path("mediaId") mediaId: String, - @Field("description") description: String + @Field("description") description: String?, + @Field("focus") focus: String? ): NetworkResult @GET("api/v1/media/{mediaId}") @@ -166,10 +169,15 @@ interface MastodonApi { @Path("id") statusId: String ): Single - @GET("api/v1/statuses/{id}/context") - fun statusContext( + @GET("api/v1/statuses/{id}") + suspend fun statusAsync( @Path("id") statusId: String - ): Single + ): NetworkResult + + @GET("api/v1/statuses/{id}/context") + suspend fun statusContext( + @Path("id") statusId: String + ): NetworkResult @GET("api/v1/statuses/{id}/reblogged_by") fun statusRebloggedBy( @@ -311,15 +319,15 @@ interface MastodonApi { * @param onlyMedia only return statuses that have media attached */ @GET("api/v1/accounts/{id}/statuses") - fun accountStatuses( + suspend fun accountStatuses( @Path("id") accountId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_replies") excludeReplies: Boolean?, - @Query("only_media") onlyMedia: Boolean?, - @Query("pinned") pinned: Boolean? - ): Single>> + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("limit") limit: Int? = null, + @Query("exclude_replies") excludeReplies: Boolean? = null, + @Query("only_media") onlyMedia: Boolean? = null, + @Query("pinned") pinned: Boolean? = null + ): Response> @GET("api/v1/accounts/{id}/followers") fun accountFollowers( @@ -413,18 +421,18 @@ interface MastodonApi { fun unblockDomain(@Field("domain") domain: String): Call @GET("api/v1/favourites") - fun favourites( + suspend fun favourites( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? - ): Single>> + ): Response> @GET("api/v1/bookmarks") - fun bookmarks( + suspend fun bookmarks( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? - ): Single>> + ): Response> @GET("api/v1/follow_requests") fun followRequests( @@ -525,29 +533,29 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/filters") - fun createFilter( + suspend fun createFilter( @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresIn: String? - ): Call + @Field("expires_in") expiresInSeconds: Int? + ): NetworkResult @FormUrlEncoded @PUT("api/v1/filters/{id}") - fun updateFilter( + suspend fun updateFilter( @Path("id") id: String, @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, - @Field("expires_in") expiresIn: String? - ): Call + @Field("expires_in") expiresInSeconds: Int? + ): NetworkResult @DELETE("api/v1/filters/{id}") - fun deleteFilter( + suspend fun deleteFilter( @Path("id") id: String - ): Call + ): NetworkResult @FormUrlEncoded @POST("api/v1/polls/{id}/votes") @@ -656,4 +664,13 @@ interface MastodonApi { @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, ): NetworkResult + + @GET("api/v1/tags/{name}") + suspend fun tag(@Path("name") name: String): NetworkResult + + @POST("api/v1/tags/{name}/follow") + suspend fun followTag(@Path("name") name: String): NetworkResult + + @POST("api/v1/tags/{name}/unfollow") + suspend fun unfollowTag(@Path("name") name: String): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt index a179e71d2..24636a641 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt @@ -15,6 +15,7 @@ interface MediaUploadApi { @POST("api/v2/media") suspend fun uploadMedia( @Part file: MultipartBody.Part, - @Part description: MultipartBody.Part? = null + @Part description: MultipartBody.Part? = null, + @Part focus: MultipartBody.Part? = null ): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 63ec04f1d..4e547fe5f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -18,13 +18,10 @@ package com.keylesspalace.tusky.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.graphics.Color import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput -import androidx.core.content.ContextCompat -import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager @@ -66,7 +63,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) .setSmallIcon(R.drawable.ic_notify) - .setColor(ContextCompat.getColor(context, R.color.tusky_blue)) + .setColor(context.getColor(R.color.tusky_blue)) .setGroup(senderFullName) .setDefaults(0) // So it doesn't ring twice, notify only in Target callback @@ -92,6 +89,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { mediaIds = emptyList(), mediaUris = emptyList(), mediaDescriptions = emptyList(), + mediaFocus = emptyList(), scheduledAt = null, inReplyToId = citedStatusId, poll = null, @@ -102,21 +100,16 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { draftId = -1, idempotencyKey = randomAlphanumericString(16), retries = 0, - mediaProcessed = mutableListOf() + mediaProcessed = mutableListOf(), + null, ) ) context.startService(sendIntent) - val color = if (BuildConfig.FLAVOR == "green") { - Color.parseColor("#19A341") - } else { - ContextCompat.getColor(context, R.color.tusky_blue) - } - val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) .setSmallIcon(R.drawable.ic_notify) - .setColor(color) + .setColor(context.getColor(R.color.notification_color)) .setGroup(senderFullName) .setDefaults(0) // So it doesn't ring twice, notify only in Target callback @@ -136,6 +129,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { private fun getReplyMessage(intent: Intent): CharSequence { val remoteInput = RemoteInput.getResultsFromIntent(intent) - return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "") + return remoteInput?.getCharSequence(NotificationHelper.KEY_REPLY, "") ?: "" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index b3c6a72b9..f7a70a07d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.service +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -12,10 +13,11 @@ import android.os.Build import android.os.IBinder import android.os.Parcelable import android.util.Log +import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat import at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusComposedEvent @@ -24,6 +26,7 @@ import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status @@ -69,7 +72,7 @@ class SendStatusService : Service(), Injectable { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(KEY_STATUS)) { - val statusToSend = intent.getParcelableExtra(KEY_STATUS) + val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS) ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -88,7 +91,7 @@ class SendStatusService : Service(), Injectable { .setContentText(notificationText) .setProgress(1, 0, true) .setOngoing(true) - .setColor(ContextCompat.getColor(this, R.color.notification_color)) + .setColor(getColor(R.color.notification_color)) .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -134,8 +137,15 @@ class SendStatusService : Service(), Injectable { delay(1000L * mediaCheckRetries) statusToSend.mediaProcessed.forEachIndexed { index, processed -> if (!processed) { - // Mastodon returns 206 if the media was not yet processed - statusToSend.mediaProcessed[index] = mastodonApi.getMedia(statusToSend.mediaIds[index]).code() == 200 + when (mastodonApi.getMedia(statusToSend.mediaIds[index]).code()) { + 200 -> statusToSend.mediaProcessed[index] = true // success + 206 -> { } // media is still being processed, continue checking + else -> { // some kind of server error, retrying probably doesn't make sense + failSending(statusId) + stopSelfWhenDone() + return@launch + } + } } } mediaCheckRetries ++ @@ -155,6 +165,7 @@ class SendStatusService : Service(), Injectable { statusToSend.mediaIds, statusToSend.scheduledAt, statusToSend.poll, + statusToSend.language, statusToSend.quoteId, ) @@ -183,22 +194,7 @@ class SendStatusService : Service(), Injectable { Log.w(TAG, "failed sending status", throwable) if (throwable is HttpException) { // the server refused to accept the status, save status & show error message - statusesToSend.remove(statusId) - saveStatusToDrafts(statusToSend) - - val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_post_notification_error_title)) - .setContentText(getString(R.string.send_post_notification_saved_content)) - .setColor( - ContextCompat.getColor( - this@SendStatusService, - R.color.notification_color - ) - ) - - notificationManager.cancel(statusId) - notificationManager.notify(errorNotificationId--, builder.build()) + failSending(statusId) } else { // a network problem occurred, let's retry sending the status retrySending(statusId) @@ -226,6 +222,24 @@ class SendStatusService : Service(), Injectable { } } + private suspend fun failSending(statusId: Int) { + val failedStatus = statusesToSend.remove(statusId) + if (failedStatus != null) { + + saveStatusToDrafts(failedStatus) + + val notification = buildDraftNotification( + R.string.send_post_notification_error_title, + R.string.send_post_notification_saved_content, + failedStatus.accountId, + statusId + ) + + notificationManager.cancel(statusId) + notificationManager.notify(errorNotificationId++, notification) + } + } + private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { @@ -234,15 +248,18 @@ class SendStatusService : Service(), Injectable { saveStatusToDrafts(statusToCancel) - val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_post_notification_cancel_title)) - .setContentText(getString(R.string.send_post_notification_saved_content)) - .setColor(ContextCompat.getColor(this@SendStatusService, R.color.notification_color)) + val notification = buildDraftNotification( + R.string.send_post_notification_cancel_title, + R.string.send_post_notification_saved_content, + statusToCancel.accountId, + statusId + ) - notificationManager.notify(statusId, builder.build()) + notificationManager.notify(statusId, notification) delay(5000) + + stopSelfWhenDone() } } @@ -257,15 +274,52 @@ class SendStatusService : Service(), Injectable { visibility = Status.Visibility.byString(status.visibility), mediaUris = status.mediaUris, mediaDescriptions = status.mediaDescriptions, + mediaFocus = status.mediaFocus, poll = status.poll, - failedToSend = true + failedToSend = true, + scheduledAt = status.scheduledAt, + language = status.language, ) } private fun cancelSendingIntent(statusId: Int): PendingIntent { val intent = Intent(this, SendStatusService::class.java) intent.putExtra(KEY_CANCEL, statusId) - return PendingIntent.getService(this, statusId, intent, NotificationHelper.pendingIntentFlags(false)) + return PendingIntent.getService( + this, + statusId, + intent, + NotificationHelper.pendingIntentFlags(false) + ) + } + + private fun buildDraftNotification( + @StringRes title: Int, + @StringRes content: Int, + accountId: Long, + statusId: Int + ): Notification { + + val intent = Intent(this, MainActivity::class.java) + intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) + intent.putExtra(MainActivity.OPEN_DRAFTS, true) + + val pendingIntent = PendingIntent.getActivity( + this, + statusId, + intent, + NotificationHelper.pendingIntentFlags(false) + ) + + return NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(title)) + .setContentText(getString(content)) + .setColor(getColor(R.color.notification_color)) + .setAutoCancel(true) + .setOngoing(false) + .setContentIntent(pendingIntent) + .build() } override fun onDestroy() { @@ -285,7 +339,6 @@ class SendStatusService : Service(), Injectable { private var sendingNotificationId = -1 // use negative ids to not clash with other notis private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis - @JvmStatic fun sendStatusIntent( context: Context, statusToSend: StatusToSend @@ -323,6 +376,7 @@ data class StatusToSend( val mediaIds: List, val mediaUris: List, val mediaDescriptions: List, + val mediaFocus: List, val scheduledAt: String?, val inReplyToId: String?, val poll: NewPoll?, @@ -333,5 +387,6 @@ data class StatusToSend( val draftId: Int, val idempotencyKey: String, var retries: Int, - val mediaProcessed: MutableList + val mediaProcessed: MutableList, + val language: String?, ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 15238999d..f5c92f507 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -27,6 +27,7 @@ object PrefKeys { const val SHOW_BOT_OVERLAY = "showBotOverlay" const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" + const val SHOW_SELF_USERNAME = "showSelfUsername" const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val CONFIRM_REBLOGS = "confirmReblogs" diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index 8f1144340..570e1e392 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.getServerErrorMessage import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.addTo @@ -130,6 +131,10 @@ class TimelineCases @Inject constructor( fun pin(statusId: String, pin: Boolean): Single { // Replace with extension method if we use RxKotlin return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId)) + .doOnError { e -> + Log.w("Failed to change pin state", e) + } + .onErrorResumeNext(::convertError) .doAfterSuccess { eventHub.dispatch(PinEvent(statusId, pin)) } @@ -144,4 +149,10 @@ class TimelineCases @Inject constructor( eventHub.dispatch(PollVoteEvent(statusId, it)) } } + + private fun convertError(e: Throwable): Single { + return Single.error(TimelineError(e.getServerErrorMessage())) + } } + +class TimelineError(message: String?) : RuntimeException(message) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt b/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt new file mode 100644 index 000000000..8f1101d29 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AsciiFolding.kt @@ -0,0 +1,26 @@ +/* Copyright 2022 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.util + +// Inspired by https://github.com/mastodon/mastodon/blob/main/app/lib/ascii_folding.rb + +val unicodeToASCIIMap = "ÀÁÂÃÄÅàáâãäåĀāĂ㥹ÇçĆćĈĉĊċČčÐðĎďĐđÈÉÊËèéêëĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħÌÍÎÏìíîïĨĩĪīĬĭĮįİıĴĵĶķĸĹĺĻļĽľĿŀŁłÑñŃńŅņŇňʼnŊŋÒÓÔÕÖØòóôõöøŌōŎŏŐőŔŕŖŗŘřŚśŜŝŞşŠšſŢţŤťŦŧÙÚÛÜùúûüŨũŪūŬŭŮůŰűŲųŴŵÝýÿŶŷŸŹźŻżŽž".toList().zip( + "AAAAAAaaaaaaAaAaAaCcCcCcCcCcDdDdDdEEEEeeeeEeEeEeEeEeGgGgGgGgHhHhIIIIiiiiIiIiIiIiIiJjKkkLlLlLlLlLlNnNnNnNnnNnOOOOOOooooooOoOoOoRrRrRrSsSsSsSssTtTtTtUUUUuuuuUuUuUuUuUuUuWwYyyYyYZzZzZz".toList() +).toMap() + +fun normalizeToASCII(text: CharSequence): CharSequence { + return String(text.map { unicodeToASCIIMap[it] ?: it }.toCharArray()) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt new file mode 100644 index 000000000..6307e7211 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AttachmentHelper.kt @@ -0,0 +1,26 @@ +@file:JvmName("AttachmentHelper") +package com.keylesspalace.tusky.util + +import android.content.Context +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Attachment +import kotlin.math.roundToInt + +fun Attachment.getFormattedDescription(context: Context): CharSequence { + var duration = "" + if (meta?.duration != null && meta.duration > 0) { + duration = formatDuration(meta.duration.toDouble()) + " " + } + return if (description.isNullOrEmpty()) { + duration + context.getString(R.string.description_post_media_no_description_placeholder) + } else { + duration + description + } +} + +private fun formatDuration(durationInSeconds: Double): String { + val seconds = durationInSeconds.roundToInt() % 60 + val minutes = durationInSeconds.toInt() % 3600 / 60 + val hours = durationInSeconds.toInt() / 3600 + return "%d:%02d:%02d".format(hours, minutes, seconds) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java deleted file mode 100644 index 7c3b68a7f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.java +++ /dev/null @@ -1,71 +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 . */ - -package com.keylesspalace.tusky.util; - -import android.content.ContentResolver; -import android.net.Uri; -import androidx.annotation.Nullable; - -import java.io.Closeable; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; - -public class IOUtils { - - private static final int DEFAULT_BLOCKSIZE = 16384; - - public static void closeQuietly(@Nullable Closeable stream) { - try { - if (stream != null) { - stream.close(); - } - } catch (IOException e) { - // intentionally unhandled - } - } - - public static boolean copyToFile(ContentResolver contentResolver, Uri uri, File file) { - InputStream from; - FileOutputStream to; - try { - from = contentResolver.openInputStream(uri); - to = new FileOutputStream(file); - } catch (FileNotFoundException e) { - return false; - } - if (from == null) { - return false; - } - byte[] chunk = new byte[DEFAULT_BLOCKSIZE]; - try { - while (true) { - int bytes = from.read(chunk, 0, chunk.length); - if (bytes < 0) { - break; - } - to.write(chunk, 0, bytes); - } - } catch (IOException e) { - return false; - } - closeQuietly(from); - closeQuietly(to); - return true; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt new file mode 100644 index 000000000..005554bfa --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt @@ -0,0 +1,67 @@ +/* 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 . */ + +package com.keylesspalace.tusky.util + +import android.content.ContentResolver +import android.net.Uri +import java.io.Closeable +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream + +private const val DEFAULT_BLOCKSIZE = 16384 + +fun Closeable?.closeQuietly() { + try { + this?.close() + } catch (e: IOException) { + // intentionally unhandled + } +} + +fun Uri.copyToFile( + contentResolver: ContentResolver, + file: File, +): Boolean { + val from: InputStream? + val to: FileOutputStream + + try { + from = contentResolver.openInputStream(this) + to = FileOutputStream(file) + } catch (e: FileNotFoundException) { + return false + } + + if (from == null) return false + + val chunk = ByteArray(DEFAULT_BLOCKSIZE) + try { + while (true) { + val bytes = from.read(chunk, 0, chunk.size) + if (bytes < 0) break + to.write(chunk, 0, bytes) + } + } catch (e: IOException) { + return false + } + + from.closeQuietly() + to.closeQuietly() + return true +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 76c9cc3b7..e9c58f5a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -57,7 +57,9 @@ fun getDomain(urlString: String?): String { * @param listener to notify about particular spans that are clicked */ fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener) { - view.text = SpannableStringBuilder.valueOf(content).apply { + val spannableContent = markupHiddenUrls(view.context, content) + + view.text = spannableContent.apply { getSpans(0, content.length, URLSpan::class.java).forEach { setClickableText(it, this, mentions, tags, listener) } @@ -65,6 +67,40 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List?): String? { - val scrapedName = text.subSequence(1, text.length).toString() + val scrapedName = normalizeToASCII(text.subSequence(1, text.length)).toString() return when (tags) { null -> scrapedName else -> tags.firstOrNull { it.name.equals(scrapedName, true) }?.name @@ -204,7 +240,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { try { context.startActivity(intent) } catch (e: ActivityNotFoundException) { - Log.w(TAG, "Actvity was not found for intent, $intent") + Log.w(TAG, "Activity was not found for intent, $intent") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 501f90565..ed10bd4e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -48,7 +48,7 @@ class ListStatusAccessibilityDelegate( val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return if (status is StatusViewData.Concrete) { - if (!status.spoilerText.isNullOrEmpty()) { + if (status.spoilerText.isNotEmpty()) { info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) } @@ -135,7 +135,7 @@ class ListStatusAccessibilityDelegate( } R.id.action_expand_cw -> { // Toggling it directly to avoid animations - // which cannot be disabled for detaild status for some reason + // which cannot be disabled for detailed status for some reason val holder = recyclerView.getChildViewHolder(host) as StatusBaseViewHolder holder.toggleContentWarning() // Stop and restart narrator before it reads old description. diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt index 7cdc12e83..e7f03fca4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListUtils.kt @@ -52,7 +52,3 @@ inline fun List.replacedFirstWhich(replacement: T, predicate: (T) -> Bool } return newList } - -inline fun Iterable<*>.firstIsInstanceOrNull(): R? { - return firstOrNull { it is R }?.let { it as R } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt deleted file mode 100644 index 21c4307c6..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/LiveDataUtil.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* Copyright 2019 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.util - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.LiveDataReactiveStreams -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.Transformations -import io.reactivex.rxjava3.core.BackpressureStrategy -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single - -inline fun LiveData.map(crossinline mapFunction: (X) -> Y): LiveData = - Transformations.map(this) { input -> mapFunction(input) } - -inline fun LiveData.switchMap( - crossinline switchMapFunction: (X) -> LiveData -): LiveData = Transformations.switchMap(this) { input -> switchMapFunction(input) } - -inline fun LiveData.filter(crossinline predicate: (X) -> Boolean): LiveData { - val liveData = MediatorLiveData() - liveData.addSource(this) { value -> - if (predicate(value)) { - liveData.value = value - } - } - return liveData -} - -fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) = - LifecycleContext(this).apply(body) - -class LifecycleContext(val lifecycleOwner: LifecycleOwner) { - inline fun LiveData.observe(crossinline observer: (T) -> Unit) = - this.observe(lifecycleOwner, Observer { observer(it) }) - - /** - * Just hold a subscription, - */ - fun LiveData.subscribe() = - this.observe(lifecycleOwner, Observer { }) -} - -/** - * Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns - * [LiveData] with value set to the result of calling [combiner] with value of both. - * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. - */ -fun combineLiveData(a: LiveData, b: LiveData, combiner: (A, B) -> R): LiveData { - val liveData = MediatorLiveData() - liveData.addSource(a) { - if (a.value != null && b.value != null) { - liveData.value = combiner(a.value!!, b.value!!) - } - } - liveData.addSource(b) { - if (a.value != null && b.value != null) { - liveData.value = combiner(a.value!!, b.value!!) - } - } - return liveData -} - -/** - * Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b] - * after either changes. Doesn't check if either has value. - * Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked. - */ -fun combineOptionalLiveData(a: LiveData, b: LiveData, combiner: (A?, B?) -> R): LiveData { - val liveData = MediatorLiveData() - liveData.addSource(a) { - liveData.value = combiner(a.value, b.value) - } - liveData.addSource(b) { - liveData.value = combiner(a.value, b.value) - } - return liveData -} - -fun Single.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable()) -fun Observable.toLiveData( - backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST -) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST)) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index 5482b292b..408f64535 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -23,7 +23,6 @@ import android.graphics.Matrix import android.net.Uri import android.provider.OpenableColumns import android.util.Log -import androidx.annotation.Px import androidx.exifinterface.media.ExifInterface import java.io.File import java.io.FileNotFoundException @@ -68,43 +67,6 @@ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long { return mediaSize } -fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: Int, @Px reqHeight: Int): Bitmap? { - // First decode with inJustDecodeBounds=true to check dimensions - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - var stream: InputStream? - try { - stream = contentResolver.openInputStream(uri) - } catch (e: FileNotFoundException) { - Log.w(TAG, e) - return null - } - - BitmapFactory.decodeStream(stream, null, options) - - IOUtils.closeQuietly(stream) - - // Calculate inSampleSize - options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) - - // Decode bitmap with inSampleSize set - options.inJustDecodeBounds = false - return try { - stream = contentResolver.openInputStream(uri) - val bitmap = BitmapFactory.decodeStream(stream, null, options) - val orientation = getImageOrientation(uri, contentResolver) - reorientBitmap(bitmap, orientation) - } catch (e: FileNotFoundException) { - Log.w(TAG, e) - null - } catch (e: OutOfMemoryError) { - Log.e(TAG, "OutOfMemoryError while trying to get sampled Bitmap", e) - null - } finally { - IOUtils.closeQuietly(stream) - } -} - @Throws(FileNotFoundException::class) fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { val input = contentResolver.openInputStream(uri) @@ -113,7 +75,7 @@ fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { options.inJustDecodeBounds = true BitmapFactory.decodeStream(input, null, options) - IOUtils.closeQuietly(input) + input.closeQuietly() return (options.outWidth * options.outHeight).toLong() } @@ -196,11 +158,11 @@ fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int { exifInterface = ExifInterface(inputStream) } catch (e: IOException) { Log.w(TAG, e) - IOUtils.closeQuietly(inputStream) + inputStream.closeQuietly() return ExifInterface.ORIENTATION_UNDEFINED } val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - IOUtils.closeQuietly(inputStream) + inputStream.closeQuietly() return orientation } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index 078639acd..5e5100341 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -90,7 +90,7 @@ object SmartLengthInputFilter : InputFilter { keep = boundary } else { - // If no runway is allowed simply remove whitespaces if present + // If no runway is allowed simply remove whitespace if present while (source[keep - 1].isWhitespace()) { --keep if (keep == start) return "" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index 7734d9d7f..7087a165f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -12,13 +12,16 @@ import kotlin.math.max * @see * Tag#HASHTAG_RE. */ -private const val TAG_REGEX = "(?:^|[^/)A-Za-z0-9_])#([\\w_]*[\\p{Alpha}_][\\w_]*)" +private const val HASHTAG_SEPARATORS = "_\\u00B7\\u200c" +private const val UNICODE_WORD = "\\p{L}\\p{Mn}\\p{Nd}\\p{Nl}\\p{Pc}" // Ugh, java ( https://stackoverflow.com/questions/4304928/unicode-equivalents-for-w-and-b-in-java-regular-expressions ) +private const val TAG_REGEX = "(?:^|[^/)\\w])#(([${UNICODE_WORD}_][$UNICODE_WORD$HASHTAG_SEPARATORS]*[\\p{Alpha}$HASHTAG_SEPARATORS][$UNICODE_WORD$HASHTAG_SEPARATORS]*[${UNICODE_WORD}_])|([${UNICODE_WORD}_]*[\\p{Alpha}][${UNICODE_WORD}_]*))" /** * @see * Account#MENTION_RE */ -private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_-]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)" +private const val USERNAME_REGEX = "[\\w]+([\\w\\.-]+[\\w]+)?" +private const val MENTION_REGEX = "(?<=^|[^\\/$UNICODE_WORD])@(($USERNAME_REGEX)(?:@[$UNICODE_WORD\\.\\-]+[$UNICODE_WORD]+)?)" private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)" private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 0752c4e5c..253ea7a0a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -23,7 +23,6 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Attachment @@ -167,7 +166,7 @@ class StatusViewHelper(private val itemView: View) { mediaPreviews[3].layoutParams.height = mediaPreviewHeight } } - if (attachments.isNullOrEmpty()) { + if (attachments.isEmpty()) { sensitiveMediaWarning.visibility = View.GONE sensitiveMediaShow.visibility = View.GONE } else { @@ -319,7 +318,7 @@ class StatusViewHelper(private val itemView: View) { } pollResults[i].background.level = level - pollResults[i].background.setTint(ContextCompat.getColor(pollResults[i].context, optionColor)) + pollResults[i].background.setTint(pollResults[i].context.getColor(optionColor)) } else { pollResults[i].visibility = View.GONE } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt index e2db79c6e..5342cbf33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -32,17 +32,16 @@ class FragmentViewBindingDelegate( object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observe( - fragment, - { t -> - t?.lifecycle?.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } + fragment + ) { t -> + t?.lifecycle?.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null } - ) - } - ) + } + ) + } } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index fef9c0bb8..3facc3a9a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -25,13 +25,15 @@ import com.keylesspalace.tusky.viewdata.StatusViewData fun Status.toViewData( isShowingContent: Boolean, isExpanded: Boolean, - isCollapsed: Boolean + isCollapsed: Boolean, + isDetailed: Boolean = false ): StatusViewData.Concrete { return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, isExpanded = isExpanded, + isDetailed = isDetailed ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt index 444e71dc1..95605b18f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt @@ -12,6 +12,7 @@ class ExposedPlayPauseVideoView @JvmOverloads constructor( VideoView(context, attrs, defStyleAttr) { private var listener: PlayPauseListener? = null + private var playing = false fun setPlayPauseListener(listener: PlayPauseListener) { this.listener = listener @@ -19,12 +20,18 @@ class ExposedPlayPauseVideoView @JvmOverloads constructor( override fun start() { super.start() - listener?.onPlay() + if (!playing) { + playing = true + listener?.onPlay() + } } override fun pause() { super.pause() - listener?.onPause() + if (playing) { + playing = false + listener?.onPause() + } } interface PlayPauseListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt new file mode 100644 index 000000000..c6cea1e21 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/FilterDialog.kt @@ -0,0 +1,73 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.FiltersActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.DialogFilterBinding +import com.keylesspalace.tusky.entity.Filter +import java.util.Date + +fun showAddFilterDialog(activity: FiltersActivity) { + val binding = DialogFilterBinding.inflate(activity.layoutInflater) + binding.phraseWholeWord.isChecked = true + binding.filterDurationSpinner.adapter = ArrayAdapter( + activity, + android.R.layout.simple_list_item_1, + activity.resources.getStringArray(R.array.filter_duration_names) + ) + AlertDialog.Builder(activity) + .setTitle(R.string.filter_addition_dialog_title) + .setView(binding.root) + .setPositiveButton(android.R.string.ok) { _, _ -> + activity.createFilter( + binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked, + getSecondsForDurationIndex(binding.filterDurationSpinner.selectedItemPosition, activity) + ) + } + .setNeutralButton(android.R.string.cancel, null) + .show() +} + +fun setupEditDialogForFilter(activity: FiltersActivity, filter: Filter, itemIndex: Int) { + val binding = DialogFilterBinding.inflate(activity.layoutInflater) + binding.phraseEditText.setText(filter.phrase) + binding.phraseWholeWord.isChecked = filter.wholeWord + val filterNames = activity.resources.getStringArray(R.array.filter_duration_names).toMutableList() + if (filter.expiresAt != null) { + filterNames.add(0, activity.getString(R.string.duration_no_change)) + } + binding.filterDurationSpinner.adapter = ArrayAdapter(activity, android.R.layout.simple_list_item_1, filterNames) + + AlertDialog.Builder(activity) + .setTitle(R.string.filter_edit_dialog_title) + .setView(binding.root) + .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> + var index = binding.filterDurationSpinner.selectedItemPosition + if (filter.expiresAt != null) { + // We prepended "No changes", account for that here + --index + } + activity.updateFilter( + filter.id, binding.phraseEditText.text.toString(), filter.context, + filter.irreversible, binding.phraseWholeWord.isChecked, + getSecondsForDurationIndex(index, activity, filter.expiresAt), itemIndex + ) + } + .setNegativeButton(R.string.filter_dialog_remove_button) { _, _ -> + activity.deleteFilter(itemIndex) + } + .setNeutralButton(android.R.string.cancel, null) + .show() +} + +// Mastodon *stores* the absolute date in the filter, +// but create/edit take a number of seconds (relative to the time the operation is posted) +fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { + return when (index) { + -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } + 0 -> null + else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 8922fafd5..dc149e4be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -37,7 +37,7 @@ import com.keylesspalace.tusky.util.FocalPointUtil * However if there is no focal point set (e.g. it is null), then this view should simply * act exactly the same as an ordinary android ImageView. */ -class MediaPreviewImageView +open class MediaPreviewImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, @@ -70,7 +70,7 @@ class MediaPreviewImageView * Overridden getScaleType method which returns CENTER_CROP if we have a focal point set. * * This is necessary because the Android transitions framework tries to copy the scale type - * from this view to the PhotoView when animating between this view and the detailled view of + * from this view to the PhotoView when animating between this view and the detailed view of * the image. Since the PhotoView does not support a MATRIX scale type, the app would crash * if we simply passed that on, so instead we pretend that CENTER_CROP is still used here * even if we have a focus point set. diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index b0a8062f6..ae24cebe1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -1,22 +1,50 @@ +/* Copyright 2022 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + package com.keylesspalace.tusky.viewdata import android.os.Parcelable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class AttachmentViewData( val attachment: Attachment, val statusId: String, - val statusUrl: String + val statusUrl: String, + val sensitive: Boolean, + val isRevealed: Boolean ) : Parcelable { + + @IgnoredOnParcel + val id = attachment.id + companion object { @JvmStatic fun list(status: Status): List { val actionable = status.actionableStatus - return actionable.attachments.map { - AttachmentViewData(it, actionable.id, actionable.url!!) + return actionable.attachments.map { attachment -> + AttachmentViewData( + attachment = attachment, + statusId = actionable.id, + statusUrl = actionable.url!!, + sensitive = actionable.sensitive, + isRevealed = !actionable.sensitive + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 75f90ca40..2a25bb4cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -30,7 +30,7 @@ import java.util.Objects; * It is either a {@link Placeholder} or a {@link Concrete}. * It is modelled this way because close relationship between placeholder and concrete notification * is fine in this case. Placeholder case is not modelled as a type of notification because - * invariants would be violated and because it would model domain incorrectly. It is prefereable to + * invariants would be violated and because it would model domain incorrectly. It is preferable to * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and * more native. */ diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 9ea83c252..823cb81f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -42,6 +42,7 @@ sealed class StatusViewData { */ /** Whether the status meets the requirement to be collapse */ val isCollapsed: Boolean, + val isDetailed: Boolean = false ) : StatusViewData() { override val id: String get() = status.id diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index d640b233b..23cbb7f72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -24,8 +24,9 @@ import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Error @@ -34,6 +35,11 @@ import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -49,14 +55,18 @@ private const val AVATAR_FILE_NAME = "avatar.png" class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, - private val application: Application + private val application: Application, + private val instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { val profileData = MutableLiveData>() val avatarData = MutableLiveData() val headerData = MutableLiveData() val saveData = MutableLiveData>() - val instanceData = MutableLiveData>() + + @OptIn(FlowPreview::class) + val instanceData: Flow = instanceInfoRepo::getInstanceInfo.asFlow() + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) private var oldProfileData: Account? = null @@ -186,19 +196,4 @@ class EditProfileViewModel @Inject constructor( private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } - - fun obtainInstance() = viewModelScope.launch { - if (instanceData.value == null || instanceData.value is Error) { - instanceData.postValue(Loading()) - - mastodonApi.getInstance().fold( - { instance -> - instanceData.postValue(Success(instance)) - }, - { - instanceData.postValue(Error()) - } - ) - } - } } diff --git a/app/src/main/java/net/accelf/yuito/QuickTootView.kt b/app/src/main/java/net/accelf/yuito/QuickTootView.kt index c0a70a706..98698c0ca 100644 --- a/app/src/main/java/net/accelf/yuito/QuickTootView.kt +++ b/app/src/main/java/net/accelf/yuito/QuickTootView.kt @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.Event @@ -19,6 +20,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.PREF import com.keylesspalace.tusky.databinding.ViewQuickTootBinding import com.keylesspalace.tusky.settings.PrefKeys.USE_QUICK_TOOT import com.keylesspalace.tusky.util.ThemeUtils +import kotlinx.coroutines.launch import kotlin.properties.Delegates class QuickTootView @JvmOverloads constructor( @@ -42,9 +44,11 @@ class QuickTootView @JvmOverloads constructor( binding.buttonVisibility.attachViewModel(viewModel, owner) - viewModel.content.observe(owner) { - if (binding.editTextContent.text.toString() != it) { - binding.editTextContent.setText(it) + owner.lifecycleScope.launch { + viewModel.content.collect { content -> + if (binding.editTextContent.text.toString() != content) { + binding.editTextContent.setText(content) + } } } binding.editTextContent.addTextChangedListener(object : TextWatcher { @@ -55,20 +59,33 @@ class QuickTootView @JvmOverloads constructor( } }) - viewModel.inReplyTo.observe(owner) { - binding.textQuickReply.text = it?.let { "Reply to ${it.account.username}" } ?: "" + owner.lifecycleScope.launch { + viewModel.inReplyTo.collect { inReplyTo -> + binding.textQuickReply.text = + inReplyTo?.let { "Reply to ${it.account.username}" } ?: "" + } } - viewModel.defaultTag.observe(owner) { - binding.textDefaultTag.text = it?.let { "${context.getString(R.string.hint_default_text)} : $it" } - ?: "${context.getString(R.string.hint_default_text)} inactive" - binding.textDefaultTag.setTextColor(ThemeUtils.getColor(context, it?.let { R.attr.colorInfo } - ?: android.R.attr.textColorTertiary)) + owner.lifecycleScope.launch { + viewModel.defaultTag.collect { defaultTag -> + binding.textDefaultTag.text = + defaultTag?.let { "${context.getString(R.string.hint_default_text)} : $it" } + ?: "${context.getString(R.string.hint_default_text)} inactive" + binding.textDefaultTag.setTextColor( + ThemeUtils.getColor( + context, + defaultTag?.let { R.attr.colorInfo } + ?: android.R.attr.textColorTertiary + ) + ) + } } syncDefaultTag() - viewModel.visibility.observe(owner) { - binding.buttonToot.setStatusVisibility(it) + owner.lifecycleScope.launch { + viewModel.visibility.collect { visibility -> + binding.buttonToot.setStatusVisibility(visibility) + } } binding.buttonToot.setOnClickListener { val intent = ComposeActivity.startIntent(it.context, viewModel.composeOptions(true)) diff --git a/app/src/main/java/net/accelf/yuito/QuickTootViewModel.kt b/app/src/main/java/net/accelf/yuito/QuickTootViewModel.kt index 8a57df343..92cf429aa 100644 --- a/app/src/main/java/net/accelf/yuito/QuickTootViewModel.kt +++ b/app/src/main/java/net/accelf/yuito/QuickTootViewModel.kt @@ -1,14 +1,14 @@ package net.accelf.yuito -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.CAN_USE_UNLEAKABLE -import com.keylesspalace.tusky.components.compose.mutableLiveData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Visibility +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject class QuickTootViewModel @Inject constructor( @@ -19,16 +19,16 @@ class QuickTootViewModel @Inject constructor( private val unleakableAllowed by lazy { CAN_USE_UNLEAKABLE.contains(account.domain) } - val content = mutableLiveData("") + val content = MutableStateFlow("") - private val visibilityMutable = mutableLiveData(Visibility.PUBLIC) - val visibility: LiveData = visibilityMutable + private val visibilityMutable = MutableStateFlow(Visibility.PUBLIC) + val visibility: StateFlow = visibilityMutable private var stashedVisibility: Visibility? = null - private val inReplyToMutable: MutableLiveData = mutableLiveData(null) - val inReplyTo: LiveData = inReplyToMutable + private val inReplyToMutable: MutableStateFlow = MutableStateFlow(null) + val inReplyTo: MutableStateFlow = inReplyToMutable - val defaultTag: MutableLiveData = mutableLiveData(null) + val defaultTag: MutableStateFlow = MutableStateFlow(null) fun setInitialVisibility(num: Int) { visibilityMutable.value = (Visibility.byNum(num) @@ -73,7 +73,7 @@ class QuickTootViewModel @Inject constructor( visibility = visibility.value, contentWarning = inReplyTo.value?.spoilerText, replyingStatusAuthor = inReplyTo.value?.account?.name, - replyingStatusContent = inReplyTo.value?.content?.toString(), + replyingStatusContent = inReplyTo.value?.content, tootRightNow = tootRightNow ) } @@ -82,7 +82,7 @@ class QuickTootViewModel @Inject constructor( content.value = "" inReplyToMutable.value = null stashedVisibility?.let { - visibilityMutable.value = stashedVisibility + visibilityMutable.update { it } stashedVisibility = null } } diff --git a/app/src/main/java/net/accelf/yuito/VisibilityToggleButton.kt b/app/src/main/java/net/accelf/yuito/VisibilityToggleButton.kt index 7a4523cad..4cff3944f 100644 --- a/app/src/main/java/net/accelf/yuito/VisibilityToggleButton.kt +++ b/app/src/main/java/net/accelf/yuito/VisibilityToggleButton.kt @@ -4,9 +4,11 @@ import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Status.Visibility +import kotlinx.coroutines.launch class VisibilityToggleButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : AppCompatImageView(context, attrs) { @@ -19,7 +21,9 @@ class VisibilityToggleButton @JvmOverloads constructor(context: Context, attrs: } fun attachViewModel(viewModel: QuickTootViewModel, owner: LifecycleOwner) { - viewModel.visibility.observe(owner, ::updateVisibility) + owner.lifecycleScope.launch { + viewModel.visibility.collect(::updateVisibility) + } viewModel.setInitialVisibility(preference.getInt(PREF_CURRENT_VISIBILITY, Visibility.UNKNOWN.num)) setOnClickListener{ viewModel.stepVisibility() } } diff --git a/app/src/main/res/drawable-hdpi/ic_notify.png b/app/src/main/res/drawable-hdpi/ic_notify.png deleted file mode 100644 index c10f8f323..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_notify.png b/app/src/main/res/drawable-mdpi/ic_notify.png deleted file mode 100644 index 364a28612..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notify.png b/app/src/main/res/drawable-xhdpi/ic_notify.png deleted file mode 100644 index d6979bec3..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notify.png b/app/src/main/res/drawable-xxhdpi/ic_notify.png deleted file mode 100644 index a0afd0d76..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_person_remove_24dp.xml b/app/src/main/res/drawable/ic_person_remove_24dp.xml new file mode 100644 index 000000000..10332c28c --- /dev/null +++ b/app/src/main/res/drawable/ic_person_remove_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml new file mode 100644 index 000000000..600e4908c --- /dev/null +++ b/app/src/main/res/drawable/ic_splash.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tusky.xml b/app/src/main/res/drawable/ic_tusky.xml deleted file mode 100644 index 0dc845c25..000000000 --- a/app/src/main/res/drawable/ic_tusky.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/info_24dp.xml b/app/src/main/res/drawable/info_24dp.xml new file mode 100644 index 000000000..0f7a508f3 --- /dev/null +++ b/app/src/main/res/drawable/info_24dp.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml index 150f0860c..ec023062a 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -1,15 +1,31 @@ - + android:layout_height="match_parent"> + + + + + + + - \ No newline at end of file + + + + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index ff548dfca..d794d4e5d 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -223,6 +223,7 @@ android:lineSpacingMultiplier="1.1" android:paddingTop="2dp" android:textColor="?android:textColorTertiary" + android:textIsSelectable="true" android:textSize="?attr/status_text_medium" app:layout_constraintTop_toBottomOf="@id/saveNoteInfo" tools:text="This is a test description. Descriptions can be quite looooong." /> diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index e4a626fec..330237895 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -22,6 +22,19 @@ tools:ignore="ContentDescription" /> + + + + @@ -19,12 +20,33 @@ android:id="@+id/loginProgress" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center" /> + android:layout_gravity="center" /> - + android:orientation="vertical" + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_view_thread.xml b/app/src/main/res/layout/activity_view_thread.xml index 66b156dcc..c6a79182b 100644 --- a/app/src/main/res/layout/activity_view_thread.xml +++ b/app/src/main/res/layout/activity_view_thread.xml @@ -2,12 +2,9 @@ - - + tools:context="com.keylesspalace.tusky.components.viewthread.ViewThreadActivity"> + android:padding="24dp"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_view_thread.xml b/app/src/main/res/layout/fragment_view_thread.xml index 806d420a4..b00fa7f81 100644 --- a/app/src/main/res/layout/fragment_view_thread.xml +++ b/app/src/main/res/layout/fragment_view_thread.xml @@ -1,17 +1,53 @@ - + android:layout_height="match_parent"> - + + + + + + + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" + android:layout_gravity="top"> - + + + + + + + + + diff --git a/app/src/main/res/layout/item_account_field.xml b/app/src/main/res/layout/item_account_field.xml index bc2d0d6d3..3d68e3df9 100644 --- a/app/src/main/res/layout/item_account_field.xml +++ b/app/src/main/res/layout/item_account_field.xml @@ -29,6 +29,7 @@ android:drawablePadding="6dp" android:gravity="center" android:lineSpacingMultiplier="1.1" + android:textIsSelectable="true" android:textSize="?attr/status_text_medium" app:layout_constrainedWidth="true" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/item_account_media.xml b/app/src/main/res/layout/item_account_media.xml new file mode 100644 index 000000000..a2938b69e --- /dev/null +++ b/app/src/main/res/layout/item_account_media.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_edit_field.xml b/app/src/main/res/layout/item_edit_field.xml index 2777730bc..a658343e1 100644 --- a/app/src/main/res/layout/item_edit_field.xml +++ b/app/src/main/res/layout/item_edit_field.xml @@ -1,5 +1,6 @@ - + app:counterTextColor="?android:textColorTertiary"> - + + + + + app:counterTextColor="?android:textColorTertiary"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index d62069a7c..b908301c3 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -173,7 +173,7 @@ app:layout_constraintTop_toBottomOf="@+id/button_toggle_content" tools:visibility="gone"> - - + android:textStyle="bold" + android:textSize="?attr/status_text_large" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 009614be9..d40dca262 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -10,8 +10,6 @@ تم رفض التصريح. فشل الحصول على رمز الولوج. إنّ المنشور طويل جدا! - يجب أن يكون حجم الملف أقل من 8 ميغابايت. - يجب أن يكون حجم ملفات الفيديو أقل من 40 ميغا بايت. لا يمكن تحميل هذا النوع من الملفات. تعذر فتح ذاك الملف. التصريح لازم لقراءة الوسائط. @@ -344,10 +342,10 @@ تحذير عن المحتوى: %s مِن دون وصف أعاد تدوينه - للعامة - غير مُدرَج - المتابِعون - مباشر + للعامة + غير مُدرَج + المتابِعون + مباشر اسم القائمة حذف وإعادة الصياغة إظهار صاحب الترقية @@ -483,7 +481,6 @@ القائمة ليس لديك أية مسودات. ليس لديك أية منشورات مُبرمَجة للنشر. - يجب أن يكون حجم الملفات الصوتية أقل مِن 40 ميغابايت. تُقدّر أدنى فترة لبرمجة النشر في ماستدون بـ 5 دقائق. تمكين حركات السحب للانتقال بين الألسنة طلب متابعة diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a58d3eaf8..99543c3c1 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -67,9 +67,9 @@ Добавяне на хаштаг Име на списък Анкета с избори: %1$s, %2$s, %3$s, %4$s; %5$s - Директно - Последователи - Публично + Директно + Последователи + Публично Отметнато Поставено в любими Реблог @@ -212,7 +212,7 @@ Любими Известия, когато публикациите ви се споделят Най-малък - Скрито + Скрито Раздели Филтриране на емисия Анимиране на персонализирани емоджита @@ -420,14 +420,11 @@ Начало Грешка при изпращане на публикация. Качването бе неуспешно. - Изображения и видеоклипове не могат да бъдат прикачени към едно и също състояние. + Изображения и видеоклипове не могат да бъдат прикачени към една и съща публикация. Изисква се разрешение за съхранение на мултимедия. Изисква се разрешение за четене на носител. Този файл не можа да бъде отворен. Този тип файл не може да бъде качен. - Аудио файловете трябва да са по-малки от 40MB. - Видео файловете трябва да са по-малки от 40MB. - Файлът трябва да е по-малък от 8MB. Състоянието е твърде дълго! Получаването на токен за вход бе неуспешно. Упълномощаването е отказано. @@ -506,4 +503,6 @@ Изтегляне на визуализации за мултимедии Показване на отговори Показване на споделяния + Видео и аудио файловете не може да превишават %s МБ в размер. + Тази снимка не може да абъде редактирана. \ No newline at end of file diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 331e5e189..ff8bdedfc 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -3,10 +3,10 @@ %1$s %dসে পছন্দগুলি সহ নর্বাচন: %1$s, %2$s, %3$s, %4$s; %5$s - সরাসরি - অনুগামিবৃন্দ - অতালিকাভুক্ত - সর্বজনীন + সরাসরি + অনুগামিবৃন্দ + অতালিকাভুক্ত + সর্বজনীন পছন্দ আবার ব্লগ বর্ণনা নাই @@ -332,7 +332,6 @@ আলাপ বন্ধ করো আলাপ বন্ধ করো মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে। - অডিও ফাইলগুলি অবশ্যই ৪০MB এর চেয়ে কম হওয়া উচিত। তোমার কোনো খসড়া নেই। তোমার কোনো সময়সূচীত স্ট্যাটাস নেই। তালিকা @@ -418,8 +417,6 @@ মিডিয়া পড়তে অনুমতি প্রয়োজন। ওই ফাইল খোলা যাবে না। যে ধরনের ফাইল আপলোড করা যাবে না। - ভিডিও ফাইল 40MB চেয়ে কম হতে হবে। - ফাইল 8MB চেয়ে কম হতে হবে। এই স্টেটাস টি খুব দীর্ঘ! একটি লগইন টোকেন পেতে ব্যর্থ। অনুমোদন অস্বীকার করা হয়েছে। diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 4ca0623fc..55a1ab121 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -10,8 +10,6 @@ অনুমোদন অস্বীকার করা হয়েছে। একটি লগইন টোকেন পেতে ব্যর্থ। এই স্টেটাস টি খুব দীর্ঘ! - ফাইল 8MB চেয়ে কম হতে হবে। - ভিডিও ফাইল 40MB চেয়ে কম হতে হবে। যে ধরনের ফাইল আপলোড করা যাবে না। ওই ফাইল খোলা যাবে না। মিডিয়া পড়তে অনুমতি প্রয়োজন। @@ -355,10 +353,10 @@ বর্ণনা নাই আবার ব্লগ পছন্দ - সর্বজনীন - অতালিকাভুক্ত - অনুগামিবৃন্দ - সরাসরি + সর্বজনীন + অতালিকাভুক্ত + অনুগামিবৃন্দ + সরাসরি পছন্দগুলি সহ নর্বাচন: %1$s, %2$s, %3$s, %4$s; %5$s নামের তালিকা # ছাড়া হ্যাশট্যাগ @@ -421,7 +419,6 @@ বুকমার্ক %s আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে বুকমার্কগুলি - অডিও ফাইলগুলি অবশ্যই ৪০MB এর চেয়ে কম হওয়া উচিত। অনুরোধ অনুসরণ করো বিজ্ঞপ্তি লুকাও নিঃশব্দ @%s\? diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 264cde857..e702556cd 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -9,7 +9,6 @@ S\'ha denegat l\'autorització. Ha fallat l\'obtenció del token d\'inici de sessió. L\'estat és massa llarg! - El fitxer ha de ser d\'una mida menor de 8MB. No es pot pujar aquest tipus de fitxer. No es pot obrir aquest tipus de fitxer. Cal permís d\'accés a l\'emmagatzematge. @@ -205,13 +204,12 @@ No hi ha res aquí. Elimina l\'impuls S\'ha produït un error de connexió! Comproveu la connexió i torneu-ho a provar! - Els fitxers de vídeo han de ser de mida menor de 40 MB. Multimèdia amagada Amaga Estàs segur de tancar la sessió de %1$s\? Amaga els impulsos Mostra els impulsos - Elimina i reecririu + Elimina i reescriu Obre el menú Visibilitat de la publicació Contingut sensible @@ -350,10 +348,10 @@ Mèdia : %s Sense descripció Favorits - Públic - Sense llistar - Seguidors - Directe + Públic + Sense llistar + Seguidors + Directe Nom de la llista Hashtag sense # Netejar @@ -430,7 +428,6 @@ Llista S\'ha produït un error en cercar la publicació %s No tens cap estat planificat. - Els fitxers d\'àudio han de ser de mida menor de 40MB. No teniu cap esborrany. L\'interval mínim de planificació a Mastodon és de 5 minuts. Peticions de seguiment diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 985ec63a5..e428c4593 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -141,9 +141,6 @@ مۆڵەت بۆ خوێندنەوەی میدیا پێویستە. ئەم فایلە ناتوانرێت بکرێتەوە. ناتوانیت لەم جۆرە فایلانە بەرز بکەیتەوە. - دەبێت فایلە دەنگییەکان لە 40 مێگابایت گەورەتر نەبن. - دەبێت ڤیدیۆکان لە 40 مێگابایت گەورەتر نەبن. - فایلەکە دەبێت لە 8 مێگابایت بچووکتر بێت. ئەم نووسینە زۆر درێژە! سەرکەوتوو نەبوو لە بەدەستهێنانی نیشانەی چوونەژوورەوە. ڕێپێدان ڕەتکرایەوە. @@ -319,10 +316,10 @@ هاشتاگی زیاد بکە ناوی لیست ڕاپرسی لەگەڵ هەڵبژاردنەکان: %1$s, %2$s, %3$s, %4$s; %5$s - ڕاستەوخۆ - شوێنکەوتوانی - لە لیست نەکراو - گشتی + ڕاستەوخۆ + شوێنکەوتوانی + لە لیست نەکراو + گشتی نیشانکراوە پەسەندکراو دووبارە بڵاگ کرا diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cdc87470e..e2c262a32 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -10,8 +10,6 @@ Autorizace byla zamítnuta. Nepodařilo se získat přihlašovací token. Toot je příliš dlouhý! - Soubor musí být menší než 8 MB. - Videosoubory musejí být menší než 40 MB. Tento typ souboru nemůže být nahrán. Tento soubor nemohl být otevřen. Je vyžadováno povolení číst média. @@ -359,10 +357,10 @@ Oblíbený - Veřejný - Neuvedený - Pro sledující - Přímý + Veřejný + Neuvedený + Pro sledující + Přímý Název seznamu Hashtag bez # @@ -459,7 +457,6 @@ Záložky Záložka Záložky - Audio soubory musí být menší než 40MB. Ukazovat náhledy k odkazům Mastodon neumožňuje pracovat s intervalem menším než 5 minut. Zatím zde nemáte žádné naplánované statusy. diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 9ca60f540..1c38634bd 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -8,43 +8,41 @@ Roedd gwall awdurdodi anhysbys. Gwrthodwyd awdurdodi. Methu cael tocyn mewngofnodi. - Mae\'r statws yn rhy hir! - Rhaid i\'r ffeil fod yn llai nag 8MB. - Rhaid i ffeiliau fideo fod yn llai na 40MB. + Mae\'ch neges yn rhy hir! Ni allwch uwchlwytho\'r math hwnnw o ffeil. Nid oedd modd agor y ffeil honno. Rhaid cael caniatâd i ddarllen hwn. Rhaid cael caniatâd i gadw hwn. - Ni allwch atodi delweddau a fideos i\'r un statws. + Ni allwch atodi delweddau a fideos i\'r un neges. Methu uwchlwytho. - Methu tŵtio. + Bu gwall wrth anfon y neges. Hafan Hysbysiadau Lleol Ffedereiddwyd - Tŵtio + Edau Negeseuon Gydag ymatebion Dilyniadau Dilynwyr Ffefrynnau - Defnyddwyr mud - Defnyddwyr wedi\'u blocio - Dilyn ceisiadau - Golygu\'ch Proffil + Defnyddwyr wedi\'u tewi + Defnyddwyr wedi\'u rhwystro + Ceisiadau i\'ch dilyn + Golygu\'ch proffil Drafftiau Trwyddedau %s wedi\'u hybu Cynnwys sensitif - Cyfryngau cudd + Cyfryngau wedi\'u cudd Cliciwch i weld Dangos Mwy - Dangos Llai + Dangos Llai Chwyddo Lleihau Dim byd yma. Tynnwch lawr i adnewyddu! - %s wedi hybu\'ch tŵt - %s wedi nodi\'ch tŵt yn ffefryn + %s wedi hybu\'ch post + %s wedi hoffi\'ch post %s wedi\'ch dilyn chi Adrodd @%s Sylwadau ychwanegol? @@ -59,8 +57,8 @@ Ydych chi\'n siŵr eich bod am allgofnodi o\'r cyfrif %1$s? Dilyn Dad-ddilyn - Blocio - Dad-flocio + Rhwystro + Dadrwystro Cuddio hybiadau Dangos hybiadau Adrodd @@ -72,40 +70,40 @@ Proffil Dewisiadau Ffefrynnau - Defnyddwyr mud - Defnyddwyr wediu blocio + Defnyddwyr wedi\'u tewi + Defnyddwyr wedi\'u rhwystro Dilyn ceisiadau Cyfryngau Agor mewn porwr Ychwanegu cyfryngau Tynnu ffotograff Rhannu - Mudo - Dad-fudo + Tewi + Dad-dewi Sôn am Cuddio cyfrwng Agor drôr Cadw - Golygu Proffil + Golygu\'ch proffil Golygu Dad-wneud Derbyn Gwrthod Chwilio Drafftiau - Pwy all weld Tŵt + Gwelededd y post Rhybudd cynnwys Bysellfwrdd emoji Lawrlwytho %1$s Copïo\'r ddolen - Rhannu URL Tŵt i… - Rhannu Tŵt i… + Rhannu URL post i… + Rhannu post i… Rhannu cyfryngau i… Anfonwyd! - Dad-flociwyd y defnyddiwr - Dad-fudwyd y defnyddiwr + Dadrwystrwyd y defnyddiwr + Defnyddiwr heb eu tewi Anfonwyd! - Anfonwyd yr ateb. + Anfonwyd yr ateb yn llwyddiannus. Pa achos? Beth sy\'n digwydd? Rhybudd cynnwys @@ -114,10 +112,10 @@ Chwilio… Dim canlyniadau Ateb… - Rhithffurf + Llun proffil Pennawd Beth yw achos? - Yn cysylltu … + Yn cysylltu… Gallwch nodi cyfeiriad neu barth unrhyw achos yma, fel mastodon.social, twt.cymru, social.tchncs.de, a mwy! @@ -132,7 +130,7 @@ Lawrlwytho Tynnu\'r cais i ddilyn yn ôl? Dad-ddilyn y cyfrif hwn? - Dileu\'r tŵt hwn? + Dileu\'r post hwn\? Cyhoeddus: Postio i ffrydiau cyhoeddus Heb restru: Peidio â dangos ar ffrydiau cyhoeddus Dilynwyr yn Unig: Postio i ddilynwyr yn unig @@ -143,11 +141,11 @@ Cael hysbysiad sŵn Cael hysbysiad crynu Cael hysbysiad â golau - Rhowch wybod i mi bryd + Rhowch wybod i mi pan soniodd dilynodd - fy negeseuon wedi\'u hybu - fy mhyst sy\'n ffefrynnau + fy mhyst yn cael eu hybu + fy mhyst yn cael eu hoffi Gwedd Thema\'r App Tywyll @@ -172,29 +170,34 @@ Cyhoeddus Heb ei restru Dilynwyr yn unig - Maint testun statws + Maint testun post Lleiaf - Bach - Canolig - Mawr - Mwyaf + Bach + Cymedrol + Mawr + Mwyaf Yn sôn amdanoch o\'r newydd Hysbysiadau sôn amdanoch o\'r newydd Dilynwyr Newydd Hysbysiadau am ddilynwyr newydd - Hybiadau - Hysbysiadau pan gaiff eich tŵtiau eu hybu + Hybiau, Hwb + Hysbysiadau pan gaiff eich pyst eu hybu Ffefrynnau - Hysbysiadau pan fo\'r tŵtiau wedi\'u marcio fel ffefryn + Hysbysiadau pan fo\'r pyst wedi\'u marcio fel ffefryn Soniodd %s amdanoch %1$s, %2$s, %3$s a %4$d eraill %1$s, %2$s, a %3$s %1$s a %2$s + %d rhyngweithiad newydd + %d rhyngweithiad newydd + %d ryngweithiad newydd + %d rhyngweithiad newydd + %d rhyngweithiad newydd %d rhyngweithiad newydd Cyfrif wedi\'i gloi - Amdano + Ynghylch Mae Yuito yn feddalwedd ffynhonnell agored barn rydd. Fe\'i trwyddedir dan Drwydded Gyhoeddus Gyffredinol GNU Fersiwn 3. Gallwch weld y drwydded yma: https://www.gnu.org/licenses/gpl-3.0.en.html @@ -211,8 +214,8 @@ https://github.com/accelforce/Yuito/issues Proffil Yuito - Rhannu cynnwys tŵt - Rhannu dolen i\'r tŵt + Rhannu cynnwys y post + Rhannu dolen i\'r post Delweddau Fideo Gofyn i ddilyn @@ -222,11 +225,11 @@ %dh %dm %ds - %dy yn ôl + %db yn ôl %dd yn ôl - %dh yn ôl + %da yn ôl %dm yn ôl - %ds yn ôl + %de yn ôl Yn eich dilyn chi Dangos cynnwys sensitif bob tro Cyfryngau @@ -236,26 +239,26 @@ Ychwanegu cyfrif Mastodon newydd Rhestri Rhestri - Yn postio â chyfrif %1$s + Yn postio fel %1$s Methu gosod pennawd Pennu pennawd Dileu Cloi cyfrif Angen cymeradwyo dilynwyr eich hun Cadw drafft? - Yn anfon Tŵt… - Gwall wrth anfon Tŵt - Yn anfon Tŵtiau - Canslo anfon - Cadwyd copi o\'r tŵt i\'ch drafftiau + Yn anfon post… + Gwall wrth anfon post + Yn anfon pyst + Canslwyd anfon + Cadwyd copi o\'r post i\'ch drafftiau Creu Nid oes gan eich achos %s emoji bersonol Arddull emoji Rhagosodiad system Bydd angen i chi lawrlwytho\'r setiau emoji hyn yn gyntaf Wrthi\'n chwilio… - Chwyddo/lleihau pob statws - Agor tŵt + Chwyddo/Lleihau pob pyst + Agor post Angen ailddechrau\'r app Bydd angen ailddechrau Yuito i roi\'r newidiadau ar waith Nes ymlaen @@ -269,7 +272,7 @@ Dad-hybu Mae gan Yuito god ac asedau o\'r prosiectau ffynhonnell agored canlynol: Trwyddedir dan Drwydded Apache (copi isod) - Metaddata proffil + Metaddata\'r proffil ychwanegu data Cynnwys Defnyddio amser absoliwt @@ -283,9 +286,9 @@ Dileu hwb Dileu hoff Dileu ac ail-ddrafftio - Dewisiadau Cyfrif + Dewisiadau\'ch cyfrif Parthau cudd - Tawelwch %s + Tewi %s Ychwanegu Tab Cysylltiadau Cysylltiadau @@ -295,9 +298,175 @@ Golygu Golygu Creu - Dilynwyr - Heb ei restru + Dilynwyr + Heb ei restru %1$s a %2$s Dileu - Cyhoeddus + Cyhoeddus + Dad-dewi sgwrs + Sgyrsiau + Cuddio parth cyfan gwbl + Tewi sgwrs + Hidlo + Hysbysiadau am bolau sydd wedi cwblhau + Ymunwyd %1$s + Does gennych ddim negeseuon arfaethedig. + %s (%s) + Ydych chi\'n siŵr eich bod chi am glirio\'ch holl hysbysiadau yn barhaol\? + Ni all feiliau fideo a sail i fod yn fwy na %s MB mewn maint. + %s (🔗 %s) + Dangos yr hidlydd hysbysiadau + Gwall wrth ddilyn #%s + Gwall wrth ddad-ddilyn #%s + Dad-dewi %s + Dad-dewi hysbysiadau o %s + %s newydd bostio + Dileu\'r sgwrs + Llyfrnodau + Ychwanegu pôl + %s heb eu cuddio + Negeseuon arfaethedig + Amserlennu post + Crybwylliadau + Tewi @%s\? + Cuddio hysbysiadau + rhywun wedi cofrestru + Hidlyddion + Rhwystro @%s\? + Pyst newydd + Golygiadau i byst + Polau + Cofrestriadau + Ffrydiau cyhoeddus + Ychwanegu hidlydd + Golygu hidlydd + Diweddaru + golygwyd post rydw i wedi rhyngweithio â + Golygodd %s eu post + Hidlo + dilyniad wedi\'u ofyn + Tewi hysbysiadau o %s + Clirio + polau wedi dod i ben + rhywn rydw i\'n tanysgrifio at wedi cyhoeddi post newydd + Gair cyfan + Ymadrodd i\'w hidlo + Agor fel %s + Ailgysodi + Llyfrnodau + Gofynodd %s i\'ch dilyn chi + Mewngofnodi + Cofrestrodd %s + Tynnu nod tudalen + Dad-dewi %s + Mewngofnodwch eto i gael hysbysiadau gwthio + Cyhoeddiadau + Methu llwytho\'r dudalen mewngofnodi. + Negeseuon arfaethedig + Wedi methu llwytho manylion cyfrif + Ffrydiau + Wedi\'u hoffi gan + Methu golygu\'r ddelwedd. + Hoffwyd + Yn cadw drafft… + Hashnodau + Diystyru + Manylion + Crybwylliadau + Agor cyfryngau #%d + Rhannu fel … + Yn lawrlwytho cyfryngau + Lawrlwytho cyfryngau + Dileu ac ail-ddrafftio y post hwn\? + Dileu y swrs hon\? + Iaith + Dangos marciwr ar gyfer botiau + Dangos graddiannau lliwgar ar gyfer cyfryngau cudd + Methu cydamseru gosodiadau + Brig + Tusky %s + Hashnodau + Gwaelod + Dangos ffefrynnau + Caiff yr adroddiad ei anfon at reolwr eich gweinydd. Gallwch esbonio pam rydych chi\'n adrodd y cyfrif hwn isod: + Mae gan Mastodon egwyl amserlennu o leiaf 5 munud. + Er nad ydych wedi cloi\'ch cyfrif, roedd tîm %1$s yn meddwl efallai yr hoffech adolygu\'r ceisiadau i\'ch dilyn o\'r cyfrifon hyn â llaw. + Hoffech chi ddileu\'r neges arfaethedig hon\? + Dim disgrifiad + Enw\'r rhestr + Hashnod heb # + Anfon ymlaen at %s + Daw\'r cyfrif o weinydd arall. Hoffech chi anfon copi dienw o\'r adroddiad i\'r gweinydd hwnnw hefyd\? + Awr + 6 awr + Diwrnod + Dadbinio + Byth + Bob tro + Atodiadau + Creu rhestr + Ailenwi\'r rhestr + Golygu\'r rhestr + Chwilio am bobl rydych chi\'n eu dilyn + Bot + CC-BY-SA 4.0 + Golygu\'r llun + Hybwyd gan + Hyd + 5 munud + Hanner awr + Tridiau + Wythnos + Pythefnos + 30 diwrnod + 60 diwrnod + 90 diwrnod + 180 diwrnod + Blwyddyn + Ychwanegu dewis + Pinio + %1$s, %2$s a %3$d eraill + yn dod i ben am %s + (Dim newid) + Amlddewis + Dewis %d + Does dim cyhoeddiadau. + Eich nodyn preifat ynghylch y cyfrif hwn + Wedi\'i gadw! + Tanysgrifio + Dad-danysgrifo + Ydych chi\'n sicr yr hoffech chi ddileu\'r rhestr %s\? + Does gennych chi ddim drafftiau. + Dangos rhagolygon o ddolenni + Llesiant + Cuddio ystadegau meintiol negeseuon + Cuddio ystadegau meintiol proffiliau + Drafft wedi\'i ddileu + Mae\'r neges y drafftioch ymateb iddi wedi cael ei dileu + Iaith y neges + Hashnodau + Pleidleisio + Mae pôl y pleidleisioch ynddo wedi dod i ben + Mae pôl y creoch wedi dod i ben + Parhau + Yn ôl + Sylwadau ychwanegol + Defnyddio arddull y system + Lleoliad y panel llywio + Animeiddio lluniau proffil GIF + Nodi cyfryngau yn sensitif bob tro + Pwerir gan Tusky + Cyfrifon + Pôl + CC-BY 4.0 + Dileu\'r rhestr + Ychwanegu hashnod + 1+ + Sain + Ysgrifennu neges + Methodd anfon y neges hon! + Ysgrifennu neges + Ailfewngofnodwch i\'ch cyfrifon er mwyn galluogi hysbysiadau i\'ch ffôn. + Er mwyn derbyn hysbysiadau i\'ch ffôn drwy UnifiedPush, mae angen caniatâd ar Tusky i danysgrifio i hysbysiadau ar eich gweinydd Mastodon. Bydd rhaid i chi fewngofnodi eto i newid y sgôp OAuth a roddir i Tusky. Bydd defnyddio\'r opsiwn ailfewngofnodi yma neu yn \'Dewisiadau\'ch cyfrif\' yn cadw\'ch holl ddrafftiau a\'ch storfa leol. + Ydych chi\'n sicr yr hoffech chi rwystro %s i gyd\? Welwch chi ddim cynnwys o\'r parth hwnnw mewn unrhyw llinellau amser cyhoeddus nac ychwaith yn eich hysbysiadau. Ceir gwared ar eich dilynwyr o\'r parth hwnnw. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 30c0243df..973abcf9f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -7,11 +7,9 @@ Authentifizieren mit dieser Instanz fehlgeschlagen. Kein Webbrowser gefunden. Ein unbekannter Fehler ist bei der Autorisierung aufgetreten. - Autorisierung fehlgeschlagen. + Autorisierung wurde abgelehnt. Es konnte kein Login-Token abgerufen werden. Der Beitrag ist zu lang! - Die Datei muss kleiner als 8 MB sein. - Videodateien müssen kleiner als 40 MB sein. Dieser Dateityp darf nicht hochgeladen werden. Die Datei konnte nicht geöffnet werden. Eine Leseberechtigung wird für das Hochladen der Mediendatei benötigt. @@ -56,7 +54,7 @@ Irgendwelche Anmerkungen? Schnell antworten Antworten - Teilen + Boosten Boost entfernen Favorisieren Favorisierung entfernen @@ -261,7 +259,7 @@ Liste löschen Liste bearbeiten Ein Konto zu einer Liste hinzufügen - verfassen mit %1$s + Veröffentlichen als %1$s Fehler beim Speichern der Beschreibung Für Menschen mit Sehbehinderung beschreiben\n(%d Zeichen) @@ -319,9 +317,9 @@ Keine Beschreibung Favorisiert - Öffentlich - Folgende - Direkt + Öffentlich + Folgende + Direkt Listenname Medien herunterladen Medien werden heruntergeladen @@ -342,7 +340,7 @@ Medien: %s Inhaltswarnung: %s Geteilt - Ungelistet + Ungelistet Löschen und neu erstellen Bist du dir sicher, dass du diesen Beitrag löschen und neu erstellen möchtest\? Umfragen beendet sind @@ -425,7 +423,6 @@ Geplante Beiträge Plane Beitrag Zurücksetzen - Audiodateien müssen kleiner als 40 MB sein. Lesezeichen Lesezeichen Lesezeichen @@ -541,4 +538,14 @@ Du hast dich erneut in dein aktuelles Konto eingeloggt, um Tusky die Genehmigung für Push-Abonnements zu erteilen. Du hast jedoch noch andere Konten, die nicht auf diese Weise migriert wurden. Wechsel zu diesen Konten und melde dich nacheinander neu an, um die Unterstützung für UnifiedPush-Benachrichtigungen zu aktivieren. Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf Ihrem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Einloggen hier oder in den Kontoeinstellungen verwendest, bleiben alle deine lokalen Entwürfe und der Cache erhalten. Melde alle Konten neu an, um die Unterstützung für Push-Benachrichtigungen zu aktivieren. + %1$s beigetreten + 1+ + Fehler beim Laden der Kontodetails + Bild bearbeiten + Details + Das Bild konnte nicht bearbeitet werden. + Speichere den Entwurf… + Video- oder Tondateien dürfen nicht grösser als %s MB sein. + #%s folgen fehlgeschlagen + #%s entfolgen fehlgeschlagen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 85583a2b2..cef008748 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -49,7 +49,6 @@ Ακολουθήστε Αναφορά Σίγαση - Τα μουσικά αρχεία πρέπει να είναι μικρότερα από 40MB. Αφαίρεση αγαπημένου Αναφορά του/της %s Προτιμήσεις Λογαριασμού @@ -67,7 +66,6 @@ Αποθήκευση Γρήγορη Απάντηση Χρήστες σε σίγαση - Το αρχείο πρέπει να είναι μικρότερο από 8MB. Απόκρυψη προωθήσεων Προτιμήσεις Σύνδεση diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index de167b858..434160e76 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -42,14 +42,11 @@ An error occurred. Local Blocked users - Video files must be less than 40MB. Tabs - Audio files must be less than 40MB. Follow Requests Home Muted users Thread - The file must be less than 8MB. Error sending post. This cannot be empty. Permission to store media is required. diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 429a20322..110c5c245 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -2,7 +2,7 @@ Eraro okazis. Reta eraro okazis! Bonvolu kontroli vian konekton kaj klopodi denove! - Ne povas esti malplena. + Tiu ne povas esti malplena. Enmetita domajno estas nevalida Aŭtentigo en ĉi tiu nodo malsukcesis. Ne eblas trovi retumilon. @@ -10,22 +10,20 @@ Rajtigo rifuzita. Akiro de atingoĵetono malsukcesis. Via mesaĝo estas tro longa! - La dosiero devas esti malpli ol 8MB. - Videaj dosieroj devas esti malpli ol 40MB. - Tia dosiero ne estas rajtigita. + Tia dosiero ne estas alŝutebla. Tiu dosiero ne povas esti malfermita. Permeso legi aŭdovidaĵojn necesas. Permeso konservi aŭdovidaĵojn necesas. - Bildoj kaj videoj ne povas ambaŭ estas alligita al la sama mesaĝo. + Bildoj kaj videoj ne povas esti ambaŭ alkroĉitaj al la sama mesaĝo. La alŝuto malsukcesis. - Eraro dum sendo de la mesaĝo. + Okazis eraro dum la sendo de la mesaĝo. Hejmo Sciigoj Loka Fratara Rektaj mesaĝoj Langetoj - Mesaĝo + Fadeno Mesaĝoj Kun respondoj Alpinglitaj @@ -47,13 +45,13 @@ Montri malpli Pligrandigi Malgrandigi - Nenio ĉi tie. - Nenio ĉi tie. Tiru malsupren por aktualigi! + Nenio tie ĉi. + Nenio tie ĉi. Tiru malsupren por aktualigi! %s diskonigis vian mesaĝon %s stelumis vian mesaĝon %s eksekvis vin Signali @%s - Pliaj komentoj? + Ĉu pliaj komentoj\? Rapida respondo Respondi Diskonigi @@ -64,9 +62,9 @@ Verki Ensaluti al Mastodon Elsaluti - Ĉu vi certas ke vi volas elsaluti el konto %1$s? + Ĉu vi certas, ke vi volas elsaluti el la konto %1$s\? Sekvi - Ne plus sekvi + Ne plu sekvi Bloki Malbloki Kaŝi diskonigojn @@ -75,11 +73,11 @@ Forigi HUP HUP! - Reprovu + Reprovi Fermi Profilo - Preferoj - Preferoj de konto + Agordoj + Agordoj de konto Stelumoj Silentigitaj uzantoj Blokitaj uzantoj @@ -116,22 +114,22 @@ Mencioj Ligiloj Malfermi aŭdovidaĵon #%d - Elŝutante %1$s + Elŝutado de %1$s Kopii la ligilon Malfermi kiel %s - Konigi kiel … + Konigi kiel… Elŝuti aŭdovidaĵon - Elŝutante aŭdovidaĵo - Konigi URL de mesaĝo al… + Elŝutado de la aŭdovidaĵo + Konigi ligilon de mesaĝo al… Konigi mesaĝon al… Konigi aŭdovidaĵon al… Sendita! Malblokita uzanto Malsilentigita uzanto Sendita! - Respondi sukcese sendita. + Respondo sukcese sendita. Kiu nodo? - Kio okazas? + Kio nova\? Enhava averto Publika nomo Sinprezento @@ -141,12 +139,14 @@ Profilbildo Fonbildo Kio estas nodo? - Konektante… - La adreso aŭ domajno de iu ajn nodo povas esti enmetitaĉi tie, kiel mastodon.social, icosahedron.website, social.tchncs.de, kaj - pli! - \n\nSe vi ne ankoraŭ havas konton, vi povas enmeti la nomon de la nodo ke vi volas aliĝi kaj krei konton tie.\n\nNodo estas unika loko kie via konto estas gastigita, sed vi povas facile komuniki kun kaj sekvi homojn ĉe aliaj nodoj kiel vi estus ĉe la sama retejo. - \n\nPliaj informoj troviĝas ĉe joinmastodon.org. - + Konektado… + La adreso aŭ domajno de iu ajn nodo povas esti enmetita ĉi tie, kiel mastodon.social, icosahedron.website, social.tchncs.de, kaj pli! +\n +\nSe vi ankoraŭ ne havas konton, vi povas enmeti la nomon de la nodo, al kiu vi volas aliĝi, kaj krei konton tie. +\n +\nNodo estas unika loko tie, kie via konto estas gastigita, sed vi povas facile komuniki kun homoj, kaj sekvi ilin ĉe aliaj nodoj, kvazaŭ vi estus samreteje. +\n +\nPliaj informoj troviĝas ĉe joinmastodon.org. Finante alŝuto de aŭdovidaĵojn Alŝutante… Elŝuti @@ -355,12 +355,12 @@ Stelumita - Publika + Publika - Nelistigita - Sekvantoj + Nelistigita + Sekvantoj - Rekta + Rekta Nomo de la listo Forigi kaj reskribi Ĉu forigi kaj reskribi ĉi-tiun mesaĝon\? @@ -437,7 +437,6 @@ Elekti la liston Listo Eraro dum elserĉo de la mesaĝo %s - Aŭdia dosiero devas esti malpli ol 40MB. Vi ne havas iun ajn malneton. Vi ne havas iun ajn planitan mesaĝon. Petoj de sekvado @@ -524,4 +523,35 @@ Kontroli la sciigojn Limigi sciigojn pri tempolinio La mesaĝo al kiu ĉi tiu malneto respondas estis forigita + %s registriĝis + iu registriĝis + mesaĝo kun kiu mi interagis estas redaktita + Novaj kontoj + Sciigoj pri novaj uzantoj + 1+ + Kvankam via konto ne estas blokita, la teamo de %1$s pensas ke vi eble volus permane validigi la sekvopetojn de tiuj ĉi kontoj. + Filmetoj kaj sondosieroj ne povas esti pli grandaj ol %s MB. + La bildo ne povis esti redaktita. + Ensaluti + Okazis eraro dum la sekvado de #%s + Okazos eraro dum la malsekvado de #%s + Ensaluti denove por ricevi sciigojn + %s redaktis sian mesaĝon + Fermi + Detaloj + Redaktitaj mesaĝoj + Sciigoj kiam mesaĝoj kun kiuj vi interagis estas redaktitaj + Redakti la bildon + 14 tagoj + 30 tagoj + 60 tagoj + 90 tagoj + 180 tagoj + 365 tagoj + Ekverki mesaĝon + Aliĝis je %1$s + Registras la malneton… + Ensalutu denove al ĉiuj kontoj por ŝalti sciigojn. + La salutpaĝo ne povis esti ŝargita. + Ŝargo de detaloj pri la konto malsukcesis diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3fc102842..3c292b45d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -10,8 +10,6 @@ La autorización falló. Fallo al obtener identificador de login. ¡El estado es demasiado largo! - El archivo debe ser inferior a 8MB. - Los archivos de vídeo deben tener menos de 40MB. No se admite este tipo de archivo. No pudo abrirse el fichero. Se requiere permiso para acceder al almacenamiento. @@ -331,7 +329,7 @@ Añadir cuenta a la lista Eliminar cuenta de la lista 1Favoritos - Seguidores + Seguidores Aplicar Mostrar indicador de bots Votar @@ -383,9 +381,9 @@ Aviso de contenido: %s Sin descripción Compartido - Público - Sin listar - Directo + Público + Sin listar + Directo Nombre de la lista Etiqueta sin # Limpiar @@ -450,7 +448,6 @@ Marcado como favorito Seleccionar lista Lista - Los ficheros de audio deben ser menores de 40MB. No tienes ningún borrador. No tienes ningún estado programado. Mastodon tiene un intervalo de programación mínimo de 5 minutos. diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index c70b1ed6c..d252ef16e 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -9,8 +9,6 @@ Akatsa baimentzerakoan. Akatsa login identifikatzailea lortzerakoan. Tut luzeegia! - Fitxategiak 8MB baino gutxiago izan behar ditu. - Bideoak 40MB baino gutxiago izan behar ditu. Ez da fitxategi mota hau onartzen. Ezin izan da fitxategi hau ireki. Memoriara sartzeko baimena behar da. @@ -373,10 +371,10 @@ Deskribapenik ez Birblogeatuta Gogotuta - Publiko - Zerrendagabetuta - Jarraitzaileak - Zuzena + Publiko + Zerrendagabetuta + Jarraitzaileak + Zuzena Inkestatu aukerekin: %1$s, %2$s, %3$s, %4$s; %5$s Zerrendaren izena Traola # gabe @@ -441,7 +439,6 @@ Ireki bultzadaren egilea Denbora lerro publikoak Laster-markatuta - Audioak 40MB baino gutxiago izan behar ditu. Aukeratu zerrenda Zerrenda Ez duzu zirriborrorik. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 759ea7fdc..9c8ff75c2 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -9,8 +9,6 @@ احراز هویت رد شد. دریافت ژتون ورود شکست خورد. فرسته خیلی طولانی است! - پرونده باید کمتر از ۸ مگابایت باشد. - پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد. این گونهٔ پرونده نمی‌تواند بارگذاری شود. این پرونده نتوانست گشوده شود. نیاز به اجازهٔ خواندن رسانه است. @@ -363,10 +361,10 @@ بدون شرح تقویت شده برگزیده - عمومی - فهرست‌نشده - پی‌گیران - مستقیم + عمومی + فهرست‌نشده + پی‌گیران + مستقیم نظرسنجی با گزینه‌ها: %1$s، %2$s، %3$s، %4$s؛ %5$s نام فهرست برچسب بدون # @@ -429,7 +427,6 @@ این حساب از کارسازی دیگر است. رونوشتی ناشناس از گزارش، به آن‌جا نیز ارسال شود؟ خطا در یافتن فرستهٔ %s قدرت‌گرفته از تاسکی - پرونده‌های صوتی باید کم‌تر از ۴۰م‌ب باشند. نشانک‌ها نشانک نشانک‌ها diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index cd3ada402..2cf050f7f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -21,7 +21,7 @@ 1 tunti 30 minuuttia 5 minuuttia - Lisää hashtag + Lisää aihetunniste Ei kuvausta CC-BY-SA 4.0 CC-BY 4.0 @@ -37,10 +37,10 @@ Tuskyn profiili Tusky %s Lukittu tili - Uusia tuuttauksia + Uudet tuuttaukset Seuraamispyynnöt - Uusia seuraajia - Uusia mainintoja + Uudet seuraajat + Uudet maininnat HTTP-välityspalvelin Näytä vastaukset Teema @@ -83,8 +83,8 @@ suljettu Suodatin Lista - Hastagit - Julkinen + Aihetunnisteet + Julkinen Kiinnitä Poista kiinnitys Botti @@ -93,7 +93,7 @@ Listat Päivitä Poista - Audio + Ääni Video Kuvat Tietoja @@ -119,14 +119,14 @@ Kuvaus Linkit Maininnat - Hastagit - Hastagit + Aihetunnisteet + Aihetunnisteet Maininnat Linkit Nollaa Luonnokset Hae - Älä hyväksy + Hylkää Hyväksy Peruuta Muokkaa @@ -142,7 +142,7 @@ Profiili Sulje Yritä uudelleen - TUUTTAUS + TUUT Poista Muokkaa Ilmianna @@ -151,7 +151,7 @@ Seurataan Seuraa TUUTAA! - Tuuttaus + Lanka Ajastetut tuuttaukset Vastaa \@%s @@ -167,4 +167,105 @@ Paikallinen Ilmoitukset Koti + Poista suosikki + Avaa jakajan tili + Muokkaa kuvaa + Mykistetyt tilit + %s seurasi sinua + + %d uusi kannsakäyminen + %d uutta kanssakäymistä + + Poista ja kirjoita uudelleen + Samaan julkaisuun ei voi liittää sekä kuvia että videoita. + %1$s, %2$s, ja %3$s + Poista lista + Muuta listan nimeä + Tällaista tiedostoa ei voida ladata ylös. + %s haluaa seurata sinua + Verkostoitu + Lähetys epäonnistui. + Ilmianna @%s + Lisähuomautuksia\? + Tilitietojen lataaminen epäonnistui + Tiedostojen lähettäminen vaatii lukuoikeuden. + Aseta kuvaus + Kirjanmerkki + Poista mykistys verkkonimeltä %s + Avaa tilinä %s + Enemmän + %s on maininnut sinut + Tiedoston lataaminen epäonnistui. + Poista ilmoitusten mykistys tililtä %s + %s jakoi julkaisusi + Julkaisusi on liian pitkä! + Poista keskustelun mykistys + Ladataan kuvaa %1$s + Kuvauksen lisääminen epäonnistui + Mykistä keskustelu + On syntynyt virhe. + Halutako varmasti kirjautua ulos tililtä %s1\? + Poista jako + Luo lista + Mykistä %s + Lisää tili listalle + Piilotetut verkkonimet + %s lisäsi julkaisusi suosikkeihinsa + Näytä jaot + Näytä jaetut julkaisut + Muokkaa listaa + %1$s, %2$s, %3$s ja %4$d muuta + + Kuvaa näkövammaisille +\n(enintään %d merkkiä) + + + Aihetunniste ilman #-merkkiä + Piilotetus verkkonimet + Jaa… + Verkkoselainta ei löytynyt. + Poista keskustelu + Poista tili listalta + %s julkaisi juuri + Vastaa nopeasti + Piilota jaetut julkaisut + Täällä ei ole mitään. Liu\'uta alaspäin päivittääksesi! + On syntynyt verkostovirhe! Tarkista yhteytesi ja yritä uudelleen! + Avaa media No. %d + Julkaisun lähetys epäonnistui. + Tätä kenttää ei voi jättää tyhjäksi. + Tiedotukset + Ilmoitukset seuraamiesi uusista julkaisuista + Lisää suosikiksi + Tiedostojen tallentamiseen vaaditaan kirjoitusoikeus. + %1$s ja %2$s + Syöttämäsi verkkonimi on virheellinen + Poista mykistys tililtä %s + Kirjoita julkaisu + Hae seuraamiasi henkilöitä + Täällä ei ole mitään. + %s rekisteröityi + Rekisteröitymiset + Ilmoitukset uusista käyttäkistä + Sisäänkirjautuminen + Poista kirjanmerkki + 90 päivää + 180 päivää + 365 päivää + Sisäänkirjautumissivun lataaminen epäonnistui. + Jaa + Julkaisun näkyvyys + Vastauksetkin + %s jakoi + Herkkää sisältoä + Klikkaa näyttääksesi + Laajenna + Vähennä + Jaa julkaisun URL… + %s muokkasi julkaisua + Julkaisujen muokkaukset + Kirjaudu uudestaan sisään ottaaksesi vastaan push-ilmoituksia + Mykistä ilmoitukset tililtä %s + Yksityiskohdat + Kuvaa ei voitu muokata. \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 30dc16258..efc60e461 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -10,8 +10,6 @@ Authentification refusée. Impossible de récupérer le jeton d’authentification. Votre message est trop long ! - Le fichier doit avoir moins de 8 Mo. - Les fichiers vidéos doivent avoir moins de 40 Mo. Ce type de fichier ne peut pas être téléversé. Le fichier ne peut pas être ouvert. Permission requise pour lire le média. @@ -285,7 +283,7 @@ Chercher des personnes que vous suivez Ajouter un compte à la liste Supprimer un compte de la liste - Publier avec le compte %1$s + Publier en tant que %1$s Impossible de définir la légende Décrire pour les malvoyants @@ -335,10 +333,12 @@ Épingler %1$s Favori + %1$s de favoris %1$s Favoris %s Partage + %s de partages %s Partages Partagé par @@ -356,12 +356,12 @@ Aucune description Partagé Mis en favoris - Public + Public - Non listé - Abonné·e·s + Non listé + Abonné·e·s - Direct + Direct Nom de la liste Hashtag sans # @@ -451,7 +451,6 @@ Ajouté aux signets Sélectionner la liste Liste - Les fichiers audio doivent avoir moins de 40 Mo. Vous n’avez aucun brouillon. Vous n’avez aucun message planifié. L’intervalle minimum de planification sur Mastodon est de 5 minutes. @@ -555,4 +554,11 @@ Afin de recevoir les notifications via UnifiedPush, Tusky doit demander à votre serveur Mastodon la permission de s’inscrire aux notifications. Ceci nécessite une reconnexion de vos comptes afin de changer les droits OAuth accordés a Tusky. En utilisant l’option de reconnexion ici ou dans les préférences de compte, vos brouillons et le cache seront préservés. Reconnectez tous vos comptes pour activer les notifications instantanées. L\'image n’a pas pu être retouchée. + Supprimer ce message planifié \? + %s (%s) + Toujours + Jamais + Langue du message + (Aucune modification) + %s (🔗 %s) diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index f902d7426..cce1246d0 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -242,9 +242,6 @@ Tastimming om media te lêzen is nedich. Die triem koe net iepene wurde. Dat type triem kin net upload wurde. - Lûdstriemen moatte lytser as 40MB wêze. - Fideo\'s moatte lytse as 40MB wêze. - De triem moat lytser as 8MB wêze. De status is te lang! Koe gjin ynlogtoken krije. Ferifikaasje ôfkard. diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 382566c0a..3744480a4 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -6,20 +6,20 @@ Cliceáil chun amharc Meáin i bhfolach Ábhar íogair - threisigh %s + D\'athchraol %s Ceadúnais - Tútanna sceidealta + Postálacha sceidealta Cuir do phróifíl in eagar Lean Iarrataí Fearainn i bhfolach Úsáideoirí blocáilte Úsáideoirí fuaim Leabharmharcanna - Leanúna - Leanúna + Leantóirí + Ag Leanúint Greamaithe Le freagraí - Tút + Snáithe Teachtaireachtaí Díreacha Cónaidhme Áitiúil @@ -44,8 +44,8 @@ Téama an Aip Dealramh tá deireadh leis na pobalbhreitheanna - ainmnítear mo phoist - treisítear mo tútanna + tá mo chuid postálacha tofa + athchraoltar mó chuid postálacha lean iarrtar lean luaigh @@ -57,15 +57,15 @@ Fógraí Fógraí Díreach: Post chuig úsáideoirí luaite amháin - Leantóirí-Amháin: Postáil do leanúna amháin + Do Leantóirí Amháin: Postáil do do chuid leantóirí amháin Neamhliostaithe: Ná taispeáin in amlínte poiblí Poiblí: Post chuig amlínte poiblí Folaigh fógraí Bloc @%s\? Folaigh an fearann iomlán - An bhfuil tú cinnte gur mhaith leat gach %s a bhac\? Ní fheicfidh tú ábhar ón bhfearann sin in aon amlínte poiblí ná i d’fhógraí. Bainfear do leanúna ón bhfearann sin. - An tút seo a scriosadh agus a dhréachtú\? - Scrios an tút seo\? + An bhfuil tú cinnte gur mhaith leat gach %s a bhac\? Ní fheicfidh tú inneachar ón bhfearann sin in aon amlíne poiblí ar bith ná i do chuid fógraí. Bainfear do chuid leantóirí ón bhfearann sin. + Scrios agus athdhréachtaigh an postáil seo\? + Scrios an postáil seo\? An cuntas seo a scaoileadh\? An iarraidh seo a leanas a chúlghairm\? Íoslódáil @@ -80,35 +80,35 @@ Ainm taispeána Rabhadh ábhair Cad atá ag tarlú\? - Cén cás\? - Cuireadh an freagra go rathúil. + Cén ásc\? + D\'éirigh le freagra a sheoladh. Seolta! %s neamhcheangailte Úsáideoir gan trácht Úsáideoir gan bhac Seolta! - Comhroinn na meáin le… - Comhroinn tút chuig… - Comhroinn URL tút chuig… + Comhroinn meáin chuig… + Comhroinn postáil chuig… + Comhroinn URL na postála chuig… Meáin íoslódála - Íoslódáil na meáin + Íoslódáil meáin Comhroinn mar … Oscail mar %s Cóipeáil an nasc - Íoslódáil %1$s + Ag íoslódáil %1$s Meáin oscailte #%d Naisc Ghréasáin - Taispeáin ainmniúcháin - Taispeáin borradh - Údar borradh oscailte + Taispeáin toghanna + Taispeáin athchraolta + Oscail údar an athchraolta Buaicphointí Naisc ghréasáin Cuir Tab leis - Tút a sceidealú + Sceidealaigh Postáil Méarchlár Emoji Rabhadh ábhair - Infheictheacht tút - Tútanna sceidealta + Infheictheacht postálacha + Postálacha sceidealta Dréachtaí Diúltaigh Glac @@ -121,7 +121,7 @@ Fógraí tost ó %s Fógraí neamhshábháilteachta ó %s Unmute - Tost + Balbhaigh Comhroinn Tóg pictiúr Cuir vótaíocht leis @@ -133,18 +133,18 @@ Úsáideoirí blocáilte Úsáideoirí fuaim Leabharmharcanna - Ainmniúcháin + Toghanna Próifíl Dún Atriail - TÚT! - TÚT + SÉID! + SÉID Scrios agus athdhréachtú Scrios Cuir in Eagar - Inis - Taispeáin borradh - Folaigh borradh + Tuairiscigh + Taispeáin athchraolta + Folaigh athchraolta Ná bac Bac Stop ag leanúint @@ -152,13 +152,10 @@ An bhfuil tú cinnte gur mhaith leat logáil amach as an gcuntas %1$s\? Cum Níos mó - Bain ainmniúchán + Bain togha Leabharmharc - Ainmnigh + Togh Ní féidir an cineál comhaid sin a uaslódáil. - Caithfidh comhaid fuaime a bheith níos lú ná 40MB. - Caithfidh comhaid físe a bheith níos lú ná 40MB. - Caithfidh an comhad a bheith níos lú ná 8MB. Tá an stádas ró-fhada! Theip ar chomhartha logála isteach a fháil. Diúltaíodh údarú. @@ -178,13 +175,13 @@ Sainroghanna Logáil Amach Dréachtaí - Roghaí - Theip ar fhíordheimhniú leis an gcás sin. - Cad is sampla ann\? + Toghanna + Theip ar fhíordheimhniú leis an ásc sin. + Cad is ásc ann\? Logáil isteach le Mastodon Íomhánna - Comhroinn nasc le tút - Comhroinn ábhar na tút + Comhroinn nasc chuig postáil + Comhroinn inneachar na postála Próifíl Tusky Is bogearraí foinse oscailte agus saor in aisce é Tusky. Tá sé ceadúnaithe faoi Leagan 3. Ceadúnas Poiblí Ginearálta GNU 3. Is féidir leat an ceadúnas a fheiceáil anseo: https://www.gnu.org/licenses/gpl-3.0.en.html Cumhachtaithe ag Tusky @@ -199,23 +196,23 @@ Luaigh %s tú Fógraí faoi pobalbhreitheanna a bhfuil deireadh leo Vótaí - Fógraí nuair a mharcáiltear do tútanna mar an ceann is fearr leat - Rogha - Fógraí nuair a dhéantar borradh faoi do tútanna - Borradh + Fógraí nuair a thoghtar do chuid postálacha + Toghanna + Fógraí nuair a athchraoltar do chuid postálacha + Athchraolta Fógraí faoi iarratais a leanúint Lean Iarrataí - Fógraí faoi leanúna nua + Fógraí faoi leantóirí nua Leantóirí Nua Fógraí faoi luanna nua Tagairtí Nua Is mó - Móra - Mheán + Mór + Meán-mhéid Beag - Lúide - Méid an téacs stádais - Leantóirí-amháin + Is bige + Méid an téacs postála + Do leantóirí amháin Neamhliostaithe Poiblí Bun @@ -224,15 +221,15 @@ Theip ar shocruithe a sync Foilsitheoireacht (synced leis an bhfreastalaí) Déan na meáin a mharcáil i gcónaí mar íogaire - Príobháideacht réamhshocraithe tút + Príobháideacht réamhshocraithe postálacha Port seachfhreastalaí HTTP Freastalaí seachfhreastalaí HTTP Cumasaigh seachfhreastalaí HTTP Seachfhreastalaí HTTP Seachfhreastalaí - Íoslódáil réamhamharcanna na meán + Íoslódáil réamhamharcanna meán Taispeáin freagraí - Taispeáin borradh + Taispeáin athchraolta Scagadh amlíne Taispeáin grádáin ildaite do na meáin i bhfolach Beochan abhatár GIF @@ -240,30 +237,30 @@ Teanga Abhatár Tráchtanna - Borradh - Bain borradh + Athchraol + Cealaigh athchraoladh Freagra Freagra Tapa Tuairimí Breise\? Tuairiscigh @%s D’iarr %s tú a leanúint lean %s thú - Chuir %s borradh faoi do tút + D\'athchraol %s do phostáil Níl aon rud anseo. Tarraingt anuas chun athnuachan a dhéanamh! Níl aon rud anseo. - Dlúth + Fill Nuair atá an eochairfhocal nó an frása alfa-uimhriúil amháin, ní chuirfear i bhfeidhm é ach má oireann sé don fhocal iomlán - Tútanna - Bhí %s i bhfabhar do tút + Postálacha + Thogh %s do phostáil Unmute %s Comhrá tost Clibeanna hash Hashtags - Is féidir seoladh nó fearann aon cháis a iontráil anseo, mar shampla mastodon.social, icosahedron.website, social.tchncs.de, agus níos mó! + Is féidir an seoladh nó fearann a ghabhann le hásc ar bith a chur isteach anseo, mar shampla mastodon.social, icosahedron.website, social.tchncs.de, agus níos mó! \n -\nMura bhfuil cuntas agat fós, is féidir leat ainm an cháis ar mhaith leat a bheith páirteach ann agus cuntas a chruthú ann. +\nMuna bhfuil cuntas agat fós, is féidir leat ainm an áisc ar mhaith leat a bheith páirteach ann a chur isteach, agus cuntas a chruthú ann. \n -\nIs áit amháin é sampla ina ndéantar do chuntas a óstáil, ach is féidir leat cumarsáid a dhéanamh go héasca le daoine eile agus iad a leanúint ar chásanna eile mar a bheadh tú ar an suíomh céanna. +\nIs éard atá i gceist le hásc na láthair amháin ina ndéantar do chuntas a óstáil, ach is féidir leat cumarsáid a dhéanamh go héasca le daoine eile agus iad a leanúint ar áisc eile mar a bheadh sibh ar an suíomh céanna. \n \nIs féidir tuilleadh faisnéise a fháil ag joinmastodon.org . Suíomh Gréasáin an tionscadail: @@ -278,7 +275,7 @@ Focal iomlán Frása le scagadh Cuir Cuntas leis - Leathnaigh i gcónaí tútanna atá marcáilte le rabhaidh ábhair + Leathnaigh i gcónaí postálacha atá marcáilte le rabhaidh ábhair Meáin Ag freagairt do @%s luchtú níos mó @@ -287,7 +284,7 @@ Cuir scagaire in eagar Bain Amlínte poiblí - Gach stádas a leathnú/a thit amach + Leathnaigh/Fill na postálacha go léir Beidh ort Tusky a atosú chun na hathruithe seo a chur i bhfeidhm Sraith emoji reatha Google Tá cód agus sócmhainní ó na tionscadail foinse oscailte seo a leanas i Tusky: @@ -297,17 +294,17 @@ Vótaíocht le roghanna: %1$s, %2$s, %3$s, %4$s; %5$s Liosta - Cumadh Tút + Scríobh postáil An bhfuil tú cinnte gur mhaith leat do chuid fógraí go léir a ghlanadh go buan\? Tá deireadh le vótaíocht a chruthaigh tú - D\'imigh %d nóiméad - D\'imigh %d nóiméad - D\'imigh %d nóiméad - D\'imigh %d nóiméad - D\'imigh %d nóiméad + %d nóiméad fágtha + %d nóiméid fágtha + %d nóiméid fágtha + %d nóiméid fágtha + %d nóiméid fágtha - Theip ar stádas a fháil + Theip ar postálacha a ghabháil Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos: Cuir Cuntas Mastodon nua leis Níorbh fhéidir liosta a chruthú @@ -329,20 +326,20 @@ Socraigh fotheideal Bain Cuntas glasála - Éilíonn ort leanúna a cheadú de láimh + Éileofar ort leantóirí a cheadú de láimh Sábháil dréacht\? - Tút a sheoladh… - Earráid agus an tút á sheoladh - Tútanna a sheoladh - Seoladh curtha ar ceal - Sábháladh cóip den tút ar do dhréachtaí + Postáil á sheoladh… + Earráid le postáil a sheoladh + Postálacha a Sheoladh + Cealaíodh seoladh + Sábháladh cóip den phostáil i do chuid dréachtaí Cum - Níl aon emojis saincheaptha ag do shampla %s + Níl emoji-nna saincheaptha ag d\'ásc %s Stíl Emoji Réamhshocrú an chórais - Beidh ort na tacair emoji seo a íoslódáil ar dtús + Caithfear na tacair emoji seo a íoslódáil ar dtús Amharc taibhithe… - Oscail tút + Oscail postáil Atosú aip de dhíth Níos déanaí Atosaigh @@ -352,7 +349,7 @@ Theip ar íoslódáil Bot Tá %1$s tar éis bogadh go: - Treisiú leis an lucht féachana bunaidh + Athchraol don bhunlucht léite Ceadúnaithe faoin gCeadúnas Apache (cóip thíos) Meiteashonraí próifíle cuir sonraí leis @@ -361,32 +358,32 @@ Úsáid am iomlán Bioráin - %1$s Ainmniú - %1$s Ainmniúchán - %1$s Ainmniúchán - %1$s Ainmniúchán - %1$s Ainmniúchán + %1$s Togha + %1$s Toghanna + %1$s Toghanna + %1$s Toghanna + %1$s Toghanna - %s borradh - %s borradh - %s borradh - %s borradh - %s borradh + %s athchraoladh + %s athchraolta + %s athchraolta + %s athchraolta + %s athchraolta - Treisithe ag - Ainmnithe ag + Athchraolta ag + Tofa ag %1$s agus %2$s %1$s, %2$s agus %3$d níos mó Meáin: %s Rabhadh ábhair: %s - Gan tuairisc - Ainmnithe + Gan cur síos + Tofa Leabharmharcáilte - Poiblí - Neamhliostaithe - Leanúna - Díreach + Poiblí + Neamhliostaithe + Leantóirí + Díreach Ainm liosta Cuir hashtag leis Hashtag gan # @@ -449,8 +446,8 @@ Taispeáin scagaire Fógraí Cumasaigh gotha swipe aistriú idir cluaisíní Vótaíocht - 5 nóiméad - 30 nóiméad + 5 nóiméid + 30 nóiméid 1 uair an chloig 6 uair an chloig 1 lá @@ -461,14 +458,14 @@ Rogha %d Cuir in Eagar Earráid agus an post á lorg %s - Níl aon dréachtaí agat. - Níl aon stádas sceidealta agat. + Níl aon dréacht agat. + Níl aon phostáil sceidealta agat. Tá eatramh sceidealaithe íosta 5 nóiméad ag Mastodon. Taispeáin réamhamhairc nasc in amlínte - Taispeáin dialóg dearbhaithe sula ndéantar borradh faoi + Taispeáin dialóg dearbhaithe sula n-athchraolfar Cluaisíní \@%s - Tost %s + Balbhaigh %s Unmute %s Comhrá unmute Tost @%s\? @@ -483,11 +480,32 @@ %dh %dm %ds - Cur i gcoinne + Bain athchraoladh CC-BY 4.0 CC-BY-SA 4.0 Unpin %1$s Reblogged Hashtags + Folófar roinnt eolais ar féidir leis cuir isteach ar do mheabhairshláinte. Tá an méid seo a leanas san áireamh: +\n +\n - fógraí Toghanna/Athchraolta/Leanúna +\n - líon Toghanna/Athchraolta ar postálacha +\n - staitisticí Leantóirí/Postálacha ar phróifílí +\n +\n Ní chuirfear isteach ar brúfhógraí, ach is féidir leat do chuid sainroghanna phearsanra d\'fhógraí a athbhreithniú de láimh. + Theip ar sonraí an chuntais a lódáil + Rialacha %s + Postálacha nua + Do nóta príobháideach faoin cuntas seo + Anseo ó %1$s + Tá %s tar éis postáil + Chuir %s postáil in eagar + Logáil isteach + Chláraigh %s + Bain leabharmharc + Scrios comhrá + Dún + Sonraí + Scrios an comhrá seo\? \ No newline at end of file diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 294bdc56a..e804da38b 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -179,7 +179,7 @@ Cuir taga hais ris Ainm na liosta Cunntas-bheachd le roghainnean: %1$s, %2$s, %3$s, %4$s; %5$s - Dìreach + Dìreach ’Na chomharra-lìn ’Na annsachd Air ath-bhlogadh @@ -260,7 +260,7 @@ \n(%d caractar air a char as fhaide) Cha deach leinn am fo-thiotal a shuidheachadh - A’ postadh leis a’ chunntas %1$s + A’ postadh mar %1$s Thoir an cunntas air falbh on liosta Cuir cunntas ris an liosta Lorg daoine air a leanas tu @@ -301,9 +301,9 @@ Mearachd a’ cur a’ phuist. Dì-mhùch %s Tagaichean hais - Luchd-leantainn - Neo-liostaichte - Poblach + Luchd-leantainn + Neo-liostaichte + Poblach %1$s ’s %2$s Thoir air falbh Facal slàn @@ -520,9 +520,6 @@ Tha feum air cead gus meadhanan a leughadh. Cha b’ urrainn dhuinn am faidhle sin fhosgladh. Cha ghabh an seòrsa de dh’fhaidhle seo a luchdadh suas. - Feumaidh faidhlichean fuaime a bhith nas lugha na 40MB. - Feumaidh faidhlichean video a bhith nas lugha na 40MB. - Feumaidh am faidhle a bhith nas lugha na 8MB. Tha am post ro fhada! Cha deach leinn tòcan clàraidh a-steach fhaighinn. Chaidh an t-ùghdarrachadh a dhiùltadh. @@ -563,4 +560,22 @@ Clàraich a-steach às ùr airson brathan putaidh 1+ Deasaich an dealbh + Mearachd a’ leantainn air #%s + Chan fhaod na faidhlichean video ’ fuaime a bhith nas motha na %s MB. + Mearachd a’ sgur de leantainn air #%s + Mearachd a’ luchdadh fiosrachadh a’ chunntais + Cha b’ urrainn dhuinn an dealbh a dheasachadh. + Airson brathan putaidh slighe UnifiedPush a chleachdadh, feumaidh Tusky cead airson fo-sgrìobhadh air brathan air an fhrithealaiche Mastodon agad fhaighinn. Bidh feum air clàradh a-steach às ùr airson na sgòpaichean OAuth a chaidh a cheadachadh dha Tusky atharrachadh. Ma nì thu clàradh a-steach às ùr an-seo no ann an roghainnean a’ chunntais, cumaidh sinn na dreachdan is an tasgadan ionadail agad. + Rinn thu clàradh a-steach às ùr dhan chunntas làithreach agad airson cead fo-sgrìobhadh putaidh a thoirt dha Tusky. Gidheadh, cha cunntasan eile agad fhathast nach deach imrich air an dòigh sin. Geàrr leum thuca is dèan clàradh a-steach às ùr do gach fear dhiubh airson taic do bhrathan UnifiedPush a chur an comas dhaibh. + (Gun atharrachadh) + Seall an t-ainm-cleachdaiche air na bàraichean-inneal + Thoir gogag no slaod an cearcall a thaghadh puing an fhòcais a chithear air na dealbhagan an-còmhnaidh. + Cànan a’ phuist + An-còmhnaidh + Nuair a bhios iomadh cunntas air an clàradh a-steach + Chan ann idir + %s (%s) + %s (🔗 %s) + Dh’fhàillig suidheachadh na puinge-fòcais + Suidhich puing an fhòcais \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index af350478c..5954adab2 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -112,9 +112,6 @@ Requírese o permiso de lectura do multimedia. Non se puido abrir o ficheiro. Non pode subirse ese tipo de ficheiro. - Os ficheiros de audio teñen que ser menores de 40MB. - Os ficheiros de vídeo teñen que ser menores de 40MB. - O ficheiro debe ser menor de 8MB. A publicación é demasiado longa! Fallou a obtención do token de acceso. A autorización foi rexeitada. @@ -133,7 +130,7 @@ Duración Enquisa Activar xestos de desprazamento para moverse entre lapelas - Motrar filtro das notificacións + Mostrar filtro das notificacións Fallou a busca Contas A conta pertence a outro servidor. Queres enviar unha copia anónima da denuncia alí tamén\? @@ -190,10 +187,10 @@ Engadir cancelo Nome da lista Enquisa con opcións: %1$s, %2$s, %3$s, %4$s; %5$s - Directo - Seguidoras - Non listado - Público + Directo + Seguidoras + Non listado + Público Marcado Favorecido Repetido @@ -264,7 +261,7 @@ \n(%d caracteres como máximo) Fallou establecemento do texto - Publicar coa conta %1$s + Publicar como %1$s Eliminar conta da listaxe Engadir conta á listaxe Atopar persoas ás que segues @@ -537,4 +534,16 @@ Gardando borrador… 1+ Editar imaxe + Fallou a carga dos detalles da conta + A imaxe non puido ser editada. + Toca ou arrastra o círculo para elexir onde centrar a imaxe e sexa máis visible nas miniaturas. + (Sen cambio) + %s (%s) + Os ficheiros de vídeo e audio non poden superar os %s MB. + Idioma de publicación + %s (🔗 %s) + Fallou o establecemento do foco + Establece foco + Erro ao seguir #%s + Error ao retirar seguimento de #%s \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index dba9309b0..972a57451 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -27,17 +27,14 @@ पुनः प्रयास करें बंद करें प्रोफाइल - अनुगामी - फ़ाइल 8 एमबी से कम होनी चाहिए। + अनुगामी अपलोड विफल रहा। मीडिया को स्टोर करने की अनुमति आवश्यक है। मीडिया पढ़ने की अनुमति आवश्यक है। वह फ़ाइल नहीं खोली जा सकी। उस प्रकार की फ़ाइल अपलोड नहीं की जा सकती। - ऑडियो फाइलें 40 एमबी से कम होनी चाहिए। एक त्रुटि हुई। रीसेट - वीडियो फ़ाइलें 40MB से कम होनी चाहिए। लॉगिन टोकन प्राप्त करने में विफल। प्राधिकरण करने के से इनकार कर दिया। एक अज्ञात प्राधिकरण त्रुटि हुई। @@ -275,9 +272,9 @@ साफ करें सूची का चयन करें हैशटैग जोड़ें - प्रत्यक्ष - असूचीबद्ध - सार्वजनिक + प्रत्यक्ष + असूचीबद्ध + सार्वजनिक विषय वस्तु चेतावनी: %s अधिकतम %1$d टैब तक पहुंच गऐ diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 45075ca8d..2c40293ec 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -10,8 +10,6 @@ Engedély megtagadva. Bejelentkezési token megszerzése sikertelen. Túl hosszú a bejegyzés! - A fájlnak kisebbnek kell lennie, mint 8 MB. - A videofájloknak kisebbnek kell lenniük, mint 40 MB. Ilyen típusú fájlt nem lehet feltölteni. Fájl megnyitása sikertelen. Média olvasási engedély szükséges. @@ -280,8 +278,8 @@ elérted a fülek maximális számát (%1$d) Nincs leírás - Nyilvános - Követők + Nyilvános + Követők Kedvenc eltávolítása Törlés és újrafogalmazás Média megnyitása: #%d @@ -339,9 +337,11 @@ Általad követettek keresése Fiók hozzáadása a listához Fiók eltávolítása a listából - Bejegyzés %1$s fiókkal + Bejegyzés mint %1$s Cím beállítása nem sikerült + Leírás látássérülteknek +\n(%d karakter korlát) Leírás látássérülteknek \n(%d karakter korlát) @@ -368,8 +368,8 @@ Tartalomfigyelmeztetés: %s Megtolt Kedvelt - Listázatlan - Közvetlen + Listázatlan + Közvetlen Szavazás válaszokkal: %1$s, %2$s, %3$s, %4$s; %5$s Lista neve Hashtag # nélkül @@ -446,7 +446,6 @@ Könyvjelzőzve Lista kiválasztása Lista - A hangfájloknak kisebbnek kell lenniük, mint 40 MB. Nincs egy piszkozatod sem. Nincs egy ütemezett bejegyzésed sem. A Mastodonban a legrövidebb ütemezhető időintervallum 5 perc. @@ -547,4 +546,19 @@ Kép szerkesztése A kép nem szerkeszthető. Nem sikerült betölteni a fiókadatokat + Felhasználónév mutatása az eszköztáron + Töröljük ezt az időzített bejegyzést\? + Koppintsd vagy húzd a kört, hogy kijelöld azt a fókuszpontot, mely mindig látható lesz az előnézetekben. + %s (%s) + Video és audio állományok mérete nem lehet %s MB-nál nagyobb. + Bejegyzés nyelve + Mindig + Ha több fiók is be van jelentkezve + Soha + (Nincs változás) + %s (🔗 %s) + Nem sikerült a fókuszpont beállítása + Fókuszpont beállítása + Hiba a #%s követésekor + Hiba a #%s követésének befejezésekor diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 000000000..be7fc95ee --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,171 @@ + + + Kolom ini tidak boleh kosong. + Domain yang dimasukkan tidak valid + Tidak dapat menemukan browser untuk digunakan. + Terjadi kesalahan otorisasi yang tidak diketahui. + Otorisasi ditolak. + Gagal mendapatkan token masuk. + Masuk + Beranda + Notifikasi + Lokal + Pesan Langsung + Berkas video dan audio tidak boleh melebihi %s MB. + Gambar tidak dapat diubah. + Jenis berkas tersebut tidak dapat diunggah. + Berkas itu tidak dapat dibuka. + Gagal mengunggah. + Tab + Utas + Terjadi kesalahan saat mengikuti #%s + Terjadi kesalahan saat berhenti mengikuti #%s + Tidak dapat memuat halaman masuk. + Postingan + Dengan balasan + Disematkan + Mengikuti + Pengikut + Favorit + Markah + Pengguna diblokir + Domain tersembunyi + Ubah profil + Draf + Postingan terjadwal + Postingan terlalu panjang! + Izin untuk membaca media diperlukan. + Izin untuk menyimpan media diperlukan. + Lisensi + \@%s + Media disembunyikan + Klik untuk melihat + Tampilkan Lebih Banyak + Tampilkan Lebih Sedikit + Tidak ada apa pun disini. + %s mengikuti Anda + Laporkan @%s + Ikuti + Berhenti Mengikuti + Blokir + Buka blokir + Laporkan + Hapus + Coba lagi + Tutup + Profil + Preferensi + Preferensi Akun + Favorit + Markah + Pengguna dibisukan + Pengguna diblokir + Media + Buka di peramban + Tambahkan media + Ambil gambar + Bagikan + Bisukan + Jangan bisukan %s + Jangan bisukan notifikasi dari %s + Bisukan notifikasi dari %s + Bisukan %s + Jangan bisukan %s + Jangan bisukan percakapan + Simpan + Ubah profil + Ubah + Batalkan + Tolak + Terima + Cari + Draf + Setel Ulang + Tautan + Detail + Salin tautan + Mengunduh %1$s + Unduh media + Mengunduh media + Terkirim! + Terkirim! + Balasan berhasil dikirim. + Apa yang terjadi\? + Cari… + Menghubungkan… + Menyelesaikan Pengunggahan Media + Blokir @%s\? + Bisukan @%s\? + Hapus percakapan ini\? + Beri tahu dengan suara + Beri tahu dengan getaran + Beri tahu dengan lampu + Gelap + Terang + Hitam + Linimasa + Tema Aplikasi + Peramban + Tampilkan balasan + Atas + Bawah + Publik + Kecil + Sedang + Besar + Terbesar + Selalu + Jangan Pernah + Pengikut Baru + Notifikasi tentang pengikut baru + Daftar + Tentang + Tusky %s + Dipersembahkan oleh Tusky + Video + Audio + Lampiran + 1+ + Mengikuti Anda + Tambahkan Akun Mastodon baru + Daftar + Daftar + Tidak dapat mengubah nama daftar + Tidak dapat menghapus daftar + Buat daftar + Ubah nama daftar + Hapus daftar + Ubah daftar + Kunci akun + Bawaan sistem + Tulis + Mulai ulang + Disimpan! + Terjadi kesalahan. + Terjadi kesalahan pada jaringan! Harap periksa koneksi Anda dan coba lagi! + Gagal memuat detail akun + Gagal mengirim postingan. + Pengguna dibisukan + Pengumuman + Konten sensitif + %s meminta untuk mengikuti anda + Ubah + Hapus percakapan + Jangan bisukan + Bisukan percakapan + Jadwalkan Postingan + Postingan terjadwal + Balas… + Mengunggah… + Sembunyikan notifikasi + Penampilan + Bahasa + Terkecil + Favorit + Gambar + Tambah Akun + Tidak dapat membuat daftar + Nanti + Autentikasi gagal dilakukan. + Tulis Postingan + \ No newline at end of file diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 77350a872..a76f0b5a6 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -22,8 +22,6 @@ Heimild var hafnað. Mistókst að fá innskráningarteikn. Færslan er of löng! - Skráin verður að vera minni en 8MB. - Myndskeiðaskrár verða að vera minni en 40MB. Þessa tegund skrár er ekki hægt að senda inn. Ekki var hægt að opna skrána. Krafist er heimilda til að lesa gögn. @@ -362,10 +360,10 @@ Endurbloggað Í eftirlætum Bókamerkt - Opinbert - Óskráð - Fylgjendur - Beint + Opinbert + Óskráð + Fylgjendur + Beint Könnun með valkostunum: %1$s, %2$s, %3$s, %4$s; %5$s Heiti á lista Myllumerki án # @@ -416,7 +414,6 @@ Villa við að fletta upp færslunni %s Þú ert ekki með nein drög. Þú ert ekki með neinar áætlaðar stöðufærslur. - Hljóðskrár verða að vera minni en 40MB. Mastodon er með 5 mínútna lágmarksbil fyrir áætlaðar aðgerðir. Fylgjendabeiðnir Myllumerki @@ -537,4 +534,12 @@ Skráðu aftur inn fyrir ýti-tilkynningar Hunsa Nánar + %s (%s) + Myndskeiða- og hljóðskrár geta ekki verið stærri en %s MB. + Tungumál færslu + (engin breyting) + Villa við að fylgjast með #%s + Villa við að hætta að fylgjast með #%s + Mistókst að hlaða inn nánari upplýsingum notandaaðgangs + Ekki var hægt að breyta myndinni. \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 541996b51..707ce4211 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -3,22 +3,20 @@ Si è verificato un errore. Si è verificato un errore di rete! Per favore controlla la tua connessione e riprova! Questo non può essere vuoto. - Inserito dominio non valido + Inserito un dominio non valido Autenticazione con quell\'istanza fallita. Nessun browser web utilizzabile trovato. Si è verificato un errore di autenticazione non identificato. Autorizzazione negata. Acquisizione token di accesso fallita. - Il post è troppo lungo! - Il file deve essere più piccolo di 8 MB. - I video devono essere più piccoli di 40 MB. + Il messaggio è troppo lungo! Quel tipo di file non può essere caricato. Non è stato possibile aprire quel file. È richiesto il permesso di leggere file. È richiesto il permesso di salvare file. - Non è possibile allegare allo stesso post immagini e video. + Non è possibile allegare nello stesso messaggio immagini e video. Il caricamento è fallito. - Errore nell\'invio del post. + Errore nell\'invio del messaggio. Home Notifiche Locale @@ -26,7 +24,7 @@ Messaggi diretti Schede Conversazione - Post + Messaggi Con risposte Fissati Seguiti @@ -50,8 +48,8 @@ Qui non c\'è nulla. Qui non c\'è nulla. Trascina verso il basso per aggiornare! %s ha condiviso il tuo post - %s ha messo il tuo post nei preferiti - %s ti ha seguito + %s ha messo il tuo messaggio nei preferiti + %s ti segue Segnala @%s Commenti aggiuntivi? Risposta veloce @@ -102,7 +100,7 @@ Rifiuta Cerca Bozze - Visibilità dei post + Visibilità dei messaggi Avviso di contenuto sensibile Tastiera emoji Aggiungi scheda @@ -117,11 +115,11 @@ Collegamenti Apri media #%d Scaricando %1$s - Copia link + Copia collegamento Apri come %s Condividi come … - Condividi URL del post su… - Condividi post su… + Condividi URL del messaggio su… + Condividi messaggio su… Condividi media su… Inviato! Utente sbloccato @@ -152,10 +150,10 @@ Scarica Revocare la richiesta di seguire? Smettere di seguire questo account? - Eliminare questo post\? + Eliminare questo messaggio\? Pubblico: visibile sulle timeline pubbliche Non in elenco: non visibile sulle timeline pubbliche - Solo follower: visibile solo dai tuoi follower + Solo chi ti segue: visibile solo da chi ti segue Diretto: visibile solo agli utenti menzionati Notifiche Notifiche @@ -166,8 +164,8 @@ Notificami quando vengo menzionato vengo seguito - i miei post vengono condivisi - i miei post vengono messi nei preferiti + i miei messaggi vengono condivisi + i miei messaggi vengono messi nei preferiti Aspetto Tema dell\'app Timeline @@ -197,8 +195,8 @@ Sincronizzazione delle impostazioni fallita Pubblico Non in elenco - Solo follower - Dimensione del testo dei post + Solo seguaci + Dimensione del testo dei messaggi Piccolissimo Piccolo Normale @@ -206,18 +204,19 @@ Grandissimo Nuove menzioni Notifiche di quando vieni menzionato da qualcuno - Nuovi follower - Notifiche su nuovi follower + Nuovi seguaci + Notifiche su nuovi seguaci Condivisioni - Notifiche sui tuoi post che vengono condivisi + Notifiche sui tuoi messaggi che vengono condivisi Preferiti - Notifiche sui tuoi post che vengono segnati come preferiti + Notifiche sui tuoi messaggi che vengono segnati come preferiti %s ti ha menzionato %1$s, %2$s, %3$s e %4$d altri %1$s, %2$s e %3$s %1$s e %2$s %d nuova interazione + %d nuove interazioni %d nuove interazioni Account bloccato @@ -237,8 +236,8 @@ Segnala problemi e richiedi funzionalità: \n https://github.com/accelforce/Yuito/issues Profilo di Yuito - Condividi contenuto del post - Condividi link al post + Condividi contenuto del messaggio + Condividi collegamento al messaggio Immagini Video Richiesta inviata @@ -254,10 +253,10 @@ %dmin %ds Ti segue - Mostra sempre tutti i contenuti sensibili + Mostra sempre i contenuti sensibili Media Rispondendo a @%s - carica altri + carica altro Timeline pubbliche Conversazioni Aggiungi filtro @@ -279,29 +278,34 @@ Cerca tra le persone che segui Aggiungi un account alla lista Rimuovi un account dalla lista - Pubblicando con l\'account %1$s + Pubblicando come %1$s Impostazione del sottotitolo non riuscita - Descrivi per ipovedenti\n(limite di %d caratteri) + Descrivi per ipovedenti +\n(limite di %d caratteri) + Descrivi per ipovedenti +\n(limite di %d caratteri) + Descrivi per ipovedenti +\n(limite di %d caratteri) Inserisci descrizione Rimuovi Blocca account Richiedi una tua approvazione manuale per seguirti Salvare bozza? - Inviando il post… + Inviando il messaggio… Errore durante l\'invio - Invio post + Invio messaggi Invio annullato - Una copia del post è stata salvata nelle tue bozze + Una copia del messaggio è stata salvata nelle tue bozze Componi La tua istanza %s non ha nessuna emoji personalizzata Stile delle emoji Predefinite del sistema Dovrai prima scaricare questo pacchetto di emoji Ricerca in corso… - Espandi/riduci tutti i post - Apri post + Espandi/riduci tutti i messaggi + Apri messaggio Riavvio dell\'app richiesto Devi riavviare Yuito per applicare queste modifiche Più tardi @@ -309,10 +313,10 @@ Le emoji predefinite del tuo dispositivo Le emoji Blob di Android 4.4-7.1 Le emoji standard di Mastodon - Download fallito + Scaricamento fallito Bot %1$s si è spostato su: - Condividi con la visibilità del post originale + Condividi con la visibilità del messaggio originale Annulla condivisione Yuito contiene codice e risorse dai seguenti progetti open source: Licenziata sotto la Licenza Apache (copia sotto) @@ -328,11 +332,13 @@ Fissa %1$s Preferito + %1$s Preferiti %1$s Preferiti - <b>%s</b> Boost - <b>%s</b> Boost + %s Condivisione + %s Condivisioni + %s Condivisioni Condiviso da Aggiunto ai preferiti da @@ -341,26 +347,24 @@ %1$s, %2$s ed altri %3$d limite massimo di %1$d scheda raggiunto + limite massimo di %1$d schede raggiunto limite massimo di %1$d schede raggiunto Media: %s - Contenuto sensibile: %s - - Nessuna descrizione - - Ribloggato - + Contenuto sensibile: %s + Nessuna descrizione + Ribloggato Messo nei preferiti - Pubblico + Pubblico - Non in elenco - Solo follower - Diretti + Non in elenco + Solo seguaci + Diretti Nome della lista Scarica media Scaricando media - Componi post + Componi messaggio Hashtag senza # Componi Svuota @@ -369,9 +373,10 @@ Mostra indicatore bot Sei sicuro di voler permanentemente eliminare tutte le tue notifiche\? Cancella e riscrivi - Cancellare e riscrivere questo post\? + Cancellare e riscrivere questo messaggio\? %s voto + %s voti %s voti si conclude alle %s @@ -384,9 +389,9 @@ Sei sicuro di voler bloccare tutto %s\? Non vedrai nessun contenuto da quel dominio in nessuna timeline pubblica o nelle tue notifiche. I tuoi seguaci che stanno in quel dominio saranno rimossi. Nascondi l\'intero dominio dei sondaggi si sono conclusi - Riproduci animazioni avatar + Riproduci animazioni avatars Votazioni - Notifiche sulle votazioni che si sono concluse + Notifiche sui sondaggi che si sono conclusi Parola intera Quando la parola chiave o la frase sono composte da soli caratteri alfanumerici, sarà applicata solo se corrisponde alla parola completa Set di emoji di Google @@ -396,7 +401,7 @@ Segnalibri Aggiungi sondaggio Fatto usando Tusky - Espandi sempre i post segnalati come contenuto sensibile + Espandi sempre i messaggi segnalati come contenuto sensibile Messo nei segnalibri Sondaggio con scelte: %1$s, %2$s, %3$s, %4$s; %5$s Scegli lista @@ -406,18 +411,22 @@ Un sondaggio che hai creato si è concluso %d giorno rimasto + %d giorni rimasti %d giorni rimasti %d ora rimasta - %d ore rimasti + %d ore rimaste + %d ore rimaste %d minuto rimasto + %d minuti rimasti %d minuti rimasti %d secondo rimasto + %d secondi rimasti %d secondi rimasti Continua @@ -427,8 +436,8 @@ Altri commenti Inoltra a %s Segnalazione fallita - Scaricamento dei post fallito - La segnalazione sarà inviata al moderatore del tuo server. Puoi spiegare perchè stai segnalando questo utente qui sotto: + Scaricamento dei messaggio fallito + La segnalazione sarà inviata al moderatore del tuo server. Puoi spiegare perchè stai segnalando l\'utente qui sotto: L\'utente è su un altro server. Mandare una copia della segnalazione anche lì\? Utenti Errore durante la ricerca @@ -447,13 +456,14 @@ Modifica Errore nella ricerca del post %s Post programmati - Post programmati - Programma un post + Messaggi programmati + Programma un messaggio Ripristina %1$s • %2$s Non hai bozze. %s persona + %s persone %s persone Hashtag @@ -464,17 +474,16 @@ Smetti di silenziare la conversazione Silenzia conversazione %s ha chiesto di seguirti - I file audio devono essere più piccoli di 40 MB. Smetti di silenziare %s Richieste di seguirti Salvato! La tua nota privata su questo account Nascondi il titolo della barra degli strumenti in alto - Mostra la finestra di conferma prima di condividere + Chiedi conferma prima di condividere Mostra le anteprime dei collegamenti nelle timelines Mastodon ha un intervallo di programmazione minimo di 5 minuti. Non ci sono annunci. - Non hai post pianificati. + Non hai messaggi programmati. Abilita il gesto di scorrimento per passare da una scheda all\'altra Notifiche sulle richieste di essere seguiti In fondo @@ -488,20 +497,21 @@ mi viene richiesto di seguirmi Nascondi statistiche quantitative sui profili Nascondi le statistiche quantitative sui post - Limita notifiche riguardo statistiche quantitative + Limita le notifiche della timeline Rivedi le notifiche Benessere - Notifiche di nuovi post di qualcuno a cui sei iscritto - Nuovi post - qualcuno che seguo ha pubblicato un nuovo post + Notifiche di nuovi messaggi di qualcuno a cui sei iscritto + Nuovi messaggi + qualcuno che seguo ha pubblicato un nuovo messaggio %s ha appena pubblicato Non puoi caricare più di %1$d allegato multimediale. + Non puoi caricare più di %1$d allegati multimediali. Non puoi caricare più di %1$d allegati multimediali. - Il post a cui hai scritto una risposta è stato rimosso + Il messaggio a cui hai scritto una bozza di risposta è stato rimosso Bozza eliminata - L\'invio di questo post è fallito! + L\'invio di questo messaggio è fallito! Sei sicuro di voler cancellare la lista %s\? Indefinita Durata @@ -521,27 +531,52 @@ \n \n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente. Rimuovi segnalibro - Chiedi conferma prima di condividere + Chiedi conferma prima di apprezzare 14 giorni 30 giorni 60 giorni 90 giorni 180 giorni 365 giorni - Anche se il tuo account non è bloccato, lo staff di %1$s ha pensato che potresti voler verificare le richieste di seguirti da parte questi account manualmente. + Anche se il tuo account non è bloccato, lo staff di %1$s ha pensato che potresti voler verificare le richieste di seguirti da parte questi utenti manualmente. %s si è registrato qualcuno si è registrato - Login - %s ha modificato il suo post - un post con cui ho interagito è stato modificato - Componi post + Accesso + %s ha modificato il suo messaggio + un messaggio con cui ho interagito è stato modificato + Componi messaggio Registrazioni Notifiche di quando qualcuno si è registrato - Modifiche ai post - Notifiche di quando i post con cui hai interagito vengono modificati - Non è stato possibile caricare la pagina di login. + Modifiche ai messaggi + Notifiche di quando i messaggi con cui hai interagito vengono modificati + Non è stato possibile caricare la pagina di accesso. Modifica immagine Salvataggio bozza… Scartare Dettagli + Riaccedi a tutti le utenze per attivare il supporto delle notifiche. + Al fine di utilizzare le notifiche tramite UnifiedPush, Tusky ha bisogno del permesso di sottoscrivere alle notifiche nella tua istanza Mastodon. Questo richiede un nuovo accesso per cambiare l\'OAuth precedentemente concesso a Tusky. Usare questa opzione qui o nelle preferenze dell\'account preserva tutte le tue bozze locali e la memoria temporanea (cache). + %s (%s) + Nuovo accesso eseguito per l\'utenza corrente al fine di garantire il permesso delle notifiche a Tusky. Però hai altre utenze che non sono state migrate in questo modo. Cambia utenza e riaccedi una alla volta per abilitare il supporto alle notifiche UnifiedPush. + Registrato da %1$s + Video and audio files non possono eccedere %s MB in dimensione. + L\'immagine non può essere modificata. + Riaccedi per le notifiche + Errore provando a seguire #%s + Errore smettendo di provare a seguire #%s + 1+ + Caricamento dettagli utente fallito + Cancellare questo post programmato\? + Regole di %s + Impossibile selezionare il punto focale + Facendo il log in accetti le regole di %s. + Tappa o crea un cerchio per scegliere il punto focale che sarà sempre visibile nelle anteprime. + Imposta punto focale + Lingua del post + Sempre + Mai + Quando connesso con più account + (nessuna modifica) + Mostra nome utente nelle barre strumenti + %s (🔗 %s) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9981724f0..620c65703 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -9,8 +9,6 @@ 承認が拒否されました。 ログイントークンの取得に失敗しました。 投稿文が長すぎます! - ファイルは4MB未満にしてください。 - ビデオファイルは40MB未満にしてください。 その形式のファイルはアップロードできません。 ファイルを開けませんでした。 メディアの読み取り許可が必要です。 @@ -325,11 +323,11 @@ タブは %1$d 個が上限です - 公開 + 公開 - 未収載 + 未収載 - ダイレクト + ダイレクト リスト名 ネットワークエラーが発生しました!接続を確認してもう一度試してください! @@ -404,7 +402,6 @@ 検索に失敗しました 通知フィルターを表示 リセット - 音声ファイルは40MB未満にしてください。 ブックマーク ブックマーク 編集 @@ -412,7 +409,7 @@ 予約トゥート 予約トゥート 予約トゥート - フォロワー + フォロワー %1$sさん、%2$sさん フォローリクエスト %sさんのミュートを解除 diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index fc7b0f238..c22e207b5 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -207,7 +207,7 @@ Ig ṭṭafar Imeḍfaṛen Nadi ɣef medden ar at ḍfereḍ - Imeḍfaṛen + Imeḍfaṛen Iseɣwan Tibdarin Tibdarin diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e8143bb95..b6c4027a0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -10,8 +10,6 @@ 인증이 거부되었습니다. 로그인 토큰을 받아올 수 없습니다. 게시물 길이가 너무 깁니다! - 파일 크기가 8MB 이상인 사진은 업로드할 수 없습니다. - 파일 크기가 40MB 이상인 동영상은 업로드할 수 없습니다. 이 파일은 첨부할 수 없습니다. 이 파일을 읽지 못했습니다. 미디어를 읽기 위한 권한이 필요합니다. @@ -360,10 +358,10 @@ 설명 없음 부스트함 즐겨찾기함 - 공개 - 타임라인에 비표시 - 비공개 - 다이렉트 + 공개 + 타임라인에 비표시 + 비공개 + 다이렉트 투표 선택지: %1$s, %2$s, %3$s, %4$s, %5$s 리스트 이름 #를 제외한 해시태그 diff --git a/app/src/main/res/values-large-land/dimens.xml b/app/src/main/res/values-large-land/dimens.xml new file mode 100644 index 000000000..8a9db25eb --- /dev/null +++ b/app/src/main/res/values-large-land/dimens.xml @@ -0,0 +1,3 @@ + + 180dp + diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 951ed4757..6cfd04054 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -22,8 +22,6 @@ ആധികാരികത ഉറപ്പുവരുത്താനായില്ല. ഒരു പ്രവേശന ടോക്കൺ ലഭ്യമാക്കുന്നതിൽ പരാജയപ്പെട്ടു. ഈ സ്റ്റാറ്റസ് വളരെ നീളമേറിയതാണ്! - ഫയൽ 8 എംബിയേക്കാളും ചെറുതായിരിക്കണം. - ചലച്ചിത്ര ഫയലുകൾ 40എംബിയിലും ചെറുതായിരിക്കണം. ഇത്തരം ഫയൽ അപ്‌ലോഡ് ചെയ്യാൻ സാധിക്കില്ല. ഈ ഫയൽ തുറക്കാനായില്ല. മീഡിയ വായിക്കുവാനുള്ള അനുമതി ആവശ്യമാണ്. @@ -58,7 +56,7 @@ വീണ്ടും ശ്രമിക്കുക പിന്‍തുടരുവാനുള്ള അഭ്യര്‍ത്ഥനകള്‍ മറച്ചുവെച്ച ഡൊമൈനുകൾ - പിന്തുടരുന്നവർ + പിന്തുടരുന്നവർ പുതിയത് ലഭിക്കാൻ താഴേക്ക് വലിക്കുക ലഭ്യമല്ല ചുരുക്കുക diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml similarity index 88% rename from app/src/main/res/values-no-rNB/strings.xml rename to app/src/main/res/values-nb-rNO/strings.xml index 13123f059..a8efb2117 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -10,8 +10,6 @@ Autorisasjon ble nektet. Henting av logintoken feilet. Innlegget er for langt! - Filen må være mindre enn 8MB. - Videofiler må være mindre enn 40MB. Den filtypen kan ikke lastes opp. Den filen kunne ikke åpnes. Trenger tillatelse til å lese media. @@ -39,7 +37,7 @@ Kladder Lisenser \@%s - %s boostet + %s fremhevet Sensitivt innhold Media skjult Klikk for å vise @@ -49,15 +47,15 @@ Kollaps Her er det ingenting. Her er det ingenting. Dra ned for å oppdatere! - %s boostet innlegget ditt + %s fremhevde innlegget ditt %s favoriserte innlegget ditt %s følger deg Rapporter @%s Ytterligere kommentarer\? Hurtigsvar Svar - Boost - Fjern boost + Fremhev + Fjern fremheving Legg til i favoritter Fjern favoritt Mer @@ -67,14 +65,14 @@ Er du sikker på at du vil logge ut fra kontoen %1$s\? Følg Slutt å følge - Blokker + Blokkér Fjern blokkering - Skjul boosts - Vis boosts + Skjul fremhevinger + Vis fremhevinger Rapporter Slett - TOOT - TOOT! + Publiser + Publiser! Prøv igjen Steng Profil @@ -109,8 +107,8 @@ Linker Nevner Stikkord - Åpne toot-forfatter - Vis boosts + Åpne innlegg-forfatter + Vis fremhevinger Vis favoritter Stikkord Nevner @@ -154,7 +152,7 @@ Last ned Trekk tilbake følgeforespørselen\? Slutte å følge denne kontoen\? - Slette dette tootet\? + Slette dette innlegget\? Offentlig: Vis i offentlig tidslinjer Ikke oppført: Ikke vis i offentlige tidslinjer Bare følgere: Vis bare til følgere @@ -168,8 +166,8 @@ Varsle meg når nevnt fulgt - tootene mine blir boostet - tootene mine blir favorisert + innleggene mine blir fremhevet + innleggene mine blir favorisert Utseende Apptema Tidslinjer @@ -180,7 +178,7 @@ Språk Tidslinjefiltrering Faner - Vis boosts + Vis fremhevinger Vis svar Last ned forhåndsvisning av media Proxy @@ -194,8 +192,8 @@ Størrelse på statustekst Nye følgere Varsler om nye følgere - Booster - Varsler når innleggene dine blir boostet + Fremhevinger + Varsler når innleggene dine blir fremhevet Favoritter Varsler når innleggene dine blir favorisert %s nevnte deg @@ -259,9 +257,11 @@ Hjemmeside: \n https://accelf.net/yuito om %dy - Poster med konto %1$s + Poster som konto %1$s Klarte ikke å sette bildetekst + Beskriv for de med nedsatt synsevne +\n(maks %d tegn) Beskriv for de med nedsatt synsevne \n(maks %d tegn) @@ -270,11 +270,11 @@ Lås konto Krever at du manuelt godkjenner nye følgere Lagre kladd\? - Sender toot… - Det oppsto en feil under sending av tootet - Sender toots + Sender innlegg… + Det oppsto en feil under sending av innlegget + Sender innleggene Sending avbrutt - En kopi av tootet er lagret i kladdene dine + En kopi av innlegget er lagret i kladdene dine Skriv Instansen %s har ingen egendefinerte emojis Emoji-stil @@ -282,7 +282,7 @@ Du må laste ned emoji-samlingene før de kan brukes Gjennomfører oppslag… Utvid/kollaps alle statuser - Åpne toot + Åpne innlegg Omstart av applikasjonen er påkrevd Du må starte Yuito på nytt for at endringene skal bli aktive Senere @@ -293,8 +293,8 @@ Nedlasting feilet Robot %1$s har flyttet til: - Boost til opprinnelig publikum - Fjern boost + Fremhev til opprinnelig publikum + Fjern fremheving Yuito inneholder programkode og elementer fra følgende åpen kildekode-prosjekter: Lisensiert under Apache License (kopi under) CC-BY 4.0 @@ -312,10 +312,10 @@ <b>%1$s</b> Favoritter - %s Boost - %s Booster + %s Fremheving + %s Fremhevinger - Boostet av + Fremhevet av Favorisert av %1$s %1$s og %2$s @@ -329,10 +329,10 @@ Ingen beskrivelse Reblogget Favorisert - Offentlig - Ikke listet - Følgere - Direkte + Offentlig + Ikke listet + Følgere + Direkte Listenavn Emneord uten # Fjern @@ -425,10 +425,10 @@ Flere valg Valg %d Endre - Planlagte toots + Planlagte innlegg Rediger - Planlagte toots - Planlegg toot + Planlagte innlegg + Planlegg innlegg Tilbakestill Det oppsto en feil under henting av %s Drevet av Tusky @@ -440,10 +440,9 @@ Liste Du har ingen planlagte innlegg. Du har ikke lagret noen kladder. - Lydfiler må være mindre enn 40MB. Mastodon har et minimums planleggingsinterval på 5 minutter. Vis forhåndsvisning av linker i tidslinjer - Vis bekreftelsesdialog før boosting + Vis bekreftelsesdialog før fremheving Skru på sveiping for å bytte mellom faner %s person @@ -474,12 +473,12 @@ Det er ingen kunngjøringer. Kunngjøringer Skjul kvantitativ informasjon på profiler - Skjul kvantitativ informasjon på toots + Skjul kvantitativ statistikk på innleggene Begrens tidslinjevarsler Se over varsler Informasjon som kan påvirke ditt mentale velvære vil bli skjult. Dette inkluderer: \n -\n - Varsler om favorisering, boosts og følgere +\n - Varsler om favorisering, fremhevinger og følgere \n - Antall favoriseringer og boots på innlegg \n - Antall følgere og innlegg på profiler \n @@ -488,7 +487,7 @@ Varsler når noen jeg følger publiserer et nytt innlegg Nye innlegg noen jeg følger publiserer et nytt innlegg - %s tootet akkurat + %s postet akkurat Du kan ikke laste opp flere enn %1$d mediavedlegg. Du kan ikke laste opp flere enn %1$d mediavedlegg. @@ -498,10 +497,10 @@ Er du sikker på at du vil slette listen %s\? Vedlegg Lyd - Tootet du kladdet et svar til har blitt fjernet + Innlegget du kladdet et svar på har blitt fjernet Kladd slettet Lasting av svarinformasjon feilet - Sending av toot feilet! + Sending av innlegg feilet! Animer egendefinerte emojis Avslutt abonnementet Abonner @@ -516,7 +515,7 @@ 180 dager 365 dager 14 dager - Komponer toot + Skriv innlegg %s registrerte seg noen registrerte seg Registreringer @@ -539,4 +538,21 @@ Rediger bilde Bildet kunne ikke redigeres. Lasting av kontodetaljer feilet + Video- og lydfiler kan ikke være større enn %s MB. + Det oppsto en feil under følging av #%s + Det oppsto en feil når følging av #%s skulle avsluttes + %s (%s) + (Ingen endring) + Innleggspråk + %s (🔗 %s) + Sett fokuspunkt + Klarte ikke å sette et fokuspunkt + Trykk eller dra sirkelen for å velge fokuspunktet som alltid skal være synlig i miniatyrbilder. + Alltid + Når flere konti er logget inn + Aldri + Vis brukernavn på verktøylinjer + Slette dette planlagte innlegget\? + Regler på %s + Ved å logge inn godtar du reglene på %s. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 92c1611a9..46fcd8fd5 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -9,24 +9,22 @@ Er deed zich een onbekende autorisatiefout voor. Autorisatie werd geweigerd. Kon geen inlogsleutel verkrijgen. - Tekst van deze toot is te lang! - Bestand moet kleiner zijn dan 8MB. - Videobestanden moeten kleiner zijn dan 40MB. + Tekst van dit bericht is te lang! Bestandstype kan niet worden geüpload. Bestand kon niet worden geopend. Er is toestemming nodig om deze media te lezen. Er is toestemming nodig om media op te slaan. - Afbeeldingen en video\'s kunnen niet allebei aan dezelfde toot worden toegevoegd. + Afbeeldingen en video\'s kunnen niet allebei aan hetzelfde bericht worden toegevoegd. Uploaden mislukt. - Fout tijdens verzenden toot. + Fout tijdens verzenden bericht. Start Meldingen Lokaal Globaal Directe berichten Tabs - Toot - Toots + Gesprek + Berichten Met reacties Vastgezet Volgend @@ -49,8 +47,8 @@ Inklappen Hier is niets. Niets te zien. Swipe naar beneden om te verversen! - %s boostte jouw toot - %s markeerde jouw toot als favoriet + %s boostte jouw bericht + %s markeerde jouw bericht als favoriet %s volgt jou Rapporteer @%s Extra opmerkingen\? @@ -61,7 +59,7 @@ Als favoriet markeren Favoriet verwijderen Meer - Toot schrijven + Bericht schrijven Aanmelden Afmelden Ben je er zeker van dat je het account %1$s wil afmelden? @@ -73,13 +71,13 @@ Boosts tonen Rapporteren Verwijderen - TOOT - TOOT! + Toot! + Toot! Opnieuw proberen Sluiten Profiel - Voorkeuren - Accountinstellingen + App-voorkeuren + Accountvoorkeuren Favorieten Genegeerde gebruikers Geblokkeerde gebruikers @@ -102,7 +100,7 @@ Afwijzen Zoeken Concepten - Zichtbaarheid toot + Zichtbaarheid bericht Tekstwaarschuwing Emojis Tab toevoegen @@ -120,8 +118,8 @@ Link kopiëren Als %s openen Delen als … - Link van de toot delen - Inhoud van de toot delen met… + Link van het bericht delen met… + Inhoud van het bericht delen met… Media delen met … Verzonden! Gebruiker is gedeblokkeerd @@ -152,7 +150,7 @@ Downloaden Het volgverzoek intrekken? Dit account ontvolgen? - Deze toot verwijderen? + Dit bericht verwijderen\? Openbaar: op openbare tijdlijnen tonen Minder openbaar: niet op openbare tijdlijnen tonen Alleen volgers: alleen aan jouw volgers tonen @@ -166,19 +164,19 @@ Waarschuw mij wanneer ik word vermeld ik word gevolgd - mijn toots werden geboost - mijn toots als favoriet werden gemarkeerd + mijn berichten werden geboost + mijn berichten zijn als favoriet gemarkeerd Uiterlijk Thema Tijdlijnen Donker Licht Zwart - Automatisch tijdens zonsondergang + Automatisch tijdens zonsop- en ondergang Systeemthema gebruiken Webbrowser Aangepaste tabbladen gebruiken - Verberg zwevende tootknop tijdens scrollen + Verberg zwevende knop om een bericht te schrijven tijdens het scrollen Taal Filteren Tijdlijnen @@ -190,14 +188,14 @@ HTTP-proxy inschakelen Serveradres van HTTP-proxy Poort van HTTP-proxy - Standaardzichtbaarheid van jouw toots + Standaardzichtbaarheid van jouw berichten Media altijd als gevoelig markeren Publiceren Synchroniseren Openbaar Minder openbaar Alleen volgers - Tekstgrootte van toots + Tekstgrootte van berichten Kleinst Klein Standaard @@ -208,9 +206,9 @@ Nieuwe volgers Meldingen over nieuwe volgers Boosts - Meldingen wanneer jouw toots worden geboost + Meldingen wanneer jouw berichten worden geboost Favorieten - Meldingen wanneer jouw toots als favoriet worden gemarkeerd + Meldingen wanneer jouw berichten als favoriet worden gemarkeerd %s vermeldde jou %1$s, %2$s, %3$s en %4$d anderen %1$s, %2$s en %3$s @@ -221,8 +219,8 @@ Besloten account Over - Yuito %s - Yuito is opensource- en vrije software. De licentie valt onder de GNU Algemene Publieke Licentie versie 3. Je kunt de licentie hier bekijken: https://www.gnu.org/licenses/gpl-3.0.nl.html + Tusky %s + Tusky is opensource- en vrije software. De licentie valt onder de GNU Algemene Publieke Licentie versie 3. Je kunt de licentie hier bekijken: https://www.gnu.org/licenses/gpl-3.0.nl.html Projectwebsite:\n - https://accelf.net/yuito + https://tusky.app Foutmeldingen & nieuwe functies aanvragen:\n - https://github.com/accelforce/Yuito/issues - Yuito\'s profiel - Deel de inhoud van de toot - Deel de link van de toot + https://github.com/tuskyapp/Tusky/issues + Tusky\'s profiel + Inhoud van bericht delen + Link van het bericht delen Afbeeldingen Video Volgverzoek verzonden @@ -259,31 +257,33 @@ Een nieuw Mastodonaccount toevoegen Lijsten Lijsten - Aan het publiceren met account %1$s + Berichten plaatsen als %1$s Toevoegen van beschrijving mislukt - Omschrijf dit voor mensen met een visuele beperking\n(tekenlimiet is %d) + + Omschrijf dit voor mensen met een visuele beperking +\n(tekenlimiet is %d) Beschrijving toevoegen Verwijderen Account besloten maken Handmatige goedkeuring vereist voor volgers Concept bewaren? - Toot aan het verzenden… - Verzenden van toot mislukt - Toots aan het verzenden + Bericht wordt verzonden… + Verzenden van het bericht is mislukt + Berichten worden verzonden Verzenden geannuleerd - Een kopie van de toot werd opgeslagen als concept - Toot schrijven + Een kopie van het bericht werd als concept opgeslagen + Bericht schrijven Jouw server %s heeft geen lokale emojis Emojistijl Systeemstandaard Je moet eerst deze emoji-sets downloaden Aan het zoeken… - Alle toots in- of uitklappen - Toot openen + Alle berichten in- of uitklappen + Bericht openen Herstarten app vereist - Je moet Yuito herstarten om deze veranderingen te kunnen doorvoeren + Je moet Tusky herstarten om deze veranderingen te kunnen doorvoeren Later Herstarten Standaard emojiset van jouw apparaat @@ -294,7 +294,7 @@ %1$s is verhuisd naar: Boost naar oorspronkelijke ontvangers Niet langer boosten - Yuito bevat broncode en onderdelen van de volgende opensourceprojecten: + Tusky bevat broncode en onderdelen van de volgende opensourceprojecten: Gelicenseerd onder de Apache-licentie (kopie hieronder) CC-BY 4.0 CC-BY-SA 4.0 @@ -315,7 +315,7 @@ <b>%s</b> boosts Geboost door - Aan favorieten toegevoegd door + Als favoriet gemarkeerd door %1$s %1$s en %2$s %1$s, %2$s en %3$d meer @@ -323,23 +323,18 @@ maximum van %1$d tab bereikt maximum van %1$d tabs bereikt - Media: %s + Media: %s + Inhoudswaarschuwing: %s + Geen omschrijving + Geboost + Als favoriet gemarkeerd + Openbaar - Inhoudswaarschuwing: %s + Minder openbaar - Geen omschrijving + Volgers - Geboost - - Aan favorieten toegevoegd - - Openbaar - - Minder openbaar - - Volgers - - Direct + Direct Media downloaden Media aan het downloaden @@ -364,11 +359,11 @@ Naam van lijst Hashtag zonder # Verwijderen en herschrijven - Deze toot verwijderen en herschrijven\? + Dit bericht verwijderen en herschrijven\? Leegmaken Filter Toepassen - Toot schrijven + Bericht schrijven Schrijven Botsindicator tonen Weet je zeker dat je alle meldingen permanent wilt verwijderen\? @@ -418,7 +413,7 @@ Extra opmerkingen Verder naar %s Het rapporteren is mislukt - Het ophalen van toots is mislukt + Het ophalen van berichten is mislukt Deze rapportage wordt naar jouw servermoderator(en) gestuurd. Je kunt hieronder een uitleg geven over waarom je het account wilt rapporteren: Het account is van een andere server. Wil je ook een geanonimiseerde kopie van de rapportage daarnaartoe sturen\? Meldingenfilter tonen @@ -435,27 +430,26 @@ Meerdere keuzes Keuze %d Bewerken - Geluidsbestanden moeten minder dan 40MB zijn. Bladwijzers - Ingeplande toots + Ingeplande berichten Bladwijzer Bewerken Bladwijzers Poll toevoegen - Ingeplande toots - Ingeplande toot + Ingeplande berichten + Ingepland bericht Herstellen - Powered by Tusky - Altijd toots met tekstwaarschuwingen uitklappen + Mogelijk gemaakt door Tusky + Berichten met tekstwaarschuwingen altijd uitklappen Als bladwijzer toegevoegd Kies een lijst Lijst Accounts Zoeken mislukt Poll - Fout tijdens opzoeken toot %s - Je hebt nog geen concepten - Je hebt nog geen ingeplande toots + Fout tijdens het opzoeken van bericht %s + Je hebt nog geen concepten. + Je hebt nog geen ingeplande berichten. Om in te plannen moet je in Mastodon een minimum interval van 5 minuten gebruiken. Volgverzoeken Hashtags @@ -463,8 +457,8 @@ volgverzoek verstuurd Afmelden Abonneren - De toot waarvoor jij een reactie had opgesteld, is verwijderd - Het versturen van deze toot is mislukt! + Het bericht waarvoor jij een reactie had opgesteld, is verwijderd + Het versturen van dit bericht is mislukt! Weet je zeker dat je de lijst %s wilt verwijderen\? Je kan niet meer dan %1$d mediabijlage uploaden. @@ -474,8 +468,8 @@ Opgeslagen! Jouw eigen opmerking over dit account Welzijn - De titel van de bovenste statusbalk verbergen - Vraag voor het boosten van een toot een bevestiging + De bovenste werkbalk verbergen + Vraag voor het boosten van een bericht een bevestiging Linkpreviews in tijdlijnen weergeven Er zijn geen aankondigingen. Oneindig @@ -487,14 +481,14 @@ Hashtag toevoegen Geluid - Meldingen wanneer iemand waar je op bent geabonneerd een nieuwe toot plaatst - Nieuwe toots + Meldingen wanneer iemand waar je op bent geabonneerd een nieuw bericht plaatst + Nieuwe berichten Meldingen over volgverzoeken Onder Boven Lokale emojis animeren Kleurverloop weergeven voor verborgen media - iemand waar ik op ben geabonneerd heeft een nieuwe toot geplaatst + iemand waar ik op ben geabonneerd heeft een nieuw bericht geplaatst Meldingen verbergen \@%s negeren\? \@%s blokkeren\? @@ -504,23 +498,69 @@ Meldingen van %s negeren Meldingen van %s niet meer negeren %s niet meer negeren - %s heeft net een toot geplaatst + %s heeft zojuist een bericht geplaatst %s verzoekt u te volgen Aankondigingen Meldingen beoordelen Concept verwijderd - Kwantitatieve statistieken voor toots verbergen + Kwantitatieve statistieken voor berichten verbergen Laden van reactie-informatie mislukt Kwantitatieve statistieken in profielen verbergen Hoofd navigatiepositie Dit gesprek verwijderen\? Gesprek verwijderen Ook al heb je geen besloten account, de medewerkers van %1$s dachten dat je misschien de volgverzoeken van deze accounts handmatig zou willen controleren. - Bepaalde informatie die invloed kan hebben op uw geestelijk welzijn zal worden verborgen. Dit bevat onder andere: + Bepaalde informatie die invloed kan hebben op jouw geestelijk welzijn zal worden verborgen. Dit bevat onder andere: \n -\n- Favoriet/Boost/Volg notificaties -\n- Favoriet/Boost/Aantal boosts per toot -\n- Volger/Bericht statistieken op profielen +\n- Meldingen over favorieten, boosts en volgers +\n- Weergave van het aantal favorieten en boosts per bericht +\n- Statistieken over het aantal volgers en berichten op profielen \n -\nPush-notificaties zullen niet worden beïnvloed, maar uw kunt uw notificatie voorkeuren handmatig wijzigen. - +\nPushmeldingen worden hierdoor niet beïnvloed, maar je kunt de voorkeuren voor meldingen handmatig wijzigen. + %s heeft zich geregistreerd + Alle accounts opnieuw inloggen i.v.m. ondersteuning pushmeldingen. + Afbeeldingen en video\'s kunnen niet groter zijn dan %s MB. + Deze afbeelding kon niet worden bewerkt. + Inloggen + Opnieuw inloggen i.v.m. pushmeldingen + %s heeft diens bericht bewerkt + Bladwijzer verwijderen + Afwijzen + Details + iemand heeft zich geregistreerd + een bericht waarmee ik interactie had is bewerkt + Registraties + Meldingen over nieuwe gebruikers + Bewerkingen van berichten + Meldingen wanneer berichten waarmee je interactie had werden bewerkt + 1+ + Afbeelding bewerken + 30 dagen + 60 dagen + 14 dagen + 90 dagen + 180 dagen + 365 dagen + Vraag voor het markeren als favoriet een bevestiging + Bericht schrijven + Geregistreerd in %1$s + Concept wordt opgeslagen… + Laden van accountdetails mislukt + De inlogpagina kon niet worden geladen. + Taal van jouw berichten + (geen verandering) + Gebruikersnaam op werkbalken tonen + Tik of sleep de cirkel naar een centraal focuspunt dat op elke thumbnail zichtbaar moet blijven. + Om pushmeldingen via UnifiedPush te kunnen gebruiken, moet Tusky zich op meldingen van jouw Mastodon-server abonneren. Dit betekent dat je opnieuw moet inloggen om de OAuth-toestemmingen voor Tusky te wijzigen. Het hier of onder accountvoorkeuren opnieuw inloggen behoudt jouw lokale concepten en buffer. + Je hebt opnieuw op jouw huidige account ingelogd om toestemming voor pushmeldingen aan Tusky te verlenen. Je hebt echter nog andere accounts die nog niet op deze manier zijn overgezet. Ga naar deze accounts en log één voor één opnieuw in om UnifiedPush-meldingen ook daar in te schakelen. + %s (🔗 %s) + Altijd + Wanneer meerdere accounts zijn ingelogd + Nooit + %s (%s) + Focuspunt instellen + Instellen van focuspunt mislukt + Fout tijdens het volgen van #%s + Fout tijdens het ontvolgen van #%s + Dit ingeplande bericht verwijderen\? + \ No newline at end of file diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 84fc5abee..33406241a 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -9,8 +9,6 @@ L\'autoritzacion es estada regetada. Fracàs de l’obtencion del testimoni d\'iniciacion de session. L\'estatut es tròp long ! - Lo fichièr a d’èsser inferior a 8Mo. - Los fichièrs vidèo devon pas far mai de 40 Mo. Aqueste tip de fichièr se pòt pas mandar. Aqueste tip de fichièr se pòt pas dobrir. Cal permís de lectura del mèdia. @@ -352,10 +350,10 @@ Cap de descripcion Repartajat Mes en favorit - Public - Pas listada - Seguidors - Dirècte + Public + Pas listada + Seguidors + Dirècte Nom de la lista Etiquetas sens # Escriure un tut @@ -449,7 +447,6 @@ Ajustat als marcapaginas Seleccionar la list Lista - Los fichièrs àudio devon èsser inferiors a 40 Mo. Avètz pas cap de borrolhon. Avètz pas cap de tut planificat. L’interval minimum de planificacion sus Mastodon e de 5 minutas. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7b6fb6215..66bef1a0d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -9,7 +9,6 @@ Odmówiono autoryzacji. Nie udało się uzyskać tokenu logowania. Zbyt długi wpis! - Plik może mieć maksymalnie 8 MB. Ten format pliku nie może zostać wysłany. Nie można otworzyć tego pliku. Wymagane jest pozwolenie na dostęp do plików z urządzenia. @@ -229,7 +228,7 @@ Dodaj nowe Konto Mastodon Listy Listy - Publikowanie z konta %1$s + Publikowanie jako %1$s Nie udało się ustawić podpisu Ustaw podpis Usuń @@ -267,7 +266,6 @@ Nazwa Zawartość Wystąpił problem z łącznością! Sprawdź swoje połączenie internetowe i spróbuj ponownie! - Pliki wideo muszą być mniejsze niż 40MB. Wiadomości bezpośrednie Przypięte Rozwiń @@ -376,10 +374,10 @@ Brak opisu Podbity Polubiony - Publiczny - Niewidoczne - Śledzący - Bezpośrednio + Publiczny + Niewidoczne + Śledzący + Bezpośrednio Głosowanie z opcjami: %1$s, %2$s, %3$s, %4$s; %5$s Nazwa listy Hashtag bez # @@ -464,7 +462,6 @@ Dodany do zakładek Wybierz listę Lista - Pliki audio muszą być mniejsze niż 40MB. Nie masz żadnych szkiców. Nie masz żadnych zaplanowanych wpisów. Mastodon umożliwia wysłanie minimalnie 5 minut od zaplanowania. @@ -558,4 +555,22 @@ Edycje wpisów Zapisywanie szkicu… Nie można załadować strony logowania. + Zalogowałeś/-aś się ponownie na swoje konto, aby przyzwolić Tusky na wysyłanie powiadomień push. Masz jednak inne konta które nie zostały zmigrowane. Przełącz się na nie i zaloguj się ponownie aby włączyć wsparcie dla powiadomień UnifiedPush. + 1+ + Dołączył/-a %1$s + Zaloguj się ponownie na wszystkie konta aby włączyć wsparcie dla powiadomień push. + Aby użyć powiadomień push przez UnifiedPush, Tusky wymaga pozwolenia na subskrybowanie powiadomień na twoim serwerze Mastodon. Wymaga to ponownego zalogowania aby zmienić zakresy OAuth przyznane Tusky. Użycie opcji ponownego zalogowania tutaj lub w ustawieniach konta zachowa wszystkie szkice i pamięć podręczną. + Edytuj obraz + Obrazek nie mógł być zmodyfikowany. + Zaloguj się ponownie aby włączyć powiadomienia push + Odrzuć + Detale + Ładowanie informacji o koncie nie powiodło się + Pliki wideo i audio nie mogą przekraczać rozmiarem %s MB. + %s (%s) + Język wpisu + %s (🔗 %s) + (bez zmian) + Wystąpił błąd podczas obserwowania #%s + Wystąpił błąd podczas usuwania obserwacji #%s \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 68edd4896..08e9e2351 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -9,8 +9,6 @@ Autorização negada. Erro ao adquirir token de entrada. O toot é muito longo! - A imagem deve ser menor que 8MB. - O vídeo deve ser menor que 40MB. Esse tipo de arquivo não pode ser enviado. Esse arquivo não pode ser aberto. Permissão para ler mídia é necessária. @@ -287,8 +285,8 @@ Usar tempo absoluto Levou boost de Favoritado por - Público - Privado + Público + Privado Ocorreu um erro de conexão! Por favor, verifique sua internet e tente novamente! Fixado \@%s @@ -356,8 +354,8 @@ Sem descrição Você deu boost Favoritado - Não-listado - Direto + Não-listado + Direto Nome da lista Hashtag sem # Limpar @@ -446,7 +444,6 @@ Selecionar lista Lista Sem toots agendados. - O áudio deve ser menor que 40MB. Sem rascunhos. Mastodon possui um intervalo mínimo de 5 minutos para agendar. Seguidores pendentes diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index e7d565590..544117b30 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -6,6 +6,7 @@ %1$s e %2$s %d nova interação + %d novas interações %d novas interações A responder a @%s @@ -24,9 +25,6 @@ Autorização negada. Erro ao adquirir token de login. O toot é muito extenso! - O ficheiro deve ter menor de 8MB. - Os ficheiros de vídeo devem ter menor de 40MB. - Os ficheiros de áudio devem ter menor de 40MB. Esse tipo de ficheiro não pode ser enviado. Não foi possível abrir esse ficheiro. É necessária permissão para ler o armazenamento. @@ -338,6 +336,8 @@ Descrição para deficientes visuais \n(até %d letra) + Descrição para deficientes visuais +\n(até %d caracteres) Descrição para deficientes visuais \n(até %d caracteres) @@ -382,10 +382,12 @@ Fixar %1$s Favorito + %1$s Favoritos %1$s Favoritos %s Boost + %s Boosts %s Boosts Boost dado por @@ -394,6 +396,7 @@ %1$s, %2$s e %3$d mais atingiu o máximo de %1$d separador + atingiu o máximo de %1$d separadores atingiu o máximo de %1$d separadores Conteúdo multimédia: %s @@ -402,10 +405,10 @@ Replicado Adicionado aos favoritos Guardado - Público - Não-listado - Privado - Direto + Público + Não-listado + Privado + Direto Votação com as opções: %1$s, %2$s, %3$s, %4$s; %5$s Nome da lista Adicionar hashtag @@ -423,10 +426,12 @@ %1$s • %2$s %s voto + %s votos %s votos %s pessoa + %s pessoas %s pessoas termina em %s @@ -436,18 +441,22 @@ A sua votação terminou %d dia restante + %d dias restantes %d dias restantes %d hora restante + %d horas restantes %d horas restantes %d minuto restante + %d minutos restantes %d minutos restantes %d segundo restante + %d segundos restantes %d segundos restantes Continuar @@ -508,6 +517,7 @@ Esconder estatísticas quantitativas nos perfis Não é possível enviar mais de %1$d arquivo de conteúdo multimédia. + Não é possível enviar mais de %1$d arquivos de conteúdo multimédia. Não é possível enviar mais de %1$d arquivos de conteúdo multimédia. Erro ao enviar o toot! @@ -528,5 +538,32 @@ Desfazer Aceitar Rejeitar - Não foi possível carregar a página de login + Não foi possível carregar a página de autenticação. + Erro ao carregar os detalhes da conta + Faz novamente login em todas as contas para ativar as notificações push. + Criada em %1$s + Para ativar as notificações push através de UnifiedPush, o Tusky necessita de permissão para subscrever as notificações da tua instância Mastodon. Isto obriga a fazer login novamente, por forma a alterar o escopo das permissões fornecidas ao Tusky pelo OAuth. Usar a opção de novo login, aqui ou nas Configurações da Conta, preservará todos os teus rascunhos e cache locais. + 1+ + Fizeste novo login na tua conta para dar permissão para a subscrição das notificações push no Tusky. Contudo, ainda tens outras contas sem esta permissão. Podes atribuir essa permissão fazendo novo login em cada uma delas e ativar o suporte para UnifiedPush. + Editar imagem + Não foi possível editar a imagem. + A guardar rascunho… + Faz novamente login para as notificações push + Descartar + Detalhes + Apagar esta publicação agendada\? + Toca ou arrasta o círculo para escolher o ponto de focagem que estará sempre visível nas pré-visualizações. + %s(%s) + (Sem alteração) + %s (🔗 %s) + Sempre + Quando autenticado em várias contas + Nunca + Idioma da publicação + Mostrar o nome de utilizador nas barras de ferramentas + Os ficheiros de áudio e vídeo não podem exceder os %s MB. + Erro ao definir ponto de focagem + Define o ponto de focagem + Erro ao seguir #%s + Erro ao deixar de seguir #%s \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b35eb2c2d..56985099e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -10,8 +10,6 @@ Авторизация была отклонена. Не удалось получить токен авторизации. Статус слишком длинный! - Файл должен быть не больше 8 Мбайт. - Видео должно быть не больше 40 Мбайт. Данный тип файла не может быть загружен. Файл не может быть открыт. Необходимо разрешение на чтение медиаконтента. @@ -385,12 +383,12 @@ Реблогнуто Понравилось - + Публичный - Неизвестно - Подписчики - Непосредственно + Неизвестно + Подписчики + Непосредственно Название списка Хэштег без # Очистить @@ -466,7 +464,6 @@ Добавлено в закладки Выбрать список Список - Аудиофайлы должны быть меньше 40МБ. Ошибка поиска поста %s У вас нет черновиков. У вас нет запланированный статусов. diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index e304b1657..7382d82fa 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -11,9 +11,6 @@ श्रव्यदृश्यसामग्र्यः द्रष्टुमनुमतिर्दातव्या । सा सञ्चिका नोद्घाट्यते । नैतादृशा सञ्चिका उपारोपणीया । - श्रव्यसञ्चिका ४०MBतोऽल्पा स्थाप्या । - चलचित्रसञ्चिका ४०MBतोऽल्पा स्थाप्या । - ८ MBतोऽल्पा परिमिता सञ्चिका स्थाप्या । सम्प्रवेशस्तोकं न लब्धः । प्रमाणीकरणं निषिद्धम् । अज्ञातः प्रमाणीकरणदोषो जातः । @@ -441,10 +438,10 @@ प्रचलितं युज्यताम् सूचिनाम मतदाने मतानि- %1$s, %2$s, %3$s, %4$s; %5$s - प्रत्यक्षम् - अनुसर्तारः - अनिर्दिष्टम् - सार्वजनिकम् + प्रत्यक्षम् + अनुसर्तारः + अनिर्दिष्टम् + सार्वजनिकम् पुटचिह्नं कृतम् प्रीतिर्दत्ता पुनर्लिखितम् diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 7ba704981..14086536c 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -97,7 +97,7 @@ අතිරේක අදහස් ප්‍රියතමයන් ඔබ මේ ඉමෝජි කට්ටල පළමුව බාගත යුතුයි - සෘජු + සෘජු සොයන්න… යෙදුමේ තේමාව පොත්යොමු @@ -130,7 +130,7 @@ ටූට්ස් යැවෙමින් සංවාද විශාල - ප්‍රසිද්ධ + ප්‍රසිද්ධ නව මාස්ටඩන් ගිණුමක් එක්කරන්න මාධ්‍ය උඩුගත වීම අහවර වෙමින් @@ -268,4 +268,5 @@ සබැඳි අතිරික්සුව තත්. %d + %s (🔗 %s) \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index e06555c71..be52a240b 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -34,9 +34,6 @@ Toto nemôže byť prázdne. Autorizácia bola zamietnutá. Nepodarilo sa získať prihlasovací token. - Súbor musí byť menší ako 8 MB. - Videosúbory musia byť menšie ako 40 MB. - Audio súbory musia byť menšie ako 40 MB. Súbor sa nepodarilo otvoriť. Domov Panely @@ -115,10 +112,10 @@ %1$s %1$s a %2$s Žiadny popis - Verejný + Verejný Podporiť Prestať podporovať - Sledujúci + Sledujúci Verejný Záložky %1$s a %2$s diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index a068f8a63..932b7f1b3 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -8,8 +8,6 @@ Pooblastitev je bila zavrnjena. Ni bilo mogoče pridobiti žetona za prijavo. Status je predolgo! - Datoteka mora biti manjša od 8 MB. - Video datoteke morajo biti manjše od 40 MB. Te vrste datoteke ni mogoče poslati. Te datoteke ni bilo mogoče odpreti. Potrebno je dovoljenje za branje medijev. @@ -324,10 +322,10 @@ Brez opisa Ponovno objavljen Priljubljene - Javno - Ni prikazano - Sledilci - Neposredno + Javno + Ni prikazano + Sledilci + Neposredno Ime seznama Ključnik brez # Počisti diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 461aa12c0..9e27c6837 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -10,8 +10,6 @@ Ingen behörighet. Misslyckades med att få en inloggnings-token. Statusen är för lång! - Filen måste vara mindre än 8MB. - Videofiler måste vara mindre än 40MB. Den typen av fil kan inte laddas upp. Den filen kunde inte öppnas. Behörighet att läsa media krävs. @@ -351,13 +349,13 @@ Favoriserad - Publik + Publik - Olistad + Olistad - Följare + Följare - Direkt + Direkt Listnamn Ladda ned media @@ -454,7 +452,6 @@ Välj lista Lista Du har inga schemalagda statusar. - Ljudfiler måste vara mindre än 40MB. Du har inga utkast. Mastodon har ett minimalt schemaläggningsintervall på 5 minuter. Tysta konversation diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index faebf8f71..face1c5b6 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -9,7 +9,6 @@ அங்கீகாரம் மறுக்கப்பட்டுள்ளது உள்நுழைவு டோக்கனைப் பெறுவதில் தோல்வி. நிலை மிக நீளமாக உள்ளது! - கோப்பு 4MB-க்கும் குறைவாக இருக்க வேண்டும். இந்த வகை கோப்பை பதிவேற்ற முடியாது. அந்த கோப்பை திறக்க முடியவில்லை. ஊடகத்தை படிக்க அனுமதி தேவை. @@ -260,7 +259,6 @@ பொருத்து கணக்கரின் முன்னுரிமைகள் பிணைய பிழை ஏற்பட்டது! உங்கள் இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சிக்கவும்! - காணொளி 40MB க்கும் குறைவாக இருக்க வேண்டும். டூத் அனுப்புவதில் பிழை ஏற்பட்டுள்ளது நேரடி தகவல் பட்டைகள் @@ -275,8 +273,8 @@ திருத்த திருத்த %1$s மற்றும் %2$s - பின்பற்றுபவர்கள் - பட்டியலிடப்படாதவர்களுக்கு - அனைவருக்கும் + பின்பற்றுபவர்கள் + பட்டியலிடப்படாதவர்களுக்கு + அனைவருக்கும் எழுது diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index e094542c9..f587d2799 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -61,10 +61,10 @@ เพิ่มแฮชแท็ก ชื่อรายการ โพลกับตัวเลือก: %1$s, %2$s, %3$s, %4$s; %5$s - ไดเร็กต์ - ผู้ติดตาม - ไม่อยู่ในรายการ - สาธารณะ + ไดเร็กต์ + ผู้ติดตาม + ไม่อยู่ในรายการ + สาธารณะ คั่นหน้า ชื่นชอบ ได้ถูกเขียนใหม่ @@ -80,7 +80,7 @@ ชื่นชอบโดย บูสต์โดย - <b>%s</b> บูสต์ + <b>%s</b> บูสต์ <b>%1$s</b> ชื่นชอบ @@ -414,9 +414,6 @@ ต้องมีสิทธิ์อ่านสื่อ ไม่สามารถเปิดไฟล์ได้ ไม่สามารถอัปโหลดไฟล์ประเภทนี้ได้ - ไฟล์เสียงต้องมีขนาดน้อยกว่า 40MB - ไฟล์วิดีโอต้องมีขนาดน้อยกว่า 40MB - ไฟล์ต้องมีขนาดน้อยกว่า 8MB ข้อความสถานะยาวเกินไป! ไม่สามารถรับโทเค็นการเข้าสู่ระบบ การขออนุญาตสิทธิถูกปฏิเสธ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index f1a961dce..92c73237a 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -10,8 +10,6 @@ Yetkilendirme reddedildi. Giriş belirteci alınırken hata oluştu. Durum çok uzun! - Dosya 8 MB\'dan küçük olmalı. - Video dosyaları 40 MB’dan küçük olmalı. Bu tür bir dosya yüklenemez. Dosya açılamadı. Medya okuma izni gerekli. @@ -349,10 +347,10 @@ Açıklama yok Yeniden blogladı Favorilendi - Herkese açık - Liste dışı - Takipçiler - Direkt + Herkese açık + Liste dışı + Takipçiler + Direkt Seçenekli anket: %1$s, %2$s, %3$s, %4$s; %5$s Liste adı # olmadan hashtag @@ -465,7 +463,6 @@ %s kullanıcısından gelen bildirimleri göster %s sesini aç %s seni takip etmek istiyor - Ses dosyaları 40 MB\'dan küçük olmalı. Sohbetin sesini aç takip istendi Sohbeti sessize al diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1a9709441..6609963e2 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -21,9 +21,6 @@ Потрібен дозвіл на читання медіа. Не вдається відкрити цей файл. Неможливо відвантажити файл цього типу. - Аудіофайли повинні бути менше 40 МБ. - Відео повинне бути менше 40 МБ. - Файл повинен бути менше 8 МБ. Допис задовгий! Не вдалося знайти браузер, який можна використати. Не може бути порожнім. @@ -45,7 +42,7 @@ Пошук… Про себе Що відбувається\? - Надіслати! + Надіслано! Надіслано! Поділитися як … Відкрити як %s @@ -57,7 +54,7 @@ Згадки Посилання Попередження про вміст - Заплановані дмухи + Заплановані дописи Чернетки Відхилити Прийняти @@ -100,7 +97,7 @@ %s надсилає запит на підписку %s підписується на вас Тут нічого немає. Потягніть вниз, щоб оновити! - Тут порожньо. + Нічого немає. Згорнути Розгорнути Натисніть для перегляду @@ -110,8 +107,8 @@ Написати Скасувати приглушення розмови Заглушити розмову - Заплановані дмухи - Підписники + Заплановані дописи + Підписники Написати Медіа Сповіщення @@ -159,7 +156,7 @@ \n - Статистика підписників/Публікацій у профілях \n \n На push-сповіщення це не вплине, але ви можете переглянути налаштування сповіщень вручну. - Вподобано + Уподобано Вподобали %1$s вподобання @@ -186,8 +183,14 @@ Вимагає затвердження підписників власноруч Додати підпис - Опис для людей з порушеннями зору - \n(до %d символів) + Опис для людей з вадами зору +\n(обмеження %d символ) + Опис для людей з вадами зору +\n(обмеження %d символи) + Опис для людей з вадами зору +\n(обмеження %d символів) + Опис для людей з вадами зору +\n(обмеження %d символів) Не вдалося додати підпис Відписатися @@ -244,9 +247,9 @@ завершується о %s %s особа - %s особи - %s осіб - %s осіб + %s людини + %s людей + %s людей %s голос @@ -267,14 +270,14 @@ Пізніше Вам потрібно буде перезапустити Tusky, щоб застосувати ці зміни Необхідно перезапустити застосунок - Відкрити дмух - Розгорнути/згорнути всі статуси - Копію дмуху збережено до ваших чернеток + Відкрити допис + Розгорнути/згорнути всі дописи + Копію допису збережено до ваших чернеток Надсилання скасовано - Надсилання дмухів - Помилка надсилання дмуху - Надсилання дмуху… - Оприлюднення з облікового запису %1$s + Надсилання дописів + Помилка надсилання допису + Надсилання допису… + Публікування як %1$s Вилучити обліковий запис зі списку Додати обліковий запис до списку Пошук серед тих, на кого ви підписані @@ -358,11 +361,11 @@ Середній Маленький Найменший - Розмір шрифту статусу + Розмір шрифту допису Лише для підписників - Приховано + Приховано Приховано - Публічно + Публічно Публічно Внизу Вгорі @@ -416,8 +419,8 @@ Заблокувати @%s\? Сховати весь домен Ви впевнені, що хочете заблокувати все з %s\? Ви не побачите вміст із цього домену в жодних загальнодоступних стрічках або у своїх сповіщеннях. Ваших підписників з цього домену буде видалено. - Видалити й переписати цей дмух\? - Видалити цей дмух\? + Видалити й переписати цей допис\? + Видалити цей допис\? Не стежити за цим обліковим записом\? Відкликати запит на підписку\? Завантаження @@ -436,7 +439,7 @@ Аватар Відповісти… Показуване ім\'я - Відповідь успішно надіслано. + Відповідь надіслано. %s показано Глушіння користувача прибрано Користувача розблоковано @@ -451,12 +454,12 @@ Хештеги Відкрити автора просування Додати вкладку - Запланувати дмух + Запланувати допис Клавіотура емодзі - Дмух, для якого ви створили чернетку відповіді, вилучено + Допис, для якого ви створили чернетку відповіді, вилучено Чернетку видалено Не вдалося завантажити дані відповіді - Не вдалося надіслати цей дмух! + Не вдалося надіслати цей допис! Ви дійсно хочете видалити список %s\? Ви не можете завантажити більше ніж %1$d медіавкладення. @@ -475,7 +478,7 @@ Показувати попередній перегляд посилань у стрічках Найкоротший час планування Mastodon становить 5 хвилин. Оголошень немає. - Черга статусів порожня. + Немає запланованих дописів. У вас немає чернеток. Помилка пошуку допису %s Увімкнути перемикання між вкладками жестом проведення пальцем @@ -483,7 +486,7 @@ Не вдалося здійснити пошук Обліковий запис з іншого сервера. Надіслати анонімізовану копію звіту й туди\? Скаргу буде надіслано вашому модератору сервера. Ви можете надати пояснення, чому ви повідомляєте про цей обліковий запис знизу: - Не вдалося отримати статуси + Не вдалося отримати дописи Переслати до %s Дії для зображення %s Ви впевнені, що хочете остаточно очистити всі сповіщення\? @@ -497,11 +500,11 @@ Додати хештег Назва списку Опитування з варіантами: %1$s, %2$s, %3$s, %4$s; %5$s - Безпосередньо + Безпосередньо Додано до закладок Просунуто - досягнено обмеження %1$d вкладка + досягнено обмеження %1$d вкладку досягнено обмеження %1$d вкладки досягнено обмеження %1$d вкладок досягнено обмеження %1$d вкладок @@ -561,4 +564,21 @@ 1+ Неможливо редагувати зображення. Не вдалося завантажити подробиці облікового запису + Помилка підписки на #%s + Розмір відео та аудіофайлів не може перевищувати %s Мб. + Помилка скасування підписки на #%s + %s (%s) + Мова допису + (Не змінено) + %s (🔗 %s) + Не вдалося налаштувати точку фокусування + Налаштувати точку фокусування + Завжди + Торкніться або перетягніть коло, щоб вибрати точку фокусування, яку завжди буде видно на мініатюрах. + Якщо ви ввійшли у кілька облікових записів + Ніколи + Показувати ім\'я користувача на панелях інструментів + Видалити цей запланований допис\? + Увійшовши, ви погоджуєтесь з правилами %s. + Правила %s \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 7eb9ba336..03bffd856 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -12,14 +12,14 @@ %1$s, %2$s, và %3$s %1$s, %2$s, %3$s và %4$d người khác %s nhắc tới bạn - Lượt nhắc mới + Nhắc đến tôi %s yêu cầu theo dõi bạn %s theo dõi bạn %s thích tút của bạn %s đăng lại tút của bạn Lỗi khi tìm tút %s Máy chủ %s không có emoji tùy chỉnh - Lỗi khi đăng tút + Lỗi đăng tút Thêm nội dung thất bại Không thể xóa danh sách Không thể đổi tên danh sách @@ -31,15 +31,12 @@ Cần có quyền đọc tập tin. Không thể mở tập tin. Không hỗ trợ định dạng này. - Kích cỡ audio tối đa 40MB. - Kích cỡ video tối đa 40MB. - Kích cỡ hình ảnh tối đa là 8MB. Vượt quá số ký tự cho phép! Lấy token đăng nhập thất bại. Truy cập bị từ chối. Xảy ra lỗi khi cố gắng truy cập. Không tìm thấy trình duyệt web. - Tài khoản không hợp lệ + Tên miền không hợp lệ Không được để trống. Rớt mạng! Xin kiểm tra kết nối và thử lại! Đã có lỗi xảy ra. @@ -81,16 +78,16 @@ Trả lời… Không tìm thấy Tìm kiếm… - Tiểu sử - Tên hiển thị + Giới thiệu + Biệt danh Nội dung bạn muốn ẩn Bạn đang nghĩ về điều gì\? Bạn ở máy chủ nào\? Đã gửi trả lời tút. Đã đăng! Đã bỏ ẩn %s - Đã bỏ ẩn người dùng - Đã bỏ chặn người dùng + Đã bỏ ẩn người này + Đã bỏ chặn người này Đã gửi! Chia sẻ tập tin với… Đăng lại URL tút với… @@ -131,7 +128,7 @@ Ẩn %s Bỏ ẩn Ẩn - Đăng lại + Chia sẻ Chụp hình Tạo bình chọn Thêm tệp @@ -139,8 +136,8 @@ Media Yêu cầu theo dõi Máy chủ đã ẩn - Người dùng đã chặn - Người dùng đã ẩn + Những người đã chặn + Những người đã ẩn Lưu Thích Trang hồ sơ @@ -184,8 +181,8 @@ Chỉnh sửa hồ sơ Yêu cầu theo dõi Máy chủ đã ẩn - Người dùng đã chặn - Người dùng đã ẩn + Những người đã chặn + Những người đã ẩn Những tút đã lưu Người theo dõi Theo dõi @@ -194,15 +191,15 @@ Tút Tút Xếp tab - Tin nhắn - Thế giới + Nhắn riêng + Liên hợp Máy chủ Thông báo Bảng tin Những tút nháp Những tút đã thích Máy chủ là gì\? - Tải xem trước hình ảnh + Hiện xem trước hình ảnh Hiện những trả lời Hiện lượt đăng lại Tabs @@ -211,7 +208,7 @@ Ảnh đại diện GIF Icon cho tài khoản Bot Ngôn ngữ - Ẩn nút viết tút tự động + Ẩn nút soạn tút Mở luôn trong app Trình duyệt Mặc định của thiết bị @@ -236,9 +233,9 @@ Báo động Thông báo Thông báo - Nhắn riêng: Chỉ người được nhắc đến thấy - Riêng tư: Chỉ người theo dõi - Hạn chế: Không hiện trên bảng tin + Nhắn riêng: Chỉ người được nhắc đến + Chỉ người theo dõi + Hạn chế: Ẩn trên bảng tin Công khai: Mọi người đều thấy Ẩn @%s\? Chặn @%s\? @@ -254,7 +251,7 @@ Nhỏ vừa Nhỏ Cỡ chữ - Riêng tư + Chỉ người theo dõi Hạn chế Công khai Dưới màn hình @@ -288,12 +285,12 @@ Sửa bộ lọc Thêm bộ lọc Chủ đề - Thế giới - tiếp tục đọc + Liên hợp + tải tút chưa đọc Trả lời @%s Media - Luôn hiện nội dung bị ẩn - Luôn hiện nội dung nhạy cảm + Hiện nội dung ẩn + Hiện nội dung nhạy cảm Đang theo dõi bạn %ds %d phút @@ -309,17 +306,17 @@ Video Hình ảnh URL tút - Nội dung của tút + Nội dung tút Trang hồ sơ Tusky Xác nhận trước khi đăng lại - Hiện xem trước của link + Hiện xem trước link Mastodon giới hạn tối thiểu 5 phút. Bạn không có tút đã lên lịch. Bạn không có tút nháp. Sửa Lựa chọn %d Cho phép chọn nhiều lựa chọn - Thêm lựa chọn + Thêm 7 ngày 3 ngày 1 ngày @@ -332,7 +329,7 @@ Hiện bộ lọc thông báo Không thể tìm thấy Người - Tài khoản này thuộc máy chủ khác. Gửi luôn cho máy chủ đó\? + Người này thuộc máy chủ khác. Gửi luôn cho máy chủ đó\? Báo cáo này sẽ được gửi tới kiểm duyệt viên. Hãy cho biết lý do vì sao bạn báo cáo người này bên dưới: Chưa tải được tút Báo cáo thất bại @@ -376,10 +373,10 @@ Thêm hashtag Tên danh sách Lượt bình chọn: %1$s, %2$s, %3$s, %4$s; %5$s - Nhắn riêng - Người theo dõi - Hạn chế - Công khai + Nhắn riêng + Người theo dõi + Hạn chế + Công khai Đã lưu Đã thích Đã đăng lại @@ -438,12 +435,12 @@ Mô tả Mô tả dành cho người khiếm thị -\n(giới hạn %d chữ) +\n(Max %d ký tự) Đăng bằng tài khoản %1$s - Thêm tài khoản vào danh sách - Xóa tài khoản khỏi danh sách - Tìm người dùng + Thêm người này vào danh sách + Xóa người này khỏi danh sách + Tìm người để theo dõi Sửa danh sách Xóa danh sách Đổi tên danh sách @@ -491,10 +488,10 @@ Đã xóa tút lên lịch Chưa tải được bình luận Đăng tút không thành công! - Emoji động + Emoji GIF Ngưng nhận thông báo Nhận thông báo - Dù biết tài khoản của bạn công khai, quản trị viên %1$s vẫn nghĩ bạn hãy nên duyệt thủ công yêu cầu theo dõi từ những tài khoản lạ. + Dù biết tài khoản của bạn công khai, quản trị viên %1$s vẫn nghĩ bạn hãy nên duyệt thủ công yêu cầu theo dõi từ những người lạ. Xóa cuộc thảo luận này\? Xóa thảo luận Xác nhận trước khi thích @@ -506,10 +503,10 @@ 180 ngày 365 ngày Viết tút - ai đó đăng ký trên máy chủ - %s đăng ký - Đăng ký - Thông báo về người dùng mới đăng ký + ai đó mới tham gia máy chủ + %s tham gia máy chủ + Người mới tham gia máy chủ + Thông báo về người mới tham gia máy chủ %s đã sửa tút của họ khi một tút mà tôi tương tác bị sửa Sửa tút @@ -528,4 +525,21 @@ Sửa ảnh Hình ảnh này không thể sửa. Không thể tải thông tin tài khoản + Video và audio không thể quá %s MB. + Lỗi khi theo dõi #%s + Lỗi khi bỏ theo dõi #%s + %s (%s) + (Không đổi) + Ngôn ngữ đăng + %s (🔗 %s) + Không thể chọn tâm điểm + Chọn tâm điểm + Nhấn hoặc kéo vòng tròn để chọn tiêu điểm sẽ hiển thị trong hình thu nhỏ. + Hiện tên người dùng trên thanh công cụ + Luôn luôn + Khi đăng nhập nhiều tài khoản + Không bao giờ + Bạn có chắc muốn xóa tút đã lên lịch\? + Đăng nhập nghĩa là bạn đồng ý với quy tắc của %s. + %s quy tắc \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 38c0a30d1..8b6b30055 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -10,8 +10,6 @@ 授权被拒绝。 未能获取登录令牌。 嘟文太长了! - 文件大小限制为 8MB。 - 视频文件大小限制为 40MB。 无法上传此类型的文件。 打不开此文件。 需要授予 Yuito 读取媒体文件的权限。 @@ -287,11 +285,11 @@ 搜索已关注的用户 添加用户到列表 从列表中移除用户 - 以 %1$s 发布嘟文 + 以 %1$s 身份发布嘟文 设置图片标题失败 为视觉障碍用户提供的描述 -\n(限制 %d 字) +\n(限制 %d 字符) 设置图片标题 移除 @@ -355,14 +353,14 @@ 没有描述信息 被转嘟 被喜欢 - + 公开 - + 不公开 - 仅关注者 - + 仅关注者 + 私信 列表名 @@ -397,7 +395,6 @@ 剩余 %d 秒 重置 - 音频文件大小限制为 40M。 书签 隐藏的域名 定时嘟文 @@ -547,4 +544,21 @@ 编辑图片 无法编辑图片。 加载账户详情失败 + 音视频文件大小不能超出 %s MB。 + 关注 #%s 出错 + 取关 #%s 出错 + %s (%s) + (无更改) + 帖子语言 + %s (🔗 %s) + 设置焦点失败 + 设置焦点 + 轻按或拖动圆圈选择始终在缩略图中可见的焦点。 + 登录多个账户时 + 在工具栏中显示用户名 + 始终 + 从不 + 删除这条定时嘟文吗? + 登录即表示您同意 %s 的规定。 + %s 的规定 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 01226dd7a..23eb891fc 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -10,8 +10,6 @@ 授權被拒絕。 無法獲取登入資訊。 嘟文太長了! - 檔案大小限制 8MB。 - 影片大小限制 40MB。 無法上傳此類型的檔案。 此檔案無法開啟。 需要授予 Yuito 讀取媒體檔案的權限。 @@ -363,16 +361,16 @@ 被收藏 - + 公開 - + 不公開 - + 僅關注者 - + 私信 列表名 @@ -444,7 +442,6 @@ 公告 已排程的嘟文 被隱藏的網域 - 聲音檔大小限制 40MB。 完整字詞 你的草稿欲回覆的原嘟文已被刪除 草稿已刪除 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index e53d9b1bf..2829b66c5 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -10,8 +10,6 @@ 授權被拒絕 無法獲取登入資訊 嘟文太長了! - 檔案大小限制 8MB - 影片大小限制 40MB 無法上傳此類型的檔案 此檔案無法開啟 需要授予 Yuito 讀取媒體檔案的權限 @@ -357,14 +355,14 @@ 被收藏 - + 公開 - + 不公開 - 關注者 - + 關注者 + 私信 列表名 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index 9ffa0c971..0e173e006 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -10,8 +10,6 @@ 授权被拒绝 无法获取登录信息 嘟文太长了! - 文件大小限制 8MB - 视频文件大小限制 40MB 无法上传此类型的文件 此文件无法打开 需要授予 Yuito 读取媒体文件的权限 @@ -359,14 +357,14 @@ 被转嘟 被喜欢 - + 公开 - + 不公开 - 关注者 - + 关注者 + 私信 列表名 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1a644f5de..2350c6e8a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -10,8 +10,6 @@ 授權被拒絕。 無法獲取登入資訊。 嘟文太長了! - 檔案大小限制 8MB。 - 影片大小限制 40MB。 無法上傳此類型的檔案。 此檔案無法開啟。 需要授予 Yuito 讀取媒體檔案的權限。 @@ -361,16 +359,16 @@ 被轉嘟 被最愛 - + 公開 - + 不公開 - + 僅關注者 - + 私信 列表名 @@ -435,7 +433,6 @@ 編輯 編輯 書籤 - 音檔必需小於40MB。 隱藏個人頁面中的狀態數量資訊 隱藏貼文上的狀態數量資訊 限制時間軸通知 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 1dc7d8cd8..18f592dd7 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -42,4 +42,7 @@ #CAD4E0 #d9e1e8 + + #09497b + #39acff diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5ec69307a..ea2e744b6 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -53,4 +53,9 @@ 16dp 36dp + + 3dp + + 16dp + diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 1dffff614..f3155afdf 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -103,7 +103,7 @@ it hu nl - no-nb + nb-no oc pl pt-BR @@ -144,6 +144,12 @@ bottom + + always + disambiguate + never + + %1$s; %2$s; %3$s, %14$s %4$s, %5$s; %6$s, %7$s, %8$s, %9$s, %10$s; %11$s, %12$s, %13$s @@ -181,6 +187,8 @@ 31536000 + <b>%1$d%%</b> + @string/duration_indefinite @string/duration_5_min @@ -203,5 +211,25 @@ 604800 - <b>%1$d%%</b> + + @string/duration_indefinite + @string/duration_5_min + @string/duration_30_min + @string/duration_1_hour + @string/duration_6_hours + @string/duration_1_day + @string/duration_3_days + @string/duration_7_days + + + + 0 + 300 + 1800 + 3600 + 21600 + 86400 + 259200 + 604800 + diff --git a/app/src/main/res/values/string-arrays.xml b/app/src/main/res/values/string-arrays.xml index c97fbb3d7..2a2bd224c 100644 --- a/app/src/main/res/values/string-arrays.xml +++ b/app/src/main/res/values/string-arrays.xml @@ -23,4 +23,10 @@ @string/post_text_size_largest + + @string/pref_show_self_username_always + @string/pref_show_self_username_disambiguate + @string/pref_show_self_username_never + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad26b7896..b7f7e0257 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,9 +12,8 @@ Failed loading account details Could not load the login page. The post is too long! - The file must be less than 8MB. - Video files must be less than 40MB. - Audio files must be less than 40MB. + Video and audio files cannot exceed %s MB in size. + The image could not be edited. That type of file cannot be uploaded. That file could not be opened. @@ -23,6 +22,8 @@ Images and videos cannot both be attached to the same post. The upload failed. Error sending post. + Error following #%s + Error unfollowing #%s Collecting data… Failed. @@ -318,6 +319,10 @@ Large Largest + Always + When multiple accounts logged in + Never + New Mentions Notifications about new mentions New Followers @@ -413,6 +418,7 @@ Whole word When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word Phrase to filter + %s (%s) Add Account Add new Mastodon Account @@ -430,18 +436,22 @@ Add account to the list Remove account from the list - Posting with account %1$s + Posting as %1$s Failed to set caption + Failed to set focus point Describe for visually impaired\n(%d character limit) + Tap or drag the circle to choose the focal point which will always be visible in thumbnails. Set caption + Set focus point Edit image Remove Lock account Requires you to manually approve followers Save draft? + Save draft? (Attachments will be uploaded again when you restore the draft.) Sending post… Error sending post Sending Posts @@ -490,6 +500,8 @@ Unpin Pin + Failed to Pin + Failed to Unpin <b>%1$s</b> Favorite @@ -530,21 +542,22 @@ Bookmarked - + Public - + Unlisted - + Followers - + Direct Poll with choices: %1$s, %2$s, %3$s, %4$s; %5$s + Post language List name @@ -633,6 +646,7 @@ 90 days 180 days 365 days + (No change) Add choice Multiple choices Choice %d @@ -643,6 +657,7 @@ You don\'t have any scheduled posts. There are no announcements. Mastodon has a minimum scheduling interval of 5 minutes. + Show username in toolbars Show link previews in timelines Show confirmation dialog before boosting Show confirmation dialog before favoriting @@ -686,6 +701,11 @@ Re-login all accounts to enable push notification support. In order to use push notifications via UnifiedPush, Tusky needs permission to subscribe to notifications on your Mastodon server. This requires a re-login to change the OAuth scopes granted to Tusky. Using the re-login option here or in Account Preferences will preserve all of your local drafts and cache. You have re-logged into your current account to grant push subscription permission to Tusky. However, you still have other accounts that have not been migrated this way. Switch to them and re-login one by one in order to enable UnifiedPush notifications support. + %s (🔗 %s) + Delete this scheduled post? + + By logging in you agree to the rules of %s. + %s rules diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 74796f377..9465c910e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -89,7 +89,8 @@ class BottomSheetActivityTest { muted = false, poll = null, card = null, - quote = null + language = null, + quote = null, ) private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList())) diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index ef863560d..cc946194a 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -30,7 +30,6 @@ import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceDao import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration @@ -43,13 +42,13 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem -import java.util.Date -import kotlin.collections.HashMap +import java.util.Locale /** * Created by charlag on 3/7/18. @@ -110,7 +109,7 @@ class ComposeActivityTest { val instanceDaoMock: InstanceDao = mock { onBlocking { getInstanceInfo(any()) } doReturn - InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null) + InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null) onBlocking { getEmojiInfo(any()) } doReturn EmojisEntity(instanceDomain, emptyList()) } @@ -134,7 +133,7 @@ class ComposeActivityTest { } val viewModelFactoryMock: ViewModelFactory = mock { - on { create(ComposeViewModel::class.java) } doReturn viewModel + on { create(eq(ComposeViewModel::class.java), any()) } doReturn viewModel } activity.accountManager = accountManagerMock @@ -446,13 +445,26 @@ class ComposeActivityTest { assertEquals(selectionEnd + insertText.length, editor.selectionEnd) } + @Test + fun whenNoLanguageIsGiven_defaultLanguageIsSelected() { + assertEquals(Locale.getDefault().language, activity.selectedLanguage) + } + + @Test + fun languageGivenInComposeOptionsIsRespected() { + val language = "no" + composeOptions = ComposeActivity.ComposeOptions(language = language) + setupActivity() + assertEquals(language, activity.selectedLanguage) + } + private fun clickUp() { val menuItem = RoboMenuItem(android.R.id.home) activity.onOptionsItemSelected(menuItem) } private fun clickBack() { - activity.onBackPressed() + activity.onBackPressedDispatcher.onBackPressed() } private fun insertSomeTextInContent(text: String? = null) { @@ -461,38 +473,16 @@ class ComposeActivityTest { private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance { return Instance( - "https://example.token", - "Example dot Token", - "Example instance for testing", - "admin@example.token", - "2.6.3", - HashMap(), - null, - null, - listOf("en"), - Account( - id = "1", - localUsername = "admin", - username = "admin", - displayName = "admin", - createdAt = Date(), - note = "", - url = "https://example.token", - avatar = "", - header = "", - locked = false, - statusesCount = 0, - followersCount = 0, - followingCount = 0, - source = null, - bot = false, - emojis = emptyList(), - fields = emptyList(), - ), - maximumLegacyTootCharacters, - null, - null, - configuration, + uri = "https://example.token", + title = "Example Instance", + version = "2.6.3", + maxTootChars = maximumLegacyTootCharacters, + pollConfiguration = null, + configuration = configuration, + maxMediaAttachments = null, + pleroma = null, + uploadLimit = null, + rules = emptyList() ) } diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index fc5ee636b..449f0cf52 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -7,6 +7,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.view.getSecondsForDurationIndex import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -14,6 +15,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.robolectric.annotation.Config +import java.time.Instant import java.util.ArrayList import java.util.Date @@ -50,7 +52,39 @@ class FilterTest { expiresAt = null, irreversible = false, wholeWord = true - ) + ), + Filter( + id = "123", + phrase = "#hashtag", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = true + ), + Filter( + id = "123", + phrase = "expired", + context = listOf(Filter.HOME), + expiresAt = Date.from(Instant.now().minusSeconds(10)), + irreversible = false, + wholeWord = true + ), + Filter( + id = "123", + phrase = "unexpired", + context = listOf(Filter.HOME), + expiresAt = Date.from(Instant.now().plusSeconds(3600)), + irreversible = false, + wholeWord = true + ), + Filter( + id = "123", + phrase = "href", + context = listOf(Filter.HOME), + expiresAt = null, + irreversible = false, + wholeWord = false + ), ) filterModel.initWithFilters(filters) @@ -148,6 +182,67 @@ class FilterTest { ) } + @Test + fun shouldFilterHashtags() { + assertTrue( + filterModel.shouldFilterStatus( + mockStatus(content = "#hashtag one two three") + ) + ) + } + + @Test + fun shouldFilterHashtags_whenContentIsMarkedUp() { + assertTrue( + filterModel.shouldFilterStatus( + mockStatus(content = "

#hashtagone two three

") + ) + ) + } + + @Test + fun shouldNotFilterHtmlAttributes() { + assertFalse( + filterModel.shouldFilterStatus( + mockStatus(content = "

https://foo.bar/ one two three

") + ) + ) + } + + @Test + fun shouldNotFilter_whenFilterIsExpired() { + assertFalse( + filterModel.shouldFilterStatus( + mockStatus(content = "content matching expired filter should not be filtered") + ) + ) + } + + @Test + fun shouldFilter_whenFilterIsUnexpired() { + assertTrue( + filterModel.shouldFilterStatus( + mockStatus(content = "content matching unexpired filter should be filtered") + ) + ) + } + + @Test + fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() { + val expiredBySeconds = 3600 + val expiredDate = Date.from(Instant.now().minusSeconds(expiredBySeconds.toLong())) + val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate) + assert(updatedDuration != null && updatedDuration <= -expiredBySeconds) + } + + @Test + fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() { + val expiresInSeconds = 3600 + val expiredDate = Date.from(Instant.now().plusSeconds(expiresInSeconds.toLong())) + val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate) + assert(updatedDuration != null && updatedDuration > (expiresInSeconds - 60)) + } + private fun mockStatus( content: String = "", spoilerText: String = "", @@ -209,7 +304,8 @@ class FilterTest { ) } else null, card = null, - quote = null + language = null, + quote = null, ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt new file mode 100644 index 000000000..8c7aadaa4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -0,0 +1,147 @@ +package com.keylesspalace.tusky + +import android.app.Activity +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +import android.os.Looper +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.viewpager2.widget.ViewPager2 +import androidx.work.testing.WorkManagerTestInitHelper +import at.connyduck.calladapter.networkresult.NetworkResult +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.components.notifications.NotificationHelper +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.TimelineAccount +import net.accelf.yuito.FooterDrawerItem +import net.accelf.yuito.QuickTootViewModel +import net.accelf.yuito.streaming.StreamingManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.util.concurrent.BackgroundExecutor.runInBackground +import org.robolectric.annotation.Config +import java.util.Date + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val account = Account( + id = "1", + localUsername = "", + username = "", + displayName = "", + createdAt = Date(), + note = "", + url = "", + avatar = "", + header = "", + ) + private val accountEntity = AccountEntity( + id = 1, + domain = "test.domain", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + + @Before + fun setup() { + WorkManagerTestInitHelper.initializeTestWorkManager(context) + } + + @Test + fun `clicking notification of type FOLLOW shows notification tab`() { + val intent = showNotification(Notification.Type.FOLLOW) + + val activity = startMainActivity(intent) + val currentTab = activity.findViewById(R.id.viewPager).currentItem + + val notificationTab = defaultTabs().indexOfFirst { it.id == NOTIFICATIONS } + + assertEquals(currentTab, notificationTab) + } + + @Test + fun `clicking notification of type FOLLOW_REQUEST shows follow requests`() { + val intent = showNotification(Notification.Type.FOLLOW_REQUEST) + + val activity = startMainActivity(intent) + val nextActivity = shadowOf(activity).peekNextStartedActivity() + + assertNotNull(nextActivity) + assertEquals(ComponentName(context, AccountListActivity::class.java.name), nextActivity.component) + assertEquals(AccountListActivity.Type.FOLLOW_REQUESTS, nextActivity.getSerializableExtra("type")) + } + + private fun showNotification(type: Notification.Type): Intent { + val notificationManager = context.getSystemService(NotificationManager::class.java) + val shadowNotificationManager = shadowOf(notificationManager) + + NotificationHelper.createNotificationChannelsForAccount(accountEntity, context) + + runInBackground { + NotificationHelper.make( + context, + Notification( + type = type, + id = "id", + account = TimelineAccount( + id = "1", + localUsername = "connyduck", + username = "connyduck@mastodon.example", + displayName = "Conny Duck", + url = "https://mastodon.example/@ConnyDuck", + avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg" + ), + status = null + ), + accountEntity, + true + ) + } + + val notification = shadowNotificationManager.allNotifications.first() + return shadowOf(notification.contentIntent).savedIntent + } + + private fun startMainActivity(intent: Intent): Activity { + val controller = Robolectric.buildActivity(MainActivity::class.java, intent) + val activity = controller.get() + val instance: Instance = mock() + val mockAccountManager: AccountManager = mock { + on { activeAccount } doReturn accountEntity + } + val viewModel = QuickTootViewModel(mockAccountManager) + activity.eventHub = EventHub() + activity.accountManager = mockAccountManager + activity.mastodonApi = mock { + onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) + onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList()) + onBlocking { getInstance() } doReturn NetworkResult.success(instance) + } + activity.viewModelFactory = mock { + on { create(eq(QuickTootViewModel::class.java), any()) } doReturn viewModel + } + activity.streamingManager = StreamingManager(EventHub(), mock(), mock()) + controller.create().start() + return activity + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt index 213405603..268ae5d36 100644 --- a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt @@ -38,6 +38,7 @@ class SpanUtilsTest { return listOf( "@mention", "#tag", + "#tåg", "https://thr.ee/meh?foo=bar&wat=@at#hmm", "http://thr.ee/meh?foo=bar&wat=@at#hmm" ) diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index c117cf59e..927495cfc 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -17,7 +17,6 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import io.reactivex.rxjava3.core.Single import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import okhttp3.ResponseBody.Companion.toResponseBody @@ -30,6 +29,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @@ -78,7 +78,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.just(Response.error(500, "".toResponseBody())) + onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, db = db, gson = Gson() @@ -98,7 +98,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Single.error(IOException()) + onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, db = db, gson = Gson() @@ -154,22 +154,18 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 3) } doReturn Single.just( - Response.success( - listOf( - mockStatus("8"), - mockStatus("7"), - mockStatus("5") - ) + onBlocking { homeTimeline(limit = 3) } doReturn Response.success( + listOf( + mockStatus("8"), + mockStatus("7"), + mockStatus("5") ) ) - on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just( - Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) + onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") ) ) }, @@ -222,22 +218,18 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("8"), - mockStatus("7"), - mockStatus("5") - ) + onBlocking { homeTimeline(limit = 20) } doReturn Response.success( + listOf( + mockStatus("8"), + mockStatus("7"), + mockStatus("5") ) ) - on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) + onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") ) ) }, @@ -287,22 +279,18 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 3) } doReturn Single.just( - Response.success( - listOf( - mockStatus("6"), - mockStatus("4"), - mockStatus("3") - ) + onBlocking { homeTimeline(limit = 3) } doReturn Response.success( + listOf( + mockStatus("6"), + mockStatus("4"), + mockStatus("3") ) ) - on { homeTimeline(maxId = "3", limit = 3) } doReturn Single.just( - Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) + onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") ) ) }, @@ -344,13 +332,11 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("5"), - mockStatus("4"), - mockStatus("3") - ) + onBlocking { homeTimeline(limit = 20) } doReturn Response.success( + listOf( + mockStatus("5"), + mockStatus("4"), + mockStatus("3") ) ) }, @@ -397,15 +383,12 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(limit = 20) } doReturn Single.just( - Response.success(emptyList()) - ) - on { homeTimeline(maxId = "3", limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("3"), - mockStatus("1") - ) + onBlocking { homeTimeline(limit = 20) } doReturn Response.success(emptyList()) + + onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("1") ) ) }, @@ -452,21 +435,17 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(sinceId = "6", limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("9"), - mockStatus("8"), - mockStatus("7") - ) + onBlocking { homeTimeline(sinceId = "6", limit = 20) } doReturn Response.success( + listOf( + mockStatus("9"), + mockStatus("8"), + mockStatus("7") ) ) - on { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("8"), - mockStatus("7") - ) + onBlocking { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Response.success( + listOf( + mockStatus("8"), + mockStatus("7") ) ) }, @@ -515,13 +494,11 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - on { homeTimeline(maxId = "5", limit = 20) } doReturn Single.just( - Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) + onBlocking { homeTimeline(maxId = "5", limit = 20) } doReturn Response.success( + listOf( + mockStatus("3"), + mockStatus("2"), + mockStatus("1") ) ) }, diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index b522e63c0..a4791e6fa 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -10,7 +10,15 @@ import java.util.Date private val fixedDate = Date(1638889052000) -fun mockStatus(id: String = "100") = Status( +fun mockStatus( + id: String = "100", + inReplyToId: String? = null, + inReplyToAccountId: String? = null, + spoilerText: String = "", + reblogged: Boolean = false, + favourited: Boolean = true, + bookmarked: Boolean = true +) = Status( id = id, url = "https://mastodon.example/@ConnyDuck/$id", account = TimelineAccount( @@ -21,8 +29,8 @@ fun mockStatus(id: String = "100") = Status( url = "https://mastodon.example/@ConnyDuck", avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg" ), - inReplyToId = null, - inReplyToAccountId = null, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, reblog = null, content = "Test", createdAt = fixedDate, @@ -30,11 +38,11 @@ fun mockStatus(id: String = "100") = Status( reblogsCount = 1, favouritesCount = 2, repliesCount = 3, - reblogged = false, - favourited = true, - bookmarked = true, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked, sensitive = true, - spoilerText = "", + spoilerText = spoilerText, visibility = Status.Visibility.PUBLIC, attachments = ArrayList(), mentions = emptyList(), @@ -44,14 +52,36 @@ fun mockStatus(id: String = "100") = Status( muted = false, poll = null, card = null, + language = null, quote = null, ) -fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete( - status = mockStatus(id), - isExpanded = false, - isShowingContent = false, - isCollapsed = true, +fun mockStatusViewData( + id: String = "100", + inReplyToId: String? = null, + inReplyToAccountId: String? = null, + isDetailed: Boolean = false, + spoilerText: String = "", + isExpanded: Boolean = false, + isShowingContent: Boolean = false, + isCollapsed: Boolean = !isDetailed, + reblogged: Boolean = false, + favourited: Boolean = true, + bookmarked: Boolean = true +) = StatusViewData.Concrete( + status = mockStatus( + id = id, + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + spoilerText = spoilerText, + reblogged = reblogged, + favourited = favourited, + bookmarked = bookmarked + ), + isExpanded = isExpanded, + isShowingContent = isShowingContent, + isCollapsed = isCollapsed, + isDetailed = isDetailed ) fun mockStatusEntityWithAccount( diff --git a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt new file mode 100644 index 000000000..e1d690a14 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt @@ -0,0 +1,356 @@ +package com.keylesspalace.tusky.components.viewthread + +import android.os.Looper.getMainLooper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import at.connyduck.calladapter.networkresult.NetworkResult +import com.keylesspalace.tusky.appstore.BookmarkEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.components.timeline.mockStatus +import com.keylesspalace.tusky.components.timeline.mockStatusViewData +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.StatusContext +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.usecase.TimelineCases +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.IOException + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class ViewThreadViewModelTest { + + private lateinit var api: MastodonApi + private lateinit var eventHub: EventHub + private lateinit var viewModel: ViewThreadViewModel + + private val threadId = "1234" + + @Before + fun setup() { + shadowOf(getMainLooper()).idle() + + api = mock() + eventHub = EventHub() + val filterModel = FilterModel() + val timelineCases = TimelineCases(api, eventHub) + val accountManager: AccountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.test", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + } + viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager) + } + + @Test + fun `should emit status and context when both load`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should emit status even if context fails to load`() { + api.stub { + onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1")) + onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) + } + + viewModel.loadThread(threadId) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true) + ), + revealButton = RevealButtonState.NO_BUTTON, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should emit error when status and context fail to load`() { + api.stub { + onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException()) + onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) + } + + viewModel.loadThread(threadId) + + runBlocking { + assertEquals( + ThreadUiState.Error::class.java, + viewModel.uiState.first().javaClass + ) + } + } + + @Test + fun `should emit error when status fails to load`() { + api.stub { + onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException()) + onBlocking { statusContext(threadId) } doReturn NetworkResult.success( + StatusContext( + ancestors = listOf(mockStatus(id = "1")), + descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1")) + ) + ) + } + + viewModel.loadThread(threadId) + + runBlocking { + assertEquals( + ThreadUiState.Error::class.java, + viewModel.uiState.first().javaClass + ) + } + } + + @Test + fun `should update state when reveal button is toggled`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + viewModel.toggleRevealButton() + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true) + ), + revealButton = RevealButtonState.HIDE, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should handle favorite event`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + eventHub.dispatch(FavoriteEvent(statusId = "1", false)) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test", favourited = false), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should handle reblog event`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + eventHub.dispatch(ReblogEvent(statusId = "2", true)) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should handle bookmark event`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + eventHub.dispatch(BookmarkEvent(statusId = "3", false)) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false) + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should remove status`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should change status expanded state`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + viewModel.changeExpanded( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") + ) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should change content collapsed state`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + viewModel.changeContentCollapsed( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") + ) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + @Test + fun `should change content showing state`() { + mockSuccessResponses() + + viewModel.loadThread(threadId) + + viewModel.changeContentShowing( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test") + ) + + runBlocking { + assertEquals( + ThreadUiState.Success( + statuses = listOf( + mockStatusViewData(id = "1", spoilerText = "Test"), + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true), + mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test") + ), + revealButton = RevealButtonState.REVEAL, + refreshing = false + ), + viewModel.uiState.first() + ) + } + } + + private fun mockSuccessResponses() { + api.stub { + onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test")) + onBlocking { statusContext(threadId) } doReturn NetworkResult.success( + StatusContext( + ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")), + descendants = listOf(mockStatus(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) + ) + ) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index e12d06667..7050b3271 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -101,7 +101,7 @@ class TimelineDaoTest { assertStatuses(statusesAfterCleanup, loadedStatuses) val loadedAccounts: MutableList> = mutableListOf() - val accountCursor = db.query("SELECT timelineUserId, serverId FROM TimelineAccountEntity", null) + val accountCursor = db.query("SELECT timelineUserId, serverId FROM TimelineAccountEntity ORDER BY timelineUserId, serverId", null) accountCursor.moveToFirst() while (!accountCursor.isAfterLast) { val accountId: Long = accountCursor.getLong(accountCursor.getColumnIndex("timelineUserId")) @@ -111,10 +111,10 @@ class TimelineDaoTest { } val expectedAccounts = listOf( - 1L to "3", 1L to "10", - 1L to "R10", 1L to "20", + 1L to "3", + 1L to "R10", 2L to "5" ) @@ -448,7 +448,7 @@ class TimelineDaoTest { favourited = !even, bookmarked = false, sensitive = even, - spoilerText = "spoier$statusId", + spoilerText = "spoiler$statusId", visibility = Status.Visibility.PRIVATE, attachments = "attachments$accountId", mentions = "mentions$accountId", @@ -463,6 +463,7 @@ class TimelineDaoTest { contentShowing = true, pinned = false, card = card, + language = null, quote = null, ) return Triple(status, author, reblogAuthor) diff --git a/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt new file mode 100644 index 000000000..f6e9ccf43 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt @@ -0,0 +1,102 @@ +package com.keylesspalace.tusky.usecase + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PinEvent +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import io.reactivex.rxjava3.core.Single +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response +import java.util.Date + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class TimelineCasesTest { + + private lateinit var api: MastodonApi + private lateinit var eventHub: EventHub + private lateinit var timelineCases: TimelineCases + + private val statusId = "1234" + + @Before + fun setup() { + + api = mock() + eventHub = EventHub() + timelineCases = TimelineCases(api, eventHub) + } + + @Test + fun `pin success emits PinEvent`() { + api.stub { + onBlocking { pinStatus(statusId) } doReturn Single.just(mockStatus(pinned = true)) + } + + val events = eventHub.events.test() + timelineCases.pin(statusId, true) + .test() + .assertComplete() + + events.assertValue(PinEvent(statusId, true)) + } + + @Test + fun `pin failure with server error throws TimelineError with server message`() { + api.stub { + onBlocking { pinStatus(statusId) } doReturn Single.error( + HttpException( + Response.error( + 422, + "{\"error\":\"Validation Failed: You have already pinned the maximum number of toots\"}".toResponseBody() + ) + ) + ) + } + timelineCases.pin(statusId, true) + .test() + .assertError { it.message == "Validation Failed: You have already pinned the maximum number of toots" } + } + + private fun mockStatus(pinned: Boolean = false): Status { + return Status( + id = "123", + url = "https://mastodon.social/@Tusky/100571663297225812", + account = mock(), + inReplyToId = null, + inReplyToAccountId = null, + reblog = null, + content = "", + createdAt = Date(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + repliesCount = 0, + reblogged = false, + favourited = false, + bookmarked = false, + sensitive = false, + spoilerText = "", + visibility = Status.Visibility.PUBLIC, + attachments = arrayListOf(), + mentions = listOf(), + tags = listOf(), + application = null, + pinned = pinned, + muted = false, + poll = null, + card = null, + language = null, + quote = null, + ) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt index 65571dfba..90e12caa7 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt @@ -1,8 +1,11 @@ package com.keylesspalace.tusky.util +import android.content.Context import android.text.SpannableStringBuilder import android.text.style.URLSpan import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener @@ -29,6 +32,9 @@ class LinkHelperTest { HashTag("mastodev", "https://example.com/Tags/mastodev"), ) + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + @Test fun whenSettingClickableText_mentionUrlsArePreserved() { val builder = SpannableStringBuilder() @@ -80,6 +86,17 @@ class LinkHelperTest { } } + @Test + fun whenCheckingTags_tagNameIsNormalized() { + val mutator = "aeiou".toList().zip("åÉîøÜ".toList()).toMap() + for (tag in tags) { + val mutatedTagName = String(tag.name.map { mutator[it] ?: it }.toCharArray()) + val tagName = getTagName("#$mutatedTagName", tags) + Assert.assertNotNull(tagName) + Assert.assertNotNull(tags.firstOrNull { it.name == tagName }) + } + } + @Test fun hashedUrlSpans_withNoMatchingTag_areNotModified() { for (tag in tags) { @@ -140,4 +157,115 @@ class LinkHelperTest { Assert.assertEquals(domain, getDomain(url)) } } + + @Test + fun hiddenDomainsAreMarkedUp() { + val displayedContent = "This is a good place to go" + val maliciousDomain = "malicious.place" + val maliciousUrl = "https://$maliciousDomain/to/go" + val content = SpannableStringBuilder() + content.append(displayedContent, URLSpan(maliciousUrl), 0) + Assert.assertEquals( + context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain), + markupHiddenUrls(context, content).toString() + ) + } + + @Test + fun fraudulentDomainsAreMarkedUp() { + val displayedContent = "https://tusky.app/" + val maliciousDomain = "malicious.place" + val maliciousUrl = "https://$maliciousDomain/to/go" + val content = SpannableStringBuilder() + content.append(displayedContent, URLSpan(maliciousUrl), 0) + Assert.assertEquals( + context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain), + markupHiddenUrls(context, content).toString() + ) + } + + @Test + fun multipleHiddenDomainsAreMarkedUp() { + val domains = listOf("one.place", "another.place", "athird.place") + val displayedContent = "link" + val content = SpannableStringBuilder() + for (domain in domains) { + content.append(displayedContent, URLSpan("https://$domain/foo/bar"), 0) + } + + val markedUpContent = markupHiddenUrls(context, content) + for (domain in domains) { + Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, displayedContent, domain))) + } + } + + @Test + fun nonUriTextExactlyMatchingDomainIsNotMarkedUp() { + val domain = "some.place" + val content = SpannableStringBuilder() + .append(domain, URLSpan("https://some.place/"), 0) + .append(domain, URLSpan("https://some.place"), 0) + .append(domain, URLSpan("https://www.some.place"), 0) + .append("www.$domain", URLSpan("https://some.place"), 0) + .append("www.$domain", URLSpan("https://some.place/"), 0) + + val markedUpContent = markupHiddenUrls(context, content) + Assert.assertFalse(markedUpContent.contains("🔗")) + } + + @Test + fun validMentionsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(mention.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder) + for (mention in mentions) { + Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})")) + } + } + + @Test + fun invalidMentionsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(mention.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder) + for (mention in mentions) { + Assert.assertFalse(markedUpContent.contains("${getDomain(mention.url)})")) + } + } + + @Test + fun validTagsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(tag.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder) + for (tag in tags) { + Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})")) + } + } + + @Test + fun invalidTagsAreNotMarkedUp() { + val builder = SpannableStringBuilder() + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(tag.url), 0) + builder.append(" ") + } + + val markedUpContent = markupHiddenUrls(context, builder) + for (tag in tags) { + Assert.assertFalse(markedUpContent.contains("${getDomain(tag.url)})")) + } + } } diff --git a/build.gradle b/build.gradle index 725ab8da4..fe71eb07c 100644 --- a/build.gradle +++ b/build.gradle @@ -5,14 +5,11 @@ buildscript { gradlePluginPortal() } dependencies { - classpath "com.android.tools.build:gradle:7.2.0" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" - classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1" + classpath libs.android.gradle.plugin + classpath libs.kotlin.gradle.plugin + classpath libs.ktlint.gradle } } -plugins { - id "org.jlleitschuh.gradle.ktlint" version "10.2.1" -} allprojects { apply plugin: "org.jlleitschuh.gradle.ktlint" diff --git a/fastlane/metadata/android/de/changelogs/89.txt b/fastlane/metadata/android/de/changelogs/89.txt deleted file mode 100644 index cb92453b8..000000000 --- a/fastlane/metadata/android/de/changelogs/89.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v17.0 - -- "Öffnen als..." ist jetzt im Menü in Konto Profilen auch verfügbar, wenn mehrere Konten genutzt werden -- Die Anmeldung wird jetzt über die WebView innerhalb der App abgewickelt -- Unterstützung für Android 12 -- Unterstützung für die neue Mastodon instance configuration API -- und einige andere kleine Fehlerbehebungen und Verbesserungen diff --git a/fastlane/metadata/android/de/changelogs/94.txt b/fastlane/metadata/android/de/changelogs/94.txt deleted file mode 100644 index 711131ca3..000000000 --- a/fastlane/metadata/android/de/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Push-Benachrichtigungen via Unified Push. Um Unified Push zu verwenden musst du dich neu einloggen. -- Die Anzahl an Antworten unter einem Beitrag wird jetzt in der Timeline angezeigt. -- Bilder können jetzt vor dem Veröffentlichen zugeschnitten werden. -- Das Erstellungsdatum eines Profils wird jetzt angezeigt. -- Beim Betrachten einer Liste ist jetzt der Listenname ersichtlich. -- Fehlerbehebungen -- verbesserte Übersetzungen diff --git a/fastlane/metadata/android/en-US/changelogs/91.txt b/fastlane/metadata/android/en-US/changelogs/91.txt deleted file mode 100644 index e1d983039..000000000 --- a/fastlane/metadata/android/en-US/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Support for new Mastodon 3.5 notification types -- The bot badge now looks better and adjusts to the selected theme -- Text can now be selected on the post detail view -- Fixed a lot of bugs, including one that prevented logins on Android 6 and lower diff --git a/fastlane/metadata/android/en-US/changelogs/94.txt b/fastlane/metadata/android/en-US/changelogs/94.txt deleted file mode 100644 index 8dc15d853..000000000 --- a/fastlane/metadata/android/en-US/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Support for Unified Push. To activate the support you will have to relogin into your accounts. -- The number of responses to a post is now indicated in timelines. -- Images can now by cropped while composing a post. -- Profiles now show the date when they were created. -- When viewing a list the title is now displayed in the toolbar. -- A lot of bugfixes -- Translation improvements \ No newline at end of file diff --git a/fastlane/metadata/android/fa/changelogs/91.txt b/fastlane/metadata/android/fa/changelogs/91.txt deleted file mode 100644 index 71c5a0aca..000000000 --- a/fastlane/metadata/android/fa/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -تاسکی نگارش ۱۸٫۰ - -- پشتیبانی از گونه‌های آگاهی جدید ماستودون ۳٫۵ -- نشان بات اکنون ظاهر بهتری داشته و با زمینهٔ گزیده تنظیم می‌شود -- متن‌ها اکنون می‌توانند در نمای جزییات فرسته، گزیده شوند -- رفع کلّی مشکل، از جمله مشکلی که جلوی ورود روی اندروید ۶ و پایین‌تر را می‌گرفت diff --git a/fastlane/metadata/android/fr/changelogs/91.txt b/fastlane/metadata/android/fr/changelogs/91.txt deleted file mode 100644 index e385800dc..000000000 --- a/fastlane/metadata/android/fr/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Les nouveaux types de notifications de Mastodon 3.5 sont maintenant supportés -- Le badge robot est maintenant plus joli et s'adapte au thème choisi -- Il est maintenant possible de sélectionner le texte dans l'écran de détails d'un post -- Beaucoup de bogues résolus, dont un qui empêchait de se connecter sous Android 6 ou inférieur diff --git a/fastlane/metadata/android/fr/changelogs/94.txt b/fastlane/metadata/android/fr/changelogs/94.txt deleted file mode 100644 index 15cbac96a..000000000 --- a/fastlane/metadata/android/fr/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Les notifications via UnifiedPush sont à présent supportées. Pour les activer vous devrez reconnecter vos comptes. -- Le nombre de réponses est maintenant affiché sur chaque post dans les fils. -- Les images peuvent maintenant être rognées lors de l'écriture d'un message. -- Les profils affichent à présent leur date de création. -- Lorsqu'une liste est affichée, son nom apparaît maintenant dans la barre d'outils. -- Beaucoup de bogues résolus. -- Des améliorations sur les traductions. diff --git a/fastlane/metadata/android/gl/changelogs/91.txt b/fastlane/metadata/android/gl/changelogs/91.txt deleted file mode 100644 index 00017693d..000000000 --- a/fastlane/metadata/android/gl/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Soporte para o novos tipos de notificación de Mastodon 3.5 -- A insignia de bot foi redeseñada e combina mellor co decorado seleccionado -- Podes seleccionar texto na vista de detalles da publicación -- Moitos arranxos adicionais, incluíndo o que non permitía acceder en Android <6 diff --git a/fastlane/metadata/android/gl/changelogs/94.txt b/fastlane/metadata/android/gl/changelogs/94.txt deleted file mode 100644 index 0e4befb4d..000000000 --- a/fastlane/metadata/android/gl/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Soporte para Unified Push. Para activar a función tes que volver a acceder ás túas contas. -- Agora aparece nas cronoloxías o número de respostas a unha publicación. -- Podes recortar as imaxes cando escribes unha publicación. -- Os perfís mostran a data na que foron creados. -- Móstrase o título da lista na barra de ferramentas ao visualizala. -- Arranxamos moitos fallos. -- Melloras nas traducións. diff --git a/fastlane/metadata/android/hu/changelogs/91.txt b/fastlane/metadata/android/hu/changelogs/91.txt deleted file mode 100644 index c9ad649e1..000000000 --- a/fastlane/metadata/android/hu/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Támogatás az új Mastodon 3.5 értesítési típusokhoz -- A bot jelvény jobban néz ki és alkalmazkodik a választott témához -- A szöveget már kiválaszthatod a bejegyzési részletek megtekintésénél is -- Sok hibajavítás, beleértve egy olyat, mely megakadályozta a bejelentkezést Android 6-on vagy alatta diff --git a/fastlane/metadata/android/hu/changelogs/94.txt b/fastlane/metadata/android/hu/changelogs/94.txt deleted file mode 100644 index fe40fdf1f..000000000 --- a/fastlane/metadata/android/hu/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Egységes leküldés (Unified Push) támogatása. A támogatás aktiválásához újra jelentkezz be a fiókjaidba. -- A bejegyzésekre érkezett válaszok száma már látható az idővonalon. -- Bejegyzés szerkesztése közben meg lehet vágni a képeket. -- A profilokon látható ezek létrehozásának időpontja. -- Lista megtekintésekor ennek címe látható az eszköztáron. -- Rengeteg hibajavítás -- Fordítási javítások diff --git a/fastlane/metadata/android/is/changelogs/89.txt b/fastlane/metadata/android/is/changelogs/89.txt deleted file mode 100644 index e379fc723..000000000 --- a/fastlane/metadata/android/is/changelogs/89.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky útg.17.0 - -- "Opna sem..." er núna líka á valmyndinni í notendasniðum þegar verið er að nota marga aðganga -- Innskráning er núna meðhöndluð í WebView innan forritsins -- Stuðningur við Android 12 -- Stuðningur við API-kerfisviðmót fyrir nýja uppsetningu Mastodon-tilvika -- og mökkur af smærri endurbótum og lagfæringum diff --git a/fastlane/metadata/android/nb-NO/changelogs/91.txt b/fastlane/metadata/android/nb-NO/changelogs/91.txt deleted file mode 100644 index fe3c8e0d2..000000000 --- a/fastlane/metadata/android/nb-NO/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Støtte for Mastodon 3.5-varslingstyper -- Bot-symbolet ser nå bedre ut og endrer seg basert på valgt tema -- Det er nå mulig å markere tekst i skjermbildet som viser innleggsdetaljer -- Fikset flere feil, inkludert en som hindret innlogging på Android 6 og eldre versjoner diff --git a/fastlane/metadata/android/nb-NO/changelogs/94.txt b/fastlane/metadata/android/nb-NO/changelogs/94.txt deleted file mode 100644 index 954a80185..000000000 --- a/fastlane/metadata/android/nb-NO/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Støtte for Unified Push. For å aktivisere dette må du logg inne på kontoene dine på nytt. -- Antall tilbakemeldinger på et innlegg vises nå i tidslinjene. -- Bilder kan nå beskjæres når innlegget opprettes. -- Dato nå en profil ble opprettes vises. -- Visning av liste viser nå navnet på listen i verktøylinjen. -- En mengde feilfikser. -- Oppdaterte oversettelser. diff --git a/fastlane/metadata/android/pl/changelogs/87.txt b/fastlane/metadata/android/pl/changelogs/87.txt deleted file mode 100644 index 2b8d41f69..000000000 --- a/fastlane/metadata/android/pl/changelogs/87.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tusky v16.0 - -- Logika ładowania osi czasu została przepisana w celu przyspieszenia jej i naprawienia błędów. -- Tusky wspiera teraz animowane emotikony w formatach APNG i Animated WebP. -- Mnóstwo poprawek -- Wsparcie dla Androida 11 -- Nowe tłumaczenia: Gaelicki szkocki, galicyjski, ukraiński -- Ulepszone tłumaczenia diff --git a/fastlane/metadata/android/pl/changelogs/89.txt b/fastlane/metadata/android/pl/changelogs/89.txt deleted file mode 100644 index edfc6f0ab..000000000 --- a/fastlane/metadata/android/pl/changelogs/89.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v17.0 - -- "Otwórz jako..." teraz jest także dostępne w menu na profilach kont gdy używane jest kilka kont -- Login teraz jest obsługiwany w WebView w aplikacji -- Wsparcie dla Androida 12 -- Wsparcie nowego API konfiguracji instancji Mastodon -- i wiele innych małych poprawek i ulepszeń diff --git a/fastlane/metadata/android/pt-PT/changelogs/58.txt b/fastlane/metadata/android/pt-PT/changelogs/58.txt deleted file mode 100644 index 24aad2f26..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/58.txt +++ /dev/null @@ -1,12 +0,0 @@ -Tusky v6.0 - -- Os filtros de timeline passaram para "Preferências da Conta" e sincronizam com servidor -- Pode ter uma hashtag personalizada como separador -- Suporte a edição de listas -- O editor sugere emojis personalizados ao escrever -- Nova configuração: "seguir tema do sistema" -- Melhor acessibilidade da timeline -- O Tusky ignora notificações desconhecidas, deixando de crashar -- Nova opção: trocar o idioma do sistema por outro -- Novas traduções -- Muitas outras melhorias e correções diff --git a/fastlane/metadata/android/pt-PT/changelogs/61.txt b/fastlane/metadata/android/pt-PT/changelogs/61.txt deleted file mode 100644 index 3cc7097e1..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/61.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v7.0 - -- Suporte para mostragem de votações, para votação e notificação de votações -- Botões novos para filtrar notificações e excluí-las -- Exclua e rascunhe os seus toots -- Novo indicador que mostra, na foto de perfil, se uma conta é um bot (pode ser desativado nas preferências) -- Novas traduções: Norueguês, Bokmål e Esloveno. diff --git a/fastlane/metadata/android/pt-PT/changelogs/67.txt b/fastlane/metadata/android/pt-PT/changelogs/67.txt deleted file mode 100644 index 5d3d38494..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/67.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky v9.0 - -- Agora pode criar votações no Tusky -- Pesquisa melhorada -- Nova opção em "Preferências da Conta": "Expandir sempre os toots com Aviso de Conteúdo" -- Avatars em formato quadrado com cantos arredondados -- Agora é possível denunciar utilizadores, mesmo que não tenham toots -- O Tusky vai recusar a ligação através de ligações simples (não encriptadas) em Android 6+ -- Muitas outras pequenas melhorias e correções de bugs diff --git a/fastlane/metadata/android/pt-PT/changelogs/68.txt b/fastlane/metadata/android/pt-PT/changelogs/68.txt deleted file mode 100644 index 917921132..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/68.txt +++ /dev/null @@ -1,3 +0,0 @@ -Tusky v9.1 - -Esta atualização garante compatibilidade com Mastodon 3 e melhora a performance e estabilidade. diff --git a/fastlane/metadata/android/pt-PT/changelogs/70.txt b/fastlane/metadata/android/pt-PT/changelogs/70.txt deleted file mode 100644 index 6ac528b12..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/70.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tusky v10.0 - -- Agora é possível adicionar toots aos favoritos e ver a lista de favoritos no Tusky. -- Já pode agendar toots, no entanto é necessário agendá-los para pelo menos 5 minutos depois do momento da escrita. -- Já pode adicionar listas na barra lateral do Tusky! -- Já pode partilhar ficheiros de som nos teus toots! - -E muitas outras pequenas melhorias e correções de bugs! diff --git a/fastlane/metadata/android/pt-PT/changelogs/72.txt b/fastlane/metadata/android/pt-PT/changelogs/72.txt deleted file mode 100644 index f42b0a8e2..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/72.txt +++ /dev/null @@ -1,11 +0,0 @@ -Tusky v11.0 - -- Notificações de seguidores pendentes quando a conta está trancada! -- Novas funcionalidades nas "Preferências": - * desativação do gesto que alterna entre separadores - * diálogo de confirmação antes de dar boost - * mostragem da pré-visualização de links nas timelines -- As conversas agora podem ser silenciadas -- As votações passam a ser calculadas pelo número de votantes e não pelo número de votos -- Várias correções relacionadas com a escrita de toots - - Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/74.txt b/fastlane/metadata/android/pt-PT/changelogs/74.txt deleted file mode 100644 index 9595cb1f3..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/74.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tusky v.12.0 - -- Interface principal melhorada - passa a ser possível mover os separadores para baixo! -- Ao silenciar um utilizador, pode também escolher se também pretende silenciar as notificações -- Agora dá para seguir quantas hashtags quiser num único separador! -- A exibição da descrição dos conteúdos multimédia foi melhorada para suportar descrições super longas - -Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/77.txt b/fastlane/metadata/android/pt-PT/changelogs/77.txt deleted file mode 100644 index 01c57b339..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/77.txt +++ /dev/null @@ -1,10 +0,0 @@ -Tusky v13.0 - -- Suporte para anotações em perfis (novidade do Mastodon 3.2.0) -- Suporte para anúncios do(s) administrador(es) de instâncias (novidade do Mastodon 3.1.0) - -- O avatar da sua conta selecionada passa a ficar visível na barra de ferramentas principal (canto superior esquerdo) -- Tocar no nome de utilizador na timeline abrirá o perfil em questão - -- Várias pequenas melhorias e correções -- Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/80.txt b/fastlane/metadata/android/pt-PT/changelogs/80.txt deleted file mode 100644 index 866dc8e53..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/80.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v14.0 - -- Receba notificações quando um utilizador que segue publicar um toot - basta clicar no ícone do sino (novidade do Mastodon 3.3.0) -- O suporte para rascunhos do Tusky foi reescrito para ser mais rápido, simples e menos propenso a erros. -- Foi adicionado uma funcionalidade de bem-estar, que permite limitar algumas funcionalidades no Tusky. -- O Tusky já consegue animar os emojis personalizados -Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/82.txt b/fastlane/metadata/android/pt-PT/changelogs/82.txt deleted file mode 100644 index 0ee9e8760..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/82.txt +++ /dev/null @@ -1,5 +0,0 @@ -Tusky v15.0 - -- O menu principal passa a mostrar uma opção para ver os utilizadores que pediram para o seguir! -- O relógio para agendar toots ganhou um aspeto mais consistente com o resto do Tusky -Registo completo de alterações: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/pt-PT/changelogs/83.txt b/fastlane/metadata/android/pt-PT/changelogs/83.txt deleted file mode 100644 index 4c71e64d8..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/83.txt +++ /dev/null @@ -1,3 +0,0 @@ -Tusky v15.1 - -O Tusky já não crasha ao adicionar descrição às imagens diff --git a/fastlane/metadata/android/pt-PT/changelogs/87.txt b/fastlane/metadata/android/pt-PT/changelogs/87.txt deleted file mode 100644 index 79a811570..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/87.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tusky v16.0 - -- O algoritmo de carregamento da timeline foi completamente reescrito para ser mais rápida, mais estável e mais fácil de manter. -- O Tusky passa a poder animar emojis personalizados no formato APNG & WebP Animated. -- Muitas correções de bugs -- Suporte para Android 11 -- Novas traduções: gaélico escocês, galego, ucraniano -- Traduções melhoradas diff --git a/fastlane/metadata/android/pt-PT/changelogs/89.txt b/fastlane/metadata/android/pt-PT/changelogs/89.txt deleted file mode 100644 index 28cebc1b3..000000000 --- a/fastlane/metadata/android/pt-PT/changelogs/89.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v17.0 - -- "Abrir como..." está disponível no menu de perfis de contas quando estão várias contas configuradas -- O login passa a ser feito numa WebView dentro da aplicação -- Suporte para Android 12 -- Suporte para a nova API de configuração de instâncias do Mastodon -- Várias pequenas melhorias e correções diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt deleted file mode 100644 index 52d67d811..000000000 --- a/fastlane/metadata/android/pt-PT/full_description.txt +++ /dev/null @@ -1,12 +0,0 @@ -Tusky é um cliente leve para Mastodon, um servidor de rede social de código aberto e livre. - -• Design Material -• Maioria das APIs do Mastodon implementadas -• Suporte para várias contas -• Temas diurno e noturno, com possibilidade de troca automática de acordo com o horário -• Rascunhos - Escreva os seus toots e guarde-os para mais tarde -• Escolha entre estilos diferentes de emoji -• Otimizado para todos os tamanhos de ecrã -• Código totalmente aberto, sem dependências não-livres como Google Play Services - -Para ler mais sobre o Mastodon, visite o endereço https://joinmastodon.org/ diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt deleted file mode 100644 index 38a439d85..000000000 --- a/fastlane/metadata/android/pt-PT/short_description.txt +++ /dev/null @@ -1 +0,0 @@ -Um cliente multi-contas para a rede social Mastodon diff --git a/fastlane/metadata/android/pt-PT/title.txt b/fastlane/metadata/android/pt-PT/title.txt deleted file mode 100644 index 0238ffc0a..000000000 --- a/fastlane/metadata/android/pt-PT/title.txt +++ /dev/null @@ -1 +0,0 @@ -Tusky diff --git a/fastlane/metadata/android/uk/changelogs/89.txt b/fastlane/metadata/android/uk/changelogs/89.txt deleted file mode 100644 index fbed5e83d..000000000 --- a/fastlane/metadata/android/uk/changelogs/89.txt +++ /dev/null @@ -1,7 +0,0 @@ -Tusky v17.0 - -- «Відкрити як...» тепер також доступно в меню профілів облікових записів за користування кількома обліковими записами -- Тепер вхід обробляється у WebView у застосунку -- Підтримка Android 12 -- підтримка нового API конфігурації сервера Mastodon -- і багато інших дрібних виправлень і вдосконалень diff --git a/fastlane/metadata/android/uk/changelogs/91.txt b/fastlane/metadata/android/uk/changelogs/91.txt deleted file mode 100644 index 4132d1553..000000000 --- a/fastlane/metadata/android/uk/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Підтримка нових типів сповіщень Mastodon 3.5 -- Кращий вигляд позначки бота і розширений вибір тем -- Текст тепер можна вибрати у докладному поданні допису -- Виправлено безліч помилок, включно з тою, яка перешкоджала входу на Android 6 і старіших diff --git a/fastlane/metadata/android/uk/changelogs/94.txt b/fastlane/metadata/android/uk/changelogs/94.txt deleted file mode 100644 index a31309fdc..000000000 --- a/fastlane/metadata/android/uk/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Підтримка Unified Push. Щоб активувати підтримку, вам потрібно повторно увійти в обліковий запис. -- Кількість відповідей на допис тепер вказана у стрічках. -- Зображення тепер можуть обрізатися під час складання допису. -- Профілі тепер показують дату їхнього створення. -- Під час перегляду списку назва відтепер показана на панелі інструментів. -- Усунення помилок -- Покращення перекладу diff --git a/fastlane/metadata/android/vi/changelogs/91.txt b/fastlane/metadata/android/vi/changelogs/91.txt deleted file mode 100644 index 2835fdfcb..000000000 --- a/fastlane/metadata/android/vi/changelogs/91.txt +++ /dev/null @@ -1,6 +0,0 @@ -Tusky v18.0 - -- Hỗ trợ những kiểu thông báo mới của Mastodon 3.5 -- Nhãn của tài khoản nhìn đẹp hơn và thay đổi theo chủ đề -- Cho phép chọn và sao chép nội dung tút -- Sửa lỗi chặn đăng nhập trên Android 6 trở xuống diff --git a/fastlane/metadata/android/vi/changelogs/94.txt b/fastlane/metadata/android/vi/changelogs/94.txt deleted file mode 100644 index b3f8aa270..000000000 --- a/fastlane/metadata/android/vi/changelogs/94.txt +++ /dev/null @@ -1,9 +0,0 @@ -Tusky 19.0 - -- Hỗ trợ Unified Push. Bạn cần đăng nhập lại để sử dụng được. -- Hiện số lượng trả lời trên nút -- Cắt ảnh khi viết tút -- Hiện ngày tham gia Mastodon -- Khi xem danh sách, tựa đề sẽ hiện trên toolbar -- Sửa lỗi vặt -- Cải thiện bản dịch diff --git a/fastlane/metadata/android/zh-Hans/changelogs/83.txt b/fastlane/metadata/android/zh-Hans/changelogs/83.txt deleted file mode 100644 index e8f7c36e0..000000000 --- a/fastlane/metadata/android/zh-Hans/changelogs/83.txt +++ /dev/null @@ -1,3 +0,0 @@ -Tusky v15.1 - -此版本修复了给图片添加标题时会崩溃的问题 diff --git a/fastlane/metadata/android/zh-Hans/changelogs/87.txt b/fastlane/metadata/android/zh-Hans/changelogs/87.txt deleted file mode 100644 index 06fcd290f..000000000 --- a/fastlane/metadata/android/zh-Hans/changelogs/87.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tusky v16.0 - -- 时间线加载逻辑完全重写,提升了流畅度、稳定性,更便于维护。 -- APNG和动画WebP格式的动态自定义表情符号。 -- 修正大量BUG -- 支持Android 11 -- 新增界面语言支持:苏格兰盖尔语、加利西亚语、乌克兰语 -- 改进翻译 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..4fd919310 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,153 @@ +[versions] +accelf-easter = "1.0.2" +agp = "7.2.2" +androidx-activity = "1.6.0" +androidx-appcompat = "1.5.1" +androidx-browser = "1.4.0" +androidx-cardview = "1.0.0" +androidx-constraintlayout = "2.1.4" +androidx-core = "1.9.0" +androidx-exifinterface = "1.3.4" +androidx-fragment = "1.5.3" +androidx-junit = "1.1.3" +androidx-paging = "3.1.1" +androidx-preference = "1.2.0" +androidx-recyclerview = "1.1.0" +androidx-sharetarget = "1.2.0" +androidx-splashscreen = "1.0.0" +androidx-swiperefresh-layout = "1.1.0" +androidx-testing = "2.1.0" +androidx-viewpager2 = "1.0.0" +androidx-work = "2.7.1" +autodispose = "2.1.1" +bouncycastle = "1.70" +conscrypt = "2.5.2" +coroutines = "1.6.4" +dagger = "2.43.2" +emoji2 = "1.1.0" +espresso = "3.4.0" +filemoji-compat = "3.2.6" +glide = "4.13.2" +glide-animation-plugin = "2.23.0" +gson = "2.9.0" +kotlin = "1.7.10" +ktlint = "10.2.1" +image-cropper = "4.3.1" +jsoup = "1.14.2" +lifecycle = "2.5.1" +material = "1.6.1" +material-drawer = "8.4.5" +material-typeface = "4.0.0.2-kotlin" +mockito-inline = "4.7.0" +mockito-kotlin = "4.0.0" +networkresult-calladapter = "1.0.0" +okhttp = "4.10.0" +retrofit = "2.9.0" +robolectric = "4.8.1" +room = "2.4.3" +rxandroid3 = "3.0.0" +rxjava3 = "3.1.3" +rxkotlin3 = "3.0.1" +photoview = "2.3.0" +sparkbutton = "4.1.0" +unified-push = "2.0.1" + +[libraries] +accelfeaster = { module = "net.accelf:easter", version.ref = "accelf-easter" } +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +android-material = { module = "com.google.android.material:material", version.ref = "material" } +androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-cardview = { module = "androidx.cardview:cardview", version.ref = "androidx-cardview" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-testing" } +androidx-emoji2-core = { module = "androidx.emoji2:emoji2", version.ref = "emoji2" } +androidx-emoji2-views-core = { module = "androidx.emoji2:emoji2-views", version.ref = "emoji2" } +androidx-emoji2-view-helper = { module = "androidx.emoji2:emoji2-views-helper", version.ref = "emoji2" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } +androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } +androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } +androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycle-reactivestreams-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } +androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" } +androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.ref = "androidx-sharetarget" } +androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefresh-layout" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } +androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } +androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } +autodispose-android-lifecycle = { module = "com.uber.autodispose2:autodispose-androidx-lifecycle", version.ref = "autodispose" } +autodispose-core = { module = "com.uber.autodispose2:autodispose", version.ref = "autodispose" } +bouncycastle = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } +conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = "conscrypt" } +dagger-android-core = { module = "com.google.dagger:dagger-android", version.ref = "dagger" } +dagger-android-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "dagger" } +dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" } +dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } +dagger-core = { module = "com.google.dagger:dagger", version.ref = "dagger" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +filemojicompat-core = { module = "de.c1710:filemojicompat", version.ref = "filemoji-compat" } +filemojicompat-defaults = { module = "de.c1710:filemojicompat-defaults", version.ref = "filemoji-compat" } +filemojicompat-ui = { module = "de.c1710:filemojicompat-ui", version.ref = "filemoji-compat" } +glide-animation-plugin = { module = "com.github.penfeizhou.android.animation:glide-plugin", version.ref = "glide-animation-plugin" } +glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } +glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } +image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +material-drawer-core = { module = "com.mikepenz:materialdrawer", version.ref = "material-drawer" } +material-drawer-iconics = { module = "com.mikepenz:materialdrawer-iconics", version.ref = "material-drawer" } +material-typeface = { module = "com.mikepenz:google-material-typeface", version.ref = "material-typeface" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" } +okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +photoview = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoview" } +retrofit-adapter-rxjava3 = { module = "com.squareup.retrofit2:adapter-rxjava3", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" } +rxjava3-core = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava3" } +rxjava3-kotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin3" } +sparkbutton = { module = "com.github.connyduck:sparkbutton", version.ref = "sparkbutton" } +unified-push = { module = "com.github.UnifiedPush:android-connector", version.ref = "unified-push" } + +[bundles] +androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-browser", "androidx-swiperefreshlayout", + "androidx-recyclerview", "androidx-exifinterface", "androidx-cardview", "androidx-preference-ktx", "androidx-sharetarget", + "androidx-emoji2-core", "androidx-emoji2-views-core", "androidx-emoji2-view-helper", "androidx-lifecycle-viewmodel-ktx", + "androidx-lifecycle-livedata-ktx", "androidx-lifecycle-common-java8", "androidx-lifecycle-reactivestreams-ktx", + "androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime", + "androidx-core-splashscreen", "androidx-activity"] +autodispose = ["autodispose-core", "autodispose-android-lifecycle"] +dagger = ["dagger-core", "dagger-android-core", "dagger-android-support"] +dagger-processors = ["dagger-compiler", "dagger-android-processor"] +filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-defaults"] +glide = ["glide-core", "glide-okhttp3-integration", "glide-animation-plugin"] +material-drawer = ["material-drawer-core", "material-drawer-iconics"] +mockito = ["mockito-kotlin", "mockito-inline"] +okhttp = ["okhttp-core", "okhttp-logging-interceptor"] +retrofit = ["retrofit-core", "retrofit-converter-gson", "retrofit-adapter-rxjava3"] +room = ["androidx-room-ktx", "androidx-room-paging"] +rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"] + +[plugins] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2..249e5832f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d7e66b5c6..8fad3f5a9 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 3da45c161..a69d9cb6c 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright ? 2015-2021 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -32,10 +32,10 @@ # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; -# * expansions ?$var?, ?${var}?, ?${var:-default}?, ?${var+SET}?, -# ?${var#prefix}?, ?${var%suffix}?, and ?$( cmd )?; -# * compound commands having a testable exit status, especially ?case?; -# * various built-in commands including ?command?, ?set?, and ?ulimit?. +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..53a6b238d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/instance-build.gradle b/instance-build.gradle index e572df435..8f1697a20 100644 --- a/instance-build.gradle +++ b/instance-build.gradle @@ -1,6 +1,6 @@ /** Edit this file to create a Tusky build that is customized for your Fediverse instance. -Note: Publishing a custom build on Google Play may violate the Google Play developer policy (Repetetive Content) +Note: Publishing a custom build on Google Play may violate the Google Play developer policy (Repetitive Content) */ // The app name