diff --git a/app/build.gradle b/app/build.gradle index 3413ae94..bb04ac0e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,13 +19,13 @@ def getGitSha = { } android { - compileSdkVersion 31 + compileSdkVersion 33 defaultConfig { applicationId APP_ID - minSdkVersion 21 - targetSdkVersion 31 - versionCode 94 - versionName "19.0" + minSdkVersion 23 + targetSdkVersion 33 + versionCode 97 + versionName "20.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -93,109 +93,64 @@ 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' - -// 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" } 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 00000000..54d5a2bf --- /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 00000000..2bc6256f --- /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 00000000..a47f993a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/42.json @@ -0,0 +1,953 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "a62399cb3859de7fcbb9bd7053f7cb1d", + "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, 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 + } + ], + "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, 'a62399cb3859de7fcbb9bd7053f7cb1d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json new file mode 100644 index 00000000..eeceb203 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json @@ -0,0 +1,959 @@ +{ + "formatVersion": 1, + "database": { + "version": 43, + "identityHash": "bf68abe55bb58765da7f9d6f7ef618e2", + "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, `defaultPostLanguage` TEXT 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": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "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, 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 + } + ], + "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, 'bf68abe55bb58765da7f9d6f7ef618e2')" + ] + } +} \ No newline at end of file diff --git a/app/src/blue/res/drawable/ic_launcher_background.xml b/app/src/blue/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 29d74ed8..00000000 --- a/app/src/blue/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/blue/res/mipmap-hdpi/ic_launcher.png b/app/src/blue/res/mipmap-hdpi/ic_launcher.png index 2a27b8c0..023b25f7 100644 Binary files a/app/src/blue/res/mipmap-hdpi/ic_launcher.png and b/app/src/blue/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-mdpi/ic_launcher.png b/app/src/blue/res/mipmap-mdpi/ic_launcher.png index cfd10bbd..3463d7bb 100644 Binary files a/app/src/blue/res/mipmap-mdpi/ic_launcher.png and b/app/src/blue/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xhdpi/ic_launcher.png index 513aaf8b..3694d986 100644 Binary files a/app/src/blue/res/mipmap-xhdpi/ic_launcher.png and b/app/src/blue/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png index cc536fe6..2ef6e08a 100644 Binary files a/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/blue/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png index c11643c5..88795199 100644 Binary files a/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png differ 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 7fef1444..00000000 --- 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/mipmap-hdpi/ic_launcher.png b/app/src/green/res/mipmap-hdpi/ic_launcher.png index eddd426d..08960b92 100644 Binary files a/app/src/green/res/mipmap-hdpi/ic_launcher.png and b/app/src/green/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-mdpi/ic_launcher.png b/app/src/green/res/mipmap-mdpi/ic_launcher.png index d56d05c1..1a769a71 100644 Binary files a/app/src/green/res/mipmap-mdpi/ic_launcher.png and b/app/src/green/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xhdpi/ic_launcher.png b/app/src/green/res/mipmap-xhdpi/ic_launcher.png index b1a89a86..33714c71 100644 Binary files a/app/src/green/res/mipmap-xhdpi/ic_launcher.png and b/app/src/green/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png index 73a09c23..5de70d9f 100644 Binary files a/app/src/green/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/green/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png index a043331f..0297f2e1 100644 Binary files a/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/green/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/green/res/values/flavor-colors.xml b/app/src/green/res/values/flavor-colors.xml index e1f58f2e..a5120ead 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 55ffd493..eed5019d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,12 +4,10 @@ package="com.keylesspalace.tusky"> - - + + + - + android:usesCleartextTraffic="false" + android:localeConfig="@xml/locales_config"> + android:theme="@style/TuskyBaseTheme" + android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" /> diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 00000000..3132225d Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index d34dd6df..cb262806 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -16,7 +16,6 @@ package com.keylesspalace.tusky; import android.app.ActivityManager; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; @@ -92,11 +91,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab requesters = new HashMap<>(); } - @Override - protected void attachBaseContext(Context base) { - super.attachBaseContext(TuskyApplication.getLocaleManager().setLocale(base)); - } - protected boolean requiresLogin() { return true; } @@ -132,7 +126,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 046ab04a..36a476f2 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 b749fe93..369f6926 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 d6de5d8e..3a61df90 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 ed3aed3a..3099bd00 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 @@ -60,7 +60,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 24e0c402..c00387b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky +import android.Manifest 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 @@ -31,8 +33,10 @@ import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog 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 @@ -73,6 +77,7 @@ import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment @@ -176,6 +181,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) { @@ -200,9 +208,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 @@ -262,6 +279,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 onResume() { @@ -287,20 +331,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - 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 -> { @@ -376,7 +406,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) @@ -829,6 +859,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 @@ -840,6 +873,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 5ae1591c..cc12479a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -18,12 +18,20 @@ 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.fragment.app.commit +import androidx.lifecycle.lifecycleScope +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.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import kotlinx.coroutines.launch import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -31,16 +39,21 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + 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) @@ -67,6 +80,70 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } } + 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 { @@ -75,6 +152,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/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index de75b7c7..0db85211 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -100,6 +100,6 @@ fun defaultTabs(): List { createTabDataFromId(HOME), createTabDataFromId(NOTIFICATIONS), createTabDataFromId(LOCAL), - createTabDataFromId(FEDERATED) + createTabDataFromId(DIRECT) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 76418e01..0f20a785 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/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index ded947a8..5401b593 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,8 +16,6 @@ package com.keylesspalace.tusky import android.app.Application -import android.content.Context -import android.content.res.Configuration import android.util.Log import androidx.preference.PreferenceManager import androidx.work.WorkManager @@ -44,6 +42,9 @@ class TuskyApplication : Application(), HasAndroidInjector { @Inject lateinit var notificationWorkerFactory: NotificationWorkerFactory + @Inject + lateinit var localeManager: LocaleManager + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -74,6 +75,8 @@ class TuskyApplication : Application(), HasAndroidInjector { val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) ThemeUtils.setAppNightMode(theme) + localeManager.setLocale() + RxJavaPlugins.setErrorHandler { Log.w("RxJava", "undeliverable exception", it) } @@ -86,20 +89,5 @@ class TuskyApplication : Application(), HasAndroidInjector { ) } - override fun attachBaseContext(base: Context) { - localeManager = LocaleManager(base) - super.attachBaseContext(localeManager.setLocale(base)) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - localeManager.setLocale(this) - } - override fun androidInjector() = androidInjector - - companion object { - @JvmStatic - lateinit var localeManager: LocaleManager - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index fda2c82b..344ca3ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -27,6 +27,7 @@ import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Environment import android.transition.Transition @@ -47,6 +48,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 @@ -211,12 +213,20 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener } private fun requestDownloadMedia() { - requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadMedia() - } else { - showErrorDialog(binding.toolbar, R.string.error_media_download_permission, R.string.action_retry) { requestDownloadMedia() } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadMedia() + } else { + showErrorDialog( + binding.toolbar, + R.string.error_media_download_permission, + R.string.action_retry + ) { requestDownloadMedia() } + } } + } else { + downloadMedia() } } 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 e45b783a..00000000 --- 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 7ba5537b..30cf6309 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 com.keylesspalace.tusky.util.modernLanguageCode +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)?.modernLanguageCode?.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 c481bbf5..5c59c2fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -174,12 +174,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 */ @@ -190,8 +190,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(); } @@ -199,7 +199,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, @@ -213,19 +213,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) { @@ -239,16 +239,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; } @@ -491,7 +491,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 ef366795..6d70d0e1 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 980f644b..69765da6 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.entity.Attachment; @@ -44,6 +44,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; @@ -100,7 +101,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; @@ -563,7 +564,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. @@ -590,24 +591,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); @@ -632,7 +621,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { }); 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()) { @@ -649,7 +638,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()) { @@ -710,9 +699,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) @@ -884,16 +873,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 ""; @@ -1068,13 +1057,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); @@ -1084,8 +1069,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; @@ -1093,19 +1078,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); @@ -1116,11 +1103,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); @@ -1129,16 +1123,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); @@ -1168,13 +1168,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 ae0b0678..74f09f64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -21,12 +21,12 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; -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 8abbbd5f..00000000 --- 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 80de7874..2c687e6b 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 @@ -171,7 +170,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 760db829..baeeea43 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 bf31019e..457cda7b 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,33 @@ 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.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 +50,107 @@ 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.chinwag_green) - } + binding.swipeRefreshLayout.isEnabled = false + binding.statusView.visibility = View.GONE - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.media.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } - 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) + adapter.addLoadStateListener { loadState -> + binding.statusView.hide() + binding.progressBar.hide() + + if (adapter.itemCount == 0) { + when (loadState.refresh) { + is LoadState.NotLoading -> { + if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { + binding.statusView.show() + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) } } + is LoadState.Error -> { + binding.statusView.show() + + if ((loadState.refresh as LoadState.Error).error is IOException) { + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) + } else { + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + } + } + is LoadState.Loading -> { + binding.progressBar.show() + } } } - }) - - 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 +159,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 00000000..e5a0b592 --- /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 00000000..60c76743 --- /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 00000000..81865b0f --- /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 00000000..5c3528e9 --- /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 00000000..34ad159e --- /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 d7e753bb..b696bfc1 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 70ebfc7d..6ebe76b7 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 0ecbb9db..1522aaad 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 @@ -35,24 +35,27 @@ 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 import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDelegate import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.os.LocaleListCompat import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -66,8 +69,10 @@ 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.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 @@ -85,25 +90,27 @@ 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 import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.modernLanguageCode 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.text.DecimalFormat import java.util.Locale import javax.inject.Inject import kotlin.math.max @@ -116,7 +123,8 @@ class ComposeActivity : OnEmojiSelectedListener, Injectable, OnReceiveContentListener, - ComposeScheduleView.OnTimeSetListener { + ComposeScheduleView.OnTimeSetListener, + CaptionDialog.Listener { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -138,8 +146,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) { @@ -147,7 +154,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 -> @@ -169,6 +176,7 @@ class ComposeActivity : uriNew, size, itemOld.description, + null, // Intentionally reset focus when cropping itemOld ) } @@ -212,8 +220,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, @@ -224,17 +236,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) + + 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) val statusContent = composeOptions?.content if (!statusContent.isNullOrEmpty()) { @@ -245,11 +265,32 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } + setupLanguageSpinner(getInitialLanguage(composeOptions?.language)) setupComposeField(preferences, viewModel.startingText) 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) + } + } + + binding.composeEditField.post { + binding.composeEditField.requestFocus() + } } private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) { @@ -363,36 +404,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 { @@ -400,25 +453,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() - } } } @@ -459,6 +513,105 @@ 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 mergeLocaleListCompat(list: MutableList, localeListCompat: LocaleListCompat) { + for (index in 0 until localeListCompat.size()) { + val locale = localeListCompat[index] + if (locale != null && list.none { locale.language == it.language }) { + list.add(locale) + } + } + } + + // Ensure that the locale whose code matches the given language is first in the list + private fun ensureLanguageIsFirst(locales: MutableList, language: String) { + var currentLocaleIndex = locales.indexOfFirst { it.language == language } + if (currentLocaleIndex < 0) { + // Recheck against modern language codes + // This should only happen when replying or when the per-account post language is set + // to a modern code + currentLocaleIndex = locales.indexOfFirst { it.modernLanguageCode == language } + + if (currentLocaleIndex < 0) { + // This can happen when: + // - Your per-account posting language is set to one android doesn't know (e.g. toki pona) + // - Replying to a post in a language android doesn't know + locales.add(0, Locale(language)) + Log.w(TAG, "Attempting to use unknown language tag '$language'") + return + } + } + + if (currentLocaleIndex > 0) { + // Move preselected locale to the top + locales.add(0, locales.removeAt(currentLocaleIndex)) + } + } + + private fun setupLanguageSpinner(initialLanguage: String) { + val locales = mutableListOf() + mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first + mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages + locales.addAll( // finally, other languages + // Only "base" languages, "en" but not "en_DK" + Locale.getAvailableLocales().filter { + it.country.isNullOrEmpty() && + it.script.isNullOrEmpty() && + it.variant.isNullOrEmpty() + } + ) + ensureLanguageIsFirst(locales, initialLanguage) + + 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).modernLanguageCode + } + + override fun onNothingSelected(parent: AdapterView<*>) { + parent.setSelection(0) + } + } + binding.composePostLanguageButton.apply { + adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales) + setSelection(0) + } + } + + private fun getInitialLanguage(language: String? = null): String { + return if (language.isNullOrEmpty()) { + // Account-specific language set on the server + if (accountManager.activeAccount?.defaultPostLanguage?.isNotEmpty() == true) { + accountManager.activeAccount?.defaultPostLanguage!! + } else { + // Setting the application ui preference sets the default locale + AppCompatDelegate.getApplicationLocales()[0]?.language + ?: Locale.getDefault().language + } + } else { + language + } } private fun setupActionBar() { @@ -555,6 +708,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) } @@ -581,12 +737,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_chinwag_green) + getColor(R.color.transparent_chinwag_green) } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp) - ContextCompat.getColor(this, R.color.chinwag_green) + getColor(R.color.chinwag_green) } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) ThemeUtils.getColor(this, android.R.attr.textColorTertiary) @@ -600,7 +756,7 @@ class ComposeActivity : @ColorInt val color = if (binding.composeScheduleView.time == null) { ThemeUtils.getColor(this, android.R.attr.textColorTertiary) } else { - ContextCompat.getColor(this, R.color.chinwag_green) + getColor(R.color.chinwag_green) } binding.composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } @@ -693,7 +849,7 @@ class ComposeActivity : // Wait until bottom sheet is not collapsed and show next screen after if (newState == BottomSheetBehavior.STATE_COLLAPSED) { addMediaBehavior.removeBottomSheetCallback(this) - if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions( this@ComposeActivity, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), @@ -711,13 +867,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 ) } @@ -768,18 +928,22 @@ class ComposeActivity : } } var length = binding.composeEditField.length() - offset - 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) } @@ -822,7 +986,7 @@ class ComposeActivity : enableButtons(false) val contentText = binding.composeEditField.text.toString() var spoilerText = "" - if (viewModel.showContentWarning.value!!) { + if (viewModel.showContentWarning.value) { spoilerText = binding.composeContentWarningField.text.toString() } val characterCount = calculateTextLength() @@ -837,9 +1001,8 @@ class ComposeActivity : ) } - viewModel.sendStatus(contentText, spoilerText).observe( - this - ) { + lifecycleScope.launch { + viewModel.sendStatus(contentText, spoilerText) finishingUploadDialog?.dismiss() deleteDraftAndFinish() } @@ -935,13 +1098,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) } } } @@ -952,7 +1119,7 @@ class ComposeActivity : binding.composeContentWarningBar.show() binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length) binding.composeContentWarningField.requestFocus() - ContextCompat.getColor(this, R.color.chinwag_green) + getColor(R.color.chinwag_green) } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() @@ -970,23 +1137,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) { @@ -999,7 +1149,7 @@ class ComposeActivity : } if (keyCode == KeyEvent.KEYCODE_BACK) { - onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } } @@ -1010,8 +1160,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) } @@ -1065,7 +1222,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; @@ -1086,6 +1244,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 @@ -1106,7 +1272,8 @@ class ComposeActivity : var scheduledAt: String? = null, var sensitive: Boolean? = null, var poll: NewPoll? = null, - var modifiedInitialState: Boolean? = null + var modifiedInitialState: Boolean? = null, + var language: String? = null, ) : Parcelable companion object { @@ -1117,6 +1284,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" /** * @param options ComposeOptions to configure the ComposeActivity 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 7b3d208b..99e68db7 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 a7e1779c..71d1ae3f 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,10 +18,7 @@ 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 @@ -38,35 +35,40 @@ 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.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet 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 = "" @@ -75,41 +77,35 @@ class ComposeViewModel @Inject constructor( private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false + private var hasScheduledTimeChanged: Boolean = false - val instanceInfo: MutableLiveData = MutableLiveData() + val instanceInfo: SharedFlow = instanceInfoRepo::getInstanceInfo.asFlow() + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - val emoji: MutableLiveData?> = MutableLiveData() - val markMediaAsSensitive = - mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + val emoji: SharedFlow> = instanceInfoRepo::getEmojis.asFlow() + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - 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 markMediaAsSensitive: MutableStateFlow = + MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + + 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) 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 - init { - viewModelScope.launch { - emoji.postValue(instanceInfoRepo.getEmojis()) - } - viewModelScope.launch { - instanceInfo.postValue(instanceInfoRepo.getInstanceInfo()) - } - } + 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() && @@ -117,7 +113,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) { @@ -130,6 +126,7 @@ class ComposeViewModel @Inject constructor( uri: Uri, mediaSize: Long, description: String? = null, + focus: Attachment.Focus? = null, replaceItem: QueuedMedia? = null ): QueuedMedia { var stashMediaItem: QueuedMedia? = null @@ -140,7 +137,8 @@ class ComposeViewModel @Inject constructor( uri = uri, type = type, mediaSize = mediaSize, - description = description + description = description, + focus = focus ) stashMediaItem = mediaItem @@ -157,10 +155,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 } @@ -185,7 +183,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, @@ -194,7 +192,8 @@ class ComposeViewModel @Inject constructor( mediaSize = 0, uploadPercent = -1, id = id, - description = description + description = description, + focus = focus ) mediaValue + mediaItem } @@ -216,13 +215,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) { @@ -248,9 +248,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( @@ -259,53 +261,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, @@ -315,20 +319,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 } @@ -337,7 +342,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 -> @@ -348,6 +355,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]) { '@' -> { @@ -369,7 +388,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 -> @@ -389,7 +408,7 @@ class ComposeViewModel @Inject constructor( fun setup(composeOptions: ComposeActivity.ComposeOptions?) { - if (setupComplete.value == true) { + if (setupComplete) { return } @@ -418,7 +437,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 -> @@ -428,12 +447,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) { @@ -461,6 +481,8 @@ class ComposeViewModel @Inject constructor( } replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusAuthor = composeOptions?.replyingStatusAuthor + + setupComplete = true } fun updatePoll(newPoll: NewPoll) { @@ -468,6 +490,10 @@ class ComposeViewModel @Inject constructor( } fun updateScheduledAt(newScheduledAt: String?) { + if (newScheduledAt != scheduledAt.value) { + hasScheduledTimeChanged = true + } + scheduledAt.value = newScheduledAt } @@ -476,8 +502,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 a0215847..4976ae0c 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 be54a1aa..2855e696 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 324540d1..450cb5aa 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 -> send(UploadEvent.FinishedEvent(result.id)) }, { throwable -> val errorMessage = throwable.getServerErrorMessage() @@ -239,22 +245,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 a87b1b23..005e6729 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 71789611..d5ece95f 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 00000000..4764ec54 --- /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 00000000..9a3e4b00 --- /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 6226eeb8..50e06562 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.chinwag_green)); + circlePaint.setColor(getContext().getColor(R.color.chinwag_green)); 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.chinwag_green : R.color.tusky_grey_10)); + this.markBgPaint.setColor(getContext().getColor(checked ? R.color.chinwag_green : 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 401d6146..48aeb78e 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 { @@ -125,7 +126,8 @@ data class ConversationStatusEntity( pinned = false, muted = muted, poll = poll, - card = null + card = null, + language = language, ), isExpanded = expanded, isShowingContent = showingHiddenContent, @@ -144,7 +146,11 @@ fun TimelineAccount.toEntity() = emojis = emojis ?: emptyList() ) -fun Status.toEntity() = +fun Status.toEntity( + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +) = ConversationStatusEntity( id = id, url = url, @@ -163,19 +169,30 @@ fun Status.toEntity() = attachments = attachments, mentions = mentions, tags = tags, - showingHiddenContent = false, - expanded = false, - collapsed = true, + showingHiddenContent = contentShowing, + expanded = expanded, + collapsed = contentCollapsed, muted = muted ?: false, - poll = poll + poll = poll, + language = language, ) -fun Conversation.toEntity(accountId: Long, order: Int) = +fun Conversation.toEntity( + accountId: Long, + order: Int, + expanded: Boolean, + contentShowing: Boolean, + contentCollapsed: Boolean +) = ConversationEntity( accountId = accountId, id = id, order = order, accounts = accounts.map { it.toEntity() }, unread = unread, - lastStatus = lastStatus!!.toEntity() + lastStatus = lastStatus!!.toEntity( + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) ) 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 fae55f0b..f9082e8a 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/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index 02a44f95..921d694b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -5,6 +5,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink @@ -12,15 +13,17 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class ConversationsRemoteMediator( - private val accountId: Long, private val api: MastodonApi, - private val db: AppDatabase + private val db: AppDatabase, + accountManager: AccountManager, ) : RemoteMediator() { private var nextKey: String? = null private var order: Int = 0 + private val activeAccount = accountManager.activeAccount!! + override suspend fun load( loadType: LoadType, state: PagingState @@ -46,7 +49,7 @@ class ConversationsRemoteMediator( db.withTransaction { if (loadType == LoadType.REFRESH) { - db.conversationDao().deleteForAccount(accountId) + db.conversationDao().deleteForAccount(activeAccount.id) } val linkHeader = conversationsResponse.headers()["Link"] @@ -56,8 +59,19 @@ class ConversationsRemoteMediator( db.conversationDao().insert( conversations .filterNot { it.lastStatus == null } - .map { - it.toEntity(accountId, order++) + .map { conversation -> + + val expanded = activeAccount.alwaysOpenSpoiler + val contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive + val contentCollapsed = true + + conversation.toEntity( + accountId = activeAccount.id, + order = order++, + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed + ) } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 735aa26c..4e140b76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.await @@ -42,8 +43,15 @@ class ConversationsViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( config = PagingConfig(pageSize = 30), - remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), - pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } + remoteMediator = ConversationsRemoteMediator(api, database, accountManager), + pagingSourceFactory = { + val activeAccount = accountManager.activeAccount + if (activeAccount == null) { + EmptyPagingSource() + } else { + database.conversationDao().conversationsForAccount(activeAccount.id) + } + } ) .flow .map { pagingData -> 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 a6cd3fcd..61023ddb 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 acee683b..98a288b4 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 db6a8a31..dfa36168 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 05e10b6b..bb97621c 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 20c44ba4..bfc5a9b8 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) dao.getEmojiInfo(instanceName)?.emojiList.orEmpty() @@ -69,9 +69,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 -> @@ -85,7 +92,14 @@ class InstanceInfoRepository @Inject constructor( pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL + charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = instanceInfo?.maxFieldNameLength, + maxFieldValueLength = instanceInfo?.maxFieldValueLength ) } } @@ -99,7 +113,14 @@ 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 + // 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 } } 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 9cd28bf2..8a9cf0e9 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 @@ -230,7 +230,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 07bd5652..b69f81e7 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 00000000..39dd311a --- /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 45ecd0f6..beac22aa 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/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index ff4380d3..cf89b6fb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -154,6 +154,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + // TODO language preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) 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 a650f76b..54bb4a4d 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) @@ -61,30 +72,17 @@ class PreferencesActivity : setDisplayShowHomeEnabled(true) } - val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE" + val preferenceType = intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0) + + val fragmentTag = "preference_fragment_$preferenceType" val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) - ?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { - GENERAL_PREFERENCES -> { - setTitle(R.string.action_view_preferences) - PreferencesFragment.newInstance() - } - ACCOUNT_PREFERENCES -> { - setTitle(R.string.action_view_account_preferences) - AccountPreferencesFragment.newInstance() - } - NOTIFICATION_PREFERENCES -> { - setTitle(R.string.pref_title_edit_notification_settings) - NotificationPreferencesFragment.newInstance() - } - TAB_FILTER_PREFERENCES -> { - setTitle(R.string.pref_title_post_tabs) - TabFilterPreferencesFragment.newInstance() - } - PROXY_PREFERENCES -> { - setTitle(R.string.pref_title_http_proxy_settings) - ProxyPreferencesFragment.newInstance() - } + ?: when (preferenceType) { + GENERAL_PREFERENCES -> PreferencesFragment.newInstance() + ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance() + NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance() + TAB_FILTER_PREFERENCES -> TabFilterPreferencesFragment.newInstance() + PROXY_PREFERENCES -> ProxyPreferencesFragment.newInstance() else -> throw IllegalArgumentException("preferenceType not known") } @@ -92,7 +90,16 @@ class PreferencesActivity : replace(R.id.fragment_container, fragment, fragmentTag) } - restartActivitiesOnExit = intent.getBooleanExtra("restart", false) + when (preferenceType) { + GENERAL_PREFERENCES -> setTitle(R.string.action_view_preferences) + ACCOUNT_PREFERENCES -> setTitle(R.string.action_view_account_preferences) + NOTIFICATION_PREFERENCES -> setTitle(R.string.pref_title_edit_notification_settings) + TAB_FILTER_PREFERENCES -> setTitle(R.string.pref_title_post_tabs) + PROXY_PREFERENCES -> setTitle(R.string.pref_title_http_proxy_settings) + } + + onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) + restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false } override fun onResume() { @@ -106,11 +113,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,17 +128,13 @@ 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 -> { - restartActivitiesOnExit = true - } - "language" -> { - restartActivitiesOnExit = true - this.restartCurrentActivity() + restartActivitiesOnBackPressedCallback.isEnabled = true } } @@ -148,20 +151,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 +161,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 61d828c1..b2d31394 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 @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference +import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.getNonNullString @@ -46,6 +47,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var accountManager: AccountManager + @Inject + lateinit var localeManager: LocaleManager + private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } private var httpProxyPref: Preference? = null @@ -71,10 +75,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { setDefaultValue("default") setEntries(R.array.language_entries) setEntryValues(R.array.language_values) - key = PrefKeys.LANGUAGE + key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager setSummaryProvider { entry } setTitle(R.string.pref_title_language) icon = makeIcon(GoogleMaterial.Icon.gmd_translate) + preferenceDataStore = localeManager } listPreference { @@ -96,6 +101,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 9636ce0a..7f249a42 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 ec8110de..7b34b780 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 af886cdd..84a8b032 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 @@ -24,7 +24,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.usecase.TimelineCases @@ -113,11 +112,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 reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) { @@ -131,51 +126,34 @@ 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 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 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() @@ -183,18 +161,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) } @@ -212,18 +185,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/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 2e7849c1..2c838c77 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 @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Environment import android.util.Log import android.view.View @@ -216,7 +217,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) @@ -288,7 +290,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))) @@ -382,7 +384,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 { @@ -407,13 +409,21 @@ class SearchStatusesFragment : SearchFragment(), Status } private fun requestDownloadAllMedia(status: Status) { - val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status) - } else { - Toast.makeText(context, R.string.error_media_download_permission, Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status) + } else { + Toast.makeText( + context, + R.string.error_media_download_permission, + Toast.LENGTH_SHORT + ).show() + } } + } else { + downloadAllMedia(status) } } @@ -461,7 +471,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 8b96283f..25ca65bd 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 @@ -99,7 +99,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { contentShowing = false, pinned = false, card = null, - repliesCount = 0 + repliesCount = 0, + language = null, ) } @@ -141,7 +142,8 @@ fun Status.toEntity( contentCollapsed = contentCollapsed, pinned = actionableStatus.pinned == true, card = actionableStatus.card?.let(gson::toJson), - repliesCount = actionableStatus.repliesCount + repliesCount = actionableStatus.repliesCount, + language = actionableStatus.language, ) } @@ -185,7 +187,8 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { muted = status.muted, poll = poll, card = card, - repliesCount = status.repliesCount + repliesCount = status.repliesCount, + language = status.language, ) } val status = if (reblog != null) { @@ -216,6 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { poll = null, card = null, repliesCount = status.repliesCount, + language = status.language, ) } else { Status( @@ -245,6 +249,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { poll = poll, card = card, repliesCount = status.repliesCount, + language = status.language, ) } return StatusViewData.Concrete( 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 ebab4440..b29ab0cf 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 97bc625b..779fe38d 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 @@ -43,6 +43,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -50,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 retrofit2.HttpException import javax.inject.Inject import kotlin.time.DurationUnit @@ -86,7 +86,7 @@ class CachedTimelineViewModel @Inject constructor( pagingSourceFactory = { val activeAccount = accountManager.activeAccount if (activeAccount == null) { - EmptyTimelinePagingSource() + EmptyPagingSource() } else { db.timelineDao().getStatuses(activeAccount.id) }.also { newPagingSource -> @@ -176,7 +176,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/EmptyTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/EmptyTimelinePagingSource.kt deleted file mode 100644 index 5fd13dfb..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/EmptyTimelinePagingSource.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.keylesspalace.tusky.components.timeline.viewmodel - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.keylesspalace.tusky.db.TimelineStatusWithAccount - -class EmptyTimelinePagingSource : PagingSource() { - override fun getRefreshKey(state: PagingState): Int? = null - - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, 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 8c81df1d..7335e8f3 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 retrofit2.HttpException import retrofit2.Response import java.io.IOException @@ -298,7 +297,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 c291bd01..36e36d10 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 00000000..9e0903b0 --- /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 00000000..ed0393fa --- /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 00000000..9fe91b92 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -0,0 +1,337 @@ +/* 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.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) + ) + 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 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) { + 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) + } + + 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 00000000..b4a8a03e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -0,0 +1,427 @@ +/* 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 { + val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statuses?.find { it.id == this.id } + return toViewData( + isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), + isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, + isCollapsed = oldStatus?.isCollapsed ?: !detailed, + isDetailed = oldStatus?.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/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 5ffc9021..a9b426e6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -59,6 +59,7 @@ data class AccountEntity( var notificationLight: Boolean = true, var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, var defaultMediaSensitivity: Boolean = false, + var defaultPostLanguage: String = "", var alwaysShowSensitiveMedia: Boolean = false, var alwaysOpenSpoiler: Boolean = false, var mediaPreviewEnabled: Boolean = true, 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 9c5e118b..c4956b81 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 @@ -151,6 +154,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.displayName = account.name it.profilePictureUrl = account.avatar it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC + it.defaultPostLanguage = account.source?.language ?: "" it.defaultMediaSensitivity = account.source?.sensitive ?: false it.emojis = account.emojis ?: emptyList() @@ -225,4 +229,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 c43b3652..0b2bdea9 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 = 43) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -581,4 +581,40 @@ 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"); + } + }; + + public static final Migration MIGRATION_42_43 = new Migration(42, 43) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''"); + } + }; } 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 a1e19c75..79b7243f 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 3687da09..0bf1dc32 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 01767f32..efcfe527 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 210bfca3..704440a7 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, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', 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 ecd3c0ce..4c434099 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?, ) @Entity( 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 d85f6c45..744c76b5 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 @@ -77,7 +77,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 e17cb3cf..16ca8e31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -65,7 +65,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, AppDatabase.MIGRATION_42_43, ) .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 b3c5eaf8..989fb526 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -29,9 +29,9 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment 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 c8f746e0..29aa3b47 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 @@ -108,5 +111,20 @@ 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 + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 4870c188..fdad077a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -52,7 +52,8 @@ data class AccountSource( val privacy: Status.Visibility?, val sensitive: Boolean?, val note: String?, - val fields: List? + val fields: List?, + val language: String?, ) data class Field( 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 00d5659d..5837815b 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 29fe7f8e..05cac1a0 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 92a35b69..a653cc58 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 34b80e83..af51a04b 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 f3a4f65b..e2401d93 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 31fcca0e..67ae8912 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -19,19 +19,22 @@ import com.google.gson.annotations.SerializedName data class Instance( val uri: String, - val title: String, - val description: String, - val email: String, + // val title: 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 83ed56e9..d11ad5f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -27,7 +27,8 @@ data class NewStatus( val sensitive: Boolean, @SerializedName("media_ids") val mediaIds: List?, @SerializedName("scheduled_at") val scheduledAt: String?, - val poll: NewPoll? + val poll: NewPoll?, + val language: String?, ) @Parcelize 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 72a37f91..c147ae30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -26,7 +26,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, @@ -35,20 +35,21 @@ 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") val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments") var attachments: ArrayList, + @SerializedName("media_attachments") val attachments: ArrayList, val mentions: List, val tags: List?, val application: Application?, val pinned: Boolean?, val muted: Boolean?, val poll: Poll?, - val card: Card? + val card: Card?, + val language: String?, ) { val actionableId: String @@ -130,7 +131,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 465b9f21..49723965 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 01a08c20..a6d806f0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -25,6 +25,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Environment; import android.util.Log; import android.view.Menu; @@ -41,6 +42,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 +156,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); @@ -289,6 +292,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; @@ -354,7 +365,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); @@ -425,6 +436,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())); } @@ -463,13 +475,17 @@ public abstract class SFragment extends Fragment implements Injectable { } private void requestDownloadAllMedia(Status status) { - String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; - ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { - if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - downloadAllMedia(status); - } else { - Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); - } - }); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + ((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + downloadAllMedia(status); + } else { + Toast.makeText(getContext(), R.string.error_media_download_permission, Toast.LENGTH_SHORT).show(); + } + }); + } else { + downloadAllMedia(status); + } } } 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 0362da9c..28cf64ca 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 89c65e10..686e4fdf 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 4bab0f5c..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ /dev/null @@ -1,683 +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 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.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.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) - ); - 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.chinwag_green); - - 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 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) { - 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); - } - - @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 a1930da8..214741a8 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 062191ad..16a439d9 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 e1d18e9f..f9272994 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 a179e71d..24636a64 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 14f82e8b..b0453b12 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.chinwag_green)) + .setColor(context.getColor(R.color.chinwag_green)) .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, @@ -101,21 +99,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 @@ -135,6 +128,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 20ad8de8..3bcaf877 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 ++ @@ -154,7 +164,8 @@ class SendStatusService : Service(), Injectable { statusToSend.sensitive, statusToSend.mediaIds, statusToSend.scheduledAt, - statusToSend.poll + statusToSend.poll, + statusToSend.language, ) mastodonApi.createStatus( @@ -182,22 +193,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) @@ -225,6 +221,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) { @@ -233,15 +247,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() } } @@ -256,15 +273,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() { @@ -284,7 +338,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 @@ -322,6 +375,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?, @@ -331,5 +385,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 ee92fc2d..52c6f8a1 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 8f114434..570e1e39 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/AbsoluteTimeFormatter.kt b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt index 7d46388b..86f2e66f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt @@ -23,7 +23,7 @@ import java.util.TimeZone class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) { private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz } - private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val sameYearSdf = SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).apply { this.timeZone = tz } private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz } private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } 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 00000000..8f1101d2 --- /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 00000000..6307e721 --- /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/EmptyPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt new file mode 100644 index 00000000..41a12aa3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt @@ -0,0 +1,10 @@ +package com.keylesspalace.tusky.util + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class EmptyPagingSource : PagingSource() { + override fun getRefreshKey(state: PagingState): Int? = null + + override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, null) +} 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 7c3b68a7..00000000 --- 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 00000000..005554bf --- /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 1abbab14..39489e4a 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,37 @@ 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 +237,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 501f9056..ed10bd4e 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 7cdc12e8..e7f03fca 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 21c4307c..00000000 --- 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/LocaleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt new file mode 100644 index 00000000..c1c6425e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt @@ -0,0 +1,11 @@ +package com.keylesspalace.tusky.util + +import java.util.Locale + +// When a language code has changed, `language` *explicitly* returns the obsolete version, +// but `toLanguageTag()` uses the current version +// https://developer.android.com/reference/java/util/Locale#getLanguage() +val Locale.modernLanguageCode: String + get() { + return this.toLanguageTag().split('-', limit = 2)[0] + } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt index 45f3ab37..6795317b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleManager.kt @@ -17,25 +17,89 @@ package com.keylesspalace.tusky.util import android.content.Context import android.content.SharedPreferences -import android.content.res.Configuration +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceManager -import java.util.Locale +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.settings.PrefKeys +import javax.inject.Inject +import javax.inject.Singleton -class LocaleManager(context: Context) { +@Singleton +class LocaleManager @Inject constructor( + val context: Context +) : PreferenceDataStore() { private var prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - fun setLocale(context: Context): Context { - val language = prefs.getNonNullString("language", "default") - if (language == "default") { - return context - } - val locale = Locale.forLanguageTag(language) - Locale.setDefault(locale) + fun setLocale() { + val language = prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) - val res = context.resources - val config = Configuration(res.configuration) - config.setLocale(locale) - return context.createConfigurationContext(config) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (language != HANDLED_BY_SYSTEM) { + // app is being opened on Android 13+ for the first time + // hand over the old setting to the system and save a dummy value in Shared Preferences + applyLanguageToApp(language) + + prefs.edit() + .putString(PrefKeys.LANGUAGE, HANDLED_BY_SYSTEM) + .apply() + } + } else { + // on Android < 13 we have to apply the language at every app start + applyLanguageToApp(language) + } + } + + override fun putString(key: String?, value: String?) { + + // if we are on Android < 13 we have to save the selected language so we can apply it at appstart + // on Android 13+ the system handles it for us + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + prefs.edit() + .putString(PrefKeys.LANGUAGE, value) + .apply() + } + applyLanguageToApp(value) + } + + override fun getString(key: String?, defValue: String?): String? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val selectedLanguage = AppCompatDelegate.getApplicationLocales() + + if (selectedLanguage.isEmpty) { + DEFAULT + } else { + // Android lets users select all variants of languages we support in the system settings, + // so we need to find the closest match + // it should not happen that we find no match, but returning null is fine (picker will show default) + + val availableLanguages = context.resources.getStringArray(R.array.language_values) + + return availableLanguages.find { it == selectedLanguage[0]!!.toLanguageTag() } + ?: availableLanguages.find { language -> + language.startsWith(selectedLanguage[0]!!.language) + } + } + } else { + prefs.getNonNullString(PrefKeys.LANGUAGE, DEFAULT) + } + } + + private fun applyLanguageToApp(language: String?) { + val localeList = if (language == DEFAULT) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(language) + } + + AppCompatDelegate.setApplicationLocales(localeList) + } + + companion object { + private const val DEFAULT = "default" + private const val HANDLED_BY_SYSTEM = "handled_by_system" } } 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 5482b292..408f6453 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 078639ac..5e510034 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 7734d9d7..7087a165 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 0752c4e5..253ea7a0 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 e2db79c6..5342cbf3 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 fef9c0bb..3facc3a9 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 444e71dc..95605b18 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 00000000..c6cea1e2 --- /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 8922fafd..dc149e4b 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 b0a8062f..ae24cebe 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 75f90ca4..2a25bb4c 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 bef7d0e1..ac9df9c2 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 bc7f435d..88776606 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/res/drawable-hdpi/ic_notify.png b/app/src/main/res/drawable-hdpi/ic_notify.png deleted file mode 100644 index 1047eff9..00000000 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 4d9d5238..00000000 Binary files a/app/src/main/res/drawable-mdpi/ic_notify.png and /dev/null differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 0a1e7d8e..00000000 --- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - 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 7e413716..00000000 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 74caaccd..00000000 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_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index bf1bcaa2..283975f4 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,13 +1,30 @@ - - - - - + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..fe445e76 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notify.xml b/app/src/main/res/drawable/ic_notify.xml new file mode 100644 index 00000000..8baeb631 --- /dev/null +++ b/app/src/main/res/drawable/ic_notify.xml @@ -0,0 +1,9 @@ + + + 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 00000000..10332c28 --- /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_quicksettings.xml b/app/src/main/res/drawable/ic_quicksettings.xml new file mode 100644 index 00000000..d3a4807d --- /dev/null +++ b/app/src/main/res/drawable/ic_quicksettings.xml @@ -0,0 +1,6 @@ + + + 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 0dc845c2..00000000 --- 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 00000000..0f7a508f --- /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 150f0860..0c661b0b 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -1,16 +1,32 @@ - + android:layout_height="match_parent"> + + + + + + + android:layout_gravity="center_horizontal|top" + 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_account.xml b/app/src/main/res/layout/activity_account.xml index 562a1020..41262647 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 958de1b8..902bae40 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_main.xml b/app/src/main/res/layout/activity_main.xml index cc9f9570..3fcd8744 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -22,6 +22,7 @@ - - + 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 806d420a..faa4e421 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"> - + + + + + + + android:layout_gravity="top" + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> - + + + + + + + + + diff --git a/app/src/main/res/layout/item_account_field.xml b/app/src/main/res/layout/item_account_field.xml index bc2d0d6d..3d68e3df 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 00000000..a2938b69 --- /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_announcement.xml b/app/src/main/res/layout/item_announcement.xml index 2a9abca4..3ad2fa06 100644 --- a/app/src/main/res/layout/item_announcement.xml +++ b/app/src/main/res/layout/item_announcement.xml @@ -20,8 +20,8 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:padding="8dp" - app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/text"> - + app:counterTextColor="?android:textColorTertiary"> - + + + + + app:counterTextColor="?android:textColorTertiary"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_follow_request.xml b/app/src/main/res/layout/item_follow_request.xml index 0116a272..8fe743d9 100644 --- a/app/src/main/res/layout/item_follow_request.xml +++ b/app/src/main/res/layout/item_follow_request.xml @@ -13,7 +13,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:drawableStart="@drawable/ic_person_add_24dp" android:drawablePadding="10dp" android:ellipsize="end" android:gravity="center_vertical" @@ -21,6 +20,7 @@ android:paddingStart="28dp" android:textColor="?android:textColorSecondary" android:textSize="?attr/status_text_medium" + app:drawableStartCompat="@drawable/ic_person_add_24dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Someone requested to follow you" /> @@ -32,13 +32,13 @@ android:layout_centerVertical="true" android:layout_marginTop="10dp" android:contentDescription="@string/action_view_profile" - tools:src="@drawable/avatar_default" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/notificationTextView" /> + app:layout_constraintTop_toBottomOf="@id/notificationTextView" + tools:src="@drawable/avatar_default" /> diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml index faf9010e..3fbc51ae 100644 --- a/app/src/main/res/layout/item_status.xml +++ b/app/src/main/res/layout/item_status.xml @@ -172,7 +172,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 be553135..9305d970 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 a58d3eaf..99543c3c 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 080e4e94..04f72cf1 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 a44d773a..3e02455a 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 2d38e326..812e361a 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 985ec63a..e428c459 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 4aa248c4..4f9597ae 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,31 +2,29 @@ Vyskytla se chyba. Vyskytla se chyba sítě! Prosím zkontrolujte své připojení a zkuste to znovu! - Tohle nemůže být prázdné. - Neplatná doména zadána - Autentizace s tímto serverem neuspěla. - Nelze najít webový prohlížeč k použití. + Toto nemůže být prázdné. + Byla zadána neplatná doména + Autentizace s tímto serverem nebyla úspěšná. + Nepodařilo se najít webový prohlížeč, který lze použít. Vyskytla se neidentifikovaná chyba autorizace. 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. + Příspěvek je příliš dlouhý! Tento typ souboru nemůže být nahrán. - Tento soubor nemohl být otevřen. - Je vyžadováno povolení číst média. - Je vyžadováno povolení ukládat média. - K jednomu tootu nemohou být přiloženy obrázky i videa. - Nahrání selhalo. - Chyba při odesílání tootu. + Tento soubor se nepodařilo otevřít. + Je vyžadováno oprávnění ke čtení médií. + Je vyžadováno oprávnění ukládat média. + K jednomu příspěvku nemohou být přiloženy obrázky i videa. + Nahrání se nezdařilo. + Chyba při odesílání příspěvku. Domů Oznámení Místní - Federovaná + Federované Přímé zprávy Panely - Toot - Tooty + Vlákno + Příspěvky S odpověďmi Připnuté Sledovaní @@ -46,31 +44,31 @@ Zobrazit více Zobrazit méně Rozbalit - Zabalit + Sbalit Tady nic není. - Tady nic není. Obnovte přetažnením dolů! - %s boostnul/a váš toot - %s si oblíbil/a váš toot + Tady nic není. Obnovte přetažením dolů! + %s boostnul/a váš příspěvek + %s si oblíbil/a váš příspěvek %s vás nyní sleduje Nahlásit uživatele @%s - Dodatečné komentáře? + Další komentáře\? Rychlá odpověď Odpovědět Boostnout Odstranit boost Oblíbit Odstranit oblíbení - Další + Více Napsat - Přihlásit účtem Mastodon - Odhlásit + Přihlásit se účtem Mastodon + Odhlásit se Jste si jistý/á, že se chcete odhlásit z účtu %1$s? Sledovat Přestat sledovat Blokovat Odblokovat Skrýt boosty - Zobrazi boosty + Zobrazit boosty Nahlásit Smazat TOOTNOUT @@ -87,10 +85,10 @@ Média Otevřít v prohlížeči Přidat média - Požídit fotku + Pořídit fotku Sdílet Skrýt - Odkrýt + Zrušit skrytí Zmínit Skrýt média Otevřít menu @@ -102,7 +100,7 @@ Zamítnout Hledat Koncepty - Viditelnost tootu + Viditelnost příspěvku Varování o obsahu Klávesnice s emoji Přidat panel @@ -111,7 +109,7 @@ Hashtagy Otevřít autora boostu Zobrazit boosty - Zobrazit oblíbené + Zobrazit oblíbení Hashtagy Zmínky Odkazy @@ -122,12 +120,12 @@ Sdílet jako… Stáhnout média Stahuji média - Sdílet URL tootu na… - Sdílet toot na… + Sdílet URL příspěvku na… + Sdílet příspěvek na… Sdílet média na… Odesláno! - Uživatel odblokován - Uživatel odkryt + Uživatel byl odblokován + Skrytí uživatele bylo zrušeno Odesláno! Odpověď byla úspěšně odeslána. Který server? @@ -140,7 +138,7 @@ Odpovědět… Avatar Záhlaví - Co je server? + Co je to server\? Připojuji se… Sem může být zadána adresa či doména jakéhokoliv serveru, například mastodon.social, icosahedron.website, social.tchncs.de @@ -156,11 +154,11 @@ Stáhnout Zrušit požadavek o sledování? Přestat sledovat tento účet? - Smazat tento toot? + Smazat tento příspěvek\? Veřejný: Poslat na veřejné časové osy Neuvedený: Neposlat na veřejné časové osy Pouze pro sledující: Poslat pouze sledujícím - Přímý: Poslat pouze zmíněným uživatelům + Přímé: Poslat pouze zmíněným uživatelům Oznámení Oznámení Upozornění @@ -179,7 +177,7 @@ Tmavý Světlý Černý - Automatický při západu slunce + Automaticky při západu slunce Použít systémový design Prohlížeč Používat Vlastní karty Chrome @@ -187,7 +185,7 @@ Jazyk Filtrování časových os Panely - Zobrazi boosty + Zobrazit boosty Zobrazit odpovědi Stahovat náhledy médií Proxy @@ -213,14 +211,16 @@ Noví sledující Oznámení o nových sledujících Boosty - Oznámení, když jsou vaše tooty boostnuty + Oznámení, když jsou vaše příspěvky boostnuty Oblíbení - Oznámení, když jsou vaše tooty označeny jako oblíbené + Oznámení, když jsou vaše příspěvky označeny jako oblíbené %s vás zmínil/a %1$s, %2$s, %3$s a dalších %4$d %1$s, %2$s a %3$s %1$s a %2$s + %d nová interakce + %d nové interakce %d nových interakcí Uzamčený účet @@ -242,8 +242,8 @@ https://git.chinwag.org/chinwag/chinwag-android/issues Profil aplikace Tusky - Sdílet obsah tootu - Sdílet odkaz k tootu + Sdílet obsah příspěvku + Sdílet odkaz na příspěvek Obrázky Video Vyžádáno sledování @@ -251,7 +251,7 @@ za %d let za %d d za %d h - za %d min + za %d m za %d s %d let %d d @@ -284,30 +284,35 @@ Hledejte mezi lidmi, které sledujete Přidat účet na seznam Odstranit účet ze seznamu - Píšete s účtem %1$s + Píšete jako %1$s Nastavení popisku selhalo - Popis pro zrakově postižené\n(limit %d znaků) + Popis pro zrakově postižené +\n(limit %d znak) + Popis pro zrakově postižené +\n(limit %d znaky) + Popis pro zrakově postižené +\n(limit %d znaků) Nastavit popisek Odstranit Uzamknout účet Vyžaduje, abyste ručně schvaloval/a sledující Uložit koncept? - Odesílám toot… - Chyba při odesílání tootu - Odesílám tooty + Odesílám příspěvek… + Chyba při odesílání příspěvku + Odesílám příspěvky Odesílání bylo zrušeno - Kopie vašeho tootu byla uložena do vašich konceptů + Kopie vašeho příspěvku byla uložena do vašich konceptů Napsat Vaše instance %s nemá žádná vlastní emoji Styl emoji Výchozí nastavení systému Musíte si nejprve stáhnout tyto sady emoji Provádím prohledávání… - Rozbalit/zabalit všechny příspěvky - Otevřít toot - Je vyžadován restart aplikace + Rozbalit/Sbalit všechny příspěvky + Otevřít příspěvek + Je vyžadováno restartování aplikace Pro použití těchto změn musíte restartovat aplikaci Tusky Později Restartovat @@ -328,7 +333,7 @@ Označení Obsah Používat absolutní čas - Níže uvedené informace nemusejí zcela odrážet profil uživatele. Dotknutím otevřete celý profil v prohlížeči. + Níže uvedené informace nemusejí zcela odrážet profil uživatele. Dotknutím se otevřete celý profil v prohlížeči. Odepnout Připnout @@ -338,7 +343,7 @@ %s boost - %s boost + %s boosty %s boostů Boostnuto uživatelem @@ -347,35 +352,31 @@ %1$s a %2$s %1$s, %2$s a %3$d další - bylo dosaženo maxima %1$d panelů + bylo dosaženo maxima %1$d panelu + bylo dosaženo maxima %1$d panelů + - Média %s - - Varování o obsahu: %s - - Žádný popis - - Boostnutý - - Oblíbený - - Veřejný - Neuvedený - Pro sledující - Přímý - + Média %s + Varování o obsahu: %s + Žádný popis + Boostnutý + Oblíbený + Veřejný + Neuvedený + Pro sledující + Přímý Název seznamu Hashtag bez # - Napsat toot + Napsat příspěvek Napsat - Vymazat + Vyčistit Filtrovat Použít Zobrazovat indikátor pro roboty Jste si jistý/á, že chcete trvale vymazat všechna vaše oznámení\? Smazat a přepsat - Smazat a přepsat tento toot\? - %1$s • %2$s + Smazat a přepsat tento příspěvek\? + %1$s • %2$s %s hlas %s hlasy @@ -405,30 +406,30 @@ zbývá %d sekundy zbývá %d sekund - Animovat avatary GIF + Animovat GIF avatary Anketa s volbami: %1$s, %2$s, %3$s, %4$s; %5$s Skryté domény Skryté domény Skrýt doménu %s - Doména %s odkryta + Skrytí domény %s bylo zrušeno Jste si jistý/á, že chcete zablokovat vše z domény %s\? Z této domény neuvidíte obsah v žádné veřejné časové ose ani v oznámeních. Vaši sledující z této domény budou odstraněni. Skrýt celou doménu Aktuální sada emoji od Googlu Pokračovat Zpět Hotovo - \@%s úspěšně nahlášen/a - Dodatečné komentáře + \@%s byla/a úspěšně nahlášen/a + Další komentáře Přeposlat na %s Nahlášení selhalo - Stahování tootů neuspělo + Stahování příspěvků selhalo Nahlášení bude zasláno moderátorovi vašeho serveru. Níže můžete uvést, proč tento účet nahlašujete: - Tento účet je z jiného serveru. Chcete na něj také poslat anonymizovanou kopii\? + Tento účet je z jiného serveru. Chcete na něj také poslat anonymizovanou kopii nahlášení\? Zobrazit filtr oznámení Anketa 5 minut 30 minut - 1 hodinu + 1 hodina 6 hodin 1 den 3 dny @@ -437,13 +438,13 @@ Lze zvolit více možností Možnost %d Upravit - Plánované tooty + Naplánováné příspěvky Upravit Přidat anketu - Plánované tooty - Naplánovat toot + Naplánované příspěvky + Naplánované příspěvky Obnovit - Vždy rozbalovat tooty označené varováními o obsahu + Vždy rozbalovat příspěvky označené varováními o obsahu Celé slovo Je-li klíčové slovo nebo fráze pouze alfanumerická, bude použita pouze, pokud odpovídá celému slovu Účty @@ -459,11 +460,10 @@ 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. - Zatím zde nejsou žádné koncepty. + Zatím zde nemáte žádné koncepty. Možnost přetahování prstem pro přechod mezi kartami Seznam Přidat hashtag @@ -474,21 +474,115 @@ Powered by Tusky Zablokovat @%s\? Nahoře - Odkrýt %s + Zrušit skrytí domény %s Skrýt oznámení od %s - Odkrýt oznámení od %s - Odkrýt %s - Ztišit @%s\? + Zrušit skrytí oznámení od %s + Zrušit skrytí %s + Skrýt @%s\? %s požádal/a aby vás mohl/a sledovat Zobrazit dialogové okno s potvrzením při boostování - %s právě vydal + %s právě zveřejnil/a příspěvek Oznámení Přihlášení - %s se zaregistroval - Přihlaste se znovu pro oznámení - Nepodařilo se načíst stránku přihlášení. - Tento příspěvek se nepodařilo poslat! + %s se zaregistroval/a + Přihlaste se znovu pro push oznámení + Nepodařilo se načíst přihlášovací stránku. + Tento příspěvek se nepodařilo odeslat! Nepodařilo se načíst detaily účtu Nepodařilo se načíst informace o odpovědi Obrázek se nepodařilo upravit. + + %s osoba + %s lidi + %s lidí + + + zbývá %d hodina + zbývají %d hodiny + zbývá %d hodin + + + Nemůžete nahrát více než %1$d mediální přílohu. + Nemůžete nahrát více než %1$d mediální přílohy. + Nemůžete nahrát více než %1$d mediálních příloh. + + Smazat tento naplánovaný příspěvek\? + Upozornění na nový toot někoho, koho sledujete. + Přihlášením souhlasíte s pravidly serveru %s. + Pravidla serveru %s + Některé informace, které mohou ovlivnit Vaši duševní pohodu, mohou být skryty. To zahrnuje: +\n +\n - Upozornění na boosty, oblíbené a sledování +\n - Počty boostů a oblíbení u příspěvků +\n - Statistiky sledujících a příspěvků na profilech +\n +\nPush oznámení nebudou ovlivněna, ale můžete si zkontrolovat jejich nastavení manuálně. + Znovu jste se přihlásili ke svému aktuálnímu účtu, abyste aplikaci Tusky udělili oprávnění k odběru push. Stále však máte další účty, které tímto způsobem migrovány nebyly. Přepněte se na ně a znovu se přihlaste na jednom po druhém, abyste povolili podporu oznámení UnifiedPush. + Uložit koncept\? (Přílohy budou znovu nahrány, když obnovíte koncept.) + Klepnutím nebo přetažením kruhu vyberte ohnisko, které bude vždy viditelné v miniaturách. + Před oblíbením zobrazit dialog pro potvrzení + Skrýt nadpis horního panelu nástrojů + Skrýt kvantitativní statistiky profilů + Opravdu chcete smazat seznam %s\? + I když váš účet není uzamčen, zaměstnanci %1$s si myslí, že byste mohli chtít zkontrolovat žádosti o sledování z těchto účtů ručně. + Odebírat + Přestat odebírat + Vytvořit příspěvek + Koncept byl smazán + Video a audio soubory nesmí překročit velikost %s MB. + Připnutí se nezdařilo + Zrušení připnutí se nezdařilo + Vždy + Nikdy + %s (%s) + Upravit obrázek + 14 dní + 30 dní + Příspěvek, na který jste připravili odpověď, byl odstraněn + %s (🔗 %s) + Nastavit bod zaostření + Nepodařilo se nastavit zaostřovací bod + Znovu se přihlaste ke všem účtům, abyste povolili podporu push oznámení. + Aby bylo možné používat push oznámení prostřednictvím UnifiedPush, Tusky potřebuje oprávnění k odběru oznámení na vašem serveru Mastodon. To vyžaduje opětovné přihlášení ke změně rozsahů OAuth udělených aplikaci Tusky. Použitím možnosti opětovného přihlášení zde nebo v předvolbách účtu zachováte všechny vaše místní koncepty a mezipaměť. + přidat reakci + příspěvek, se kterým jsem interagoval/a, je upraven + někdo se zaregistroval + někdo, ke komu jsem přihlášen/a, zveřejnil nový příspěvek + Když je přihlášeno více účtů + Nové příspěvky + Registrace + Oznámení o nových uživatelích + Úpravy příspěvků + Oznámení, když je upraven příspěvek, se kterým jste interagovala, je upraven + Audio + Přílohy + 1+ + Jazyk příspěvku + Doba trvání + Na neurčito + 60 dní + 90 dní + 180 dní + 365 dní + (Beze změny) + Nejsou zde žádná oznámení. + Zobrazit uživatelské jméno v panelech nástrojů + Váše soukromá poznámka o tomto účtu. + Uloženo! + Pohoda + Zkontrolovat oznámení + Omezit upozornění na časové ose + Skrýt kvantitativní statistiky příspěvků + Připojil/a se %1$s + Koncept se ukládá… + Chyba při sledování #%s + Chyba při rušení sledování #%s + %s upravil/a svůj příspěvek + Odebrat záložku + Smazat konverzaci + Zavřít + Podrobnosti + Smazat tuto konverzaci\? + Požádáno o sledování + Animovat vlastní emotikony \ No newline at end of file diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index b6773d20..280e156b 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -8,44 +8,42 @@ 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 + Neges 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 + Wedi\'i hybu gan %s 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\'ch dilyn chi + Mae %s wedi hybu\'ch post + Mae %s wedi hoffi\'ch post + Mae %s wedi\'ch dilyn chi Adrodd @%s Sylwadau ychwanegol? Ateb Cyflym @@ -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 Tusky 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://git.chinwag.org/chinwag/chinwag-android/issues Proffil Tusky - 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 Tusky i roi\'r newidiadau ar waith Nes ymlaen @@ -269,7 +272,7 @@ Dad-hybu Mae gan Tusky 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 + Ymunodd %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. \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 507b74be..d9964fad 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -7,17 +7,15 @@ 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. + Dieser Dateityp kann nicht hochgeladen werden. Die Datei konnte nicht geöffnet werden. - Eine Leseberechtigung wird für das Hochladen der Mediendatei benötigt. + Berechtigung für Zugriff auf Mediendateien benötigt. Eine Berechtigung wird zum Speichern des Mediums benötigt. Bilder und Videos können nicht an den gleichen Beitrag angehängt werden. - Die Mediendatei konnte nicht hochgeladen werden. + Das Hochladen ist gescheitert. Fehler beim Senden des Beitrags. Start Benachrichtigungen @@ -27,7 +25,7 @@ Tabs Konversation Beiträge - mit Antworten + Mit Antworten Angeheftet Folgt Folgende @@ -45,32 +43,32 @@ Zum Anzeigen tippen Zeige mehr Zeige weniger - Mehr - Weniger + Ausklappen + Einklappen Hier ist nichts. Noch keine Beiträge hier! Ziehe nach unten um zu aktualisieren! %s teilte deinen Beitrag %s favorisierte deinen Beitrag %s folgt dir \@%s melden - Irgendwelche Anmerkungen? + Zusätzliche Anmerkungen\? Schnell antworten Antworten Teilen - Boost entfernen + Teilen rückgängig machen Favorisieren Favorisierung entfernen Mehr Beitrag erstellen Mit Mastodon anmelden Ausloggen - Bist du sicher, dass du dich aus dem Konto %1$s ausloggen möchtest\? + Bist du sicher, dass du dich vom Konto %1$s abmelden möchtest\? Folgen Entfolgen Blockieren Entblockieren Geteilte Beiträge verbergen - Zeige Boosts + Zeige geteilte Beiträge Melden Löschen TRÖT @@ -118,12 +116,12 @@ %1$s heruntergeladen Link kopieren Öffne als %s - Teilen als … - Beitragslink teilen… - Beitragsinhalt teilen… - Mediendatei teilen… + Teilen als … + Beitragslink teilen … + Beitragsinhalt teilen … + Mediendatei teilen … Gesendet! - entblockt + Benutzer entblockt Stummschaltung aufgehoben Gesendet! Antwort erfolgreich gesendet. @@ -132,13 +130,13 @@ Inhaltswarnung Anzeigename Über mich - Mastodon durchsuchen… + Suchen … Keine Ergebnisse - Antworten… + Antworten … Profilbild Titelbild - Was ist eine Instanz? - Verbinden … + Was ist eine Instanz\? + Verbinde … Die Adresse einer Instanz oder Domain kann hier eingegeben werden, wie z.B. mastodon.social, icosahedron.website, social.tchncs.de, und mehr! @@ -149,7 +147,7 @@ \n\nWeitere Informationen gibt es auf joinmastodon.org. Stelle Medienupload fertig - Lade hoch … + Lade hoch … Herunterladen Folgeanfrage zurückziehen? Willst du diesem Profil wirklich nicht mehr folgen? @@ -167,8 +165,8 @@ Benachrichtigen wenn Ich erwähnt werde Mir jemand folgt - Jemand meine Posts teilt - Jemandem meine Posts gefallen + Jemand meine Beiträge teilt + Jemandem meine Beiträge gefallen Aussehen App-Thema Zeitleisten @@ -261,17 +259,20 @@ 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) + Für Mensch mit Sehbehinderung beschreiben +\n(%d Zeichen) + Für Menschen mit Sehbehinderung beschreiben +\n(%d Zeichen) Beschreibung eingeben Entfernen Gesperrtes Profil Wer dir folgen möchte, muss um deine Erlaubnis bitten Entwurf speichern? - Beitrag senden… + Sende Beitrag … Fehler beim Senden Beiträge senden Senden abgebrochen @@ -281,7 +282,7 @@ Emoji-Stil System-Standard Du musst diese Emoji-Sets zunächst herunterladen - Nachschlagen… + Schlage nach … Alle Beiträge aus-/einklappen Beitrag öffnen App-Neustart erforderlich @@ -319,9 +320,9 @@ Keine Beschreibung Favorisiert - Öffentlich - Folgende - Direkt + Öffentlich + Folgende + Direkt Listenname Medien herunterladen Medien werden heruntergeladen @@ -342,7 +343,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 @@ -421,11 +422,10 @@ Mehrere Möglichkeiten Möglichkeit %d Geplante Beiträge - Editieren + Bearbeiten Geplante Beiträge Plane Beitrag Zurücksetzen - Audiodateien müssen kleiner als 40 MB sein. Lesezeichen Lesezeichen Lesezeichen @@ -433,7 +433,7 @@ Als Lesezeichen gespeichert Liste auswählen Liste - Fehler beim Nachschlagen von Post %s + Fehler beim Nachschlagen von Beitrag %s Du hast keine Entwürfe. Du hast keine geplanten Beiträge. Das Datum des geplanten Toots muss mindestens 5 Minuten in der Zukunft liegen. @@ -493,7 +493,7 @@ Neue Beiträge GIF-Emojis animieren Jemand, den ich abonniert habe, hat etwas Neues veröffentlicht - %s hat gerade etwas gepostet + %s hat gerade etwas veröffentlicht %d Min. Benachrichtigungen überprüfen Informationen, die dein Wohlbefinden beeinflussen könnten, werden versteckt. Das beinhaltet @@ -505,7 +505,7 @@ \nPush-Benachrichtigungen sind nicht betroffen, aber du kannst diese manuell überprüfen. Auch wenn dein Konto nicht gesperrt ist, haben die Admins von %1$s gedacht, dass es besser wäre diese Folgenden manuell zu bestätigen. Keine Statistiken auf Profilen zeigen - Keine Statistiken in Posts zeigen + Keine Statistiken in Beiträgen zeigen Timeline-Benachrichtigungen einschränken Abonnieren nicht mehr abonnieren @@ -541,4 +541,32 @@ 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 Entwurf … + Video- oder Tondateien dürfen nicht grösser als %s MB sein. + #%s folgen fehlgeschlagen + #%s entfolgen fehlgeschlagen + Diesen geplanten Beitrag löschen\? + %s-Regeln + Mit dem Anmelden stimmst du den Regeln von %s zu. + Tippe oder ziehe den Kreis auf die Stelle, die in Vorschaubildern in der Mitte sein soll. + Entwurf speichern\? (Anhänge werden erneut hochgeladen, sobald du den Entwurf wiederherstellst.) + (Keine Änderung) + Benutzername in Hauptnavigation anzeigen + Pinnen fehlgeschlagen + Lösen fehlgeschlagen + Immer + Wenn mit mehreren Konten angemeldet + Niemals + %s (%s) + Sprache des Beitrags + %s (🔗 %s) + Setzen des Fokuspunktes fehlgeschlagen + Fokuspunkt setzen + Reaktion hinzufügen \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 85583a2b..cef00874 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 de167b85..434160e7 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 b562f7fe..19c91ba9 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,18 +139,20 @@ 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… + Alŝutado… Elŝuti - Nuligi peton de sekvado? - Ne plu sekvi? - Forigi ĉi tiun mesaĝon? + Ĉu nuligi peton de sekvado\? + Ĉu ne plu sekvi\? + Ĉu forigi ĉi tiun mesaĝon\? Publika: afiŝi en publikaj tempolinioj Nelistigita: Ne afiŝi en publikaj tempolinioj Nur por sekvantoj: Afiŝi nur al sekvantoj @@ -163,7 +163,7 @@ Sciigi per sono Sciigi per vibro Sciigi per lumo - Sciigi al mi kiam + Sciigi al mi, kiam iu mencias min iu sekvas min miaj mesaĝoj estas diskonigitaj @@ -176,10 +176,10 @@ Hela Nigra Aŭtomata laŭ la horo - Uzi sisteman temon + Uzi sisteman etoson Retumilo Uzi la integritan retumilon - Kâsi butonon de verko dum rulumado + Kaŝi butonon de verko dum rulumado Lingvo Filtrado de tempolinioj Langetoj @@ -187,14 +187,14 @@ Montri la respondojn Elŝuti antaŭvidojn de aŭdovidaĵoj Prokurilo - HTTP prokurilo - Ebligi HTTP prokurilon - Adreso de HTTP prokurilo - Pordo de HTTP prokurilo + HTTP-prokurilo + Ebligi HTTP-prokurilon + Adreso de HTTP-prokurilo + Pordo de HTTP-prokurilo Dekomenca privateco de mesaĝoj - Ĉiam marki aŭdovidaĵojn kiel tiklaj + Ĉiam marki aŭdovidaĵojn kiel tiklajn Publikigante (sinkronigita kun la servilo) - Sinkronigo de la preferoj malsukcesis + Sinkronigo de la agordoj malsukcesis Publika Nelistigita Nur por sekvantoj @@ -205,16 +205,16 @@ Granda La plej granda Novaj mencioj - Sciigoj pri novajn menciojn + Sciigoj pri novaj mencioj Novaj sekvantoj - Sciigoj pri novajn sekvantojn + Sciigoj pri novaj sekvantoj Diskonigoj - Sciigoj kiam viaj mesaĝoj estas diskonigita + Sciigoj, kiam viaj mesaĝoj estas diskonigitaj Stelumoj - Sciigoj kiam viaj mesaĝoj estas stelumitaj + Sciigoj, kiam viaj mesaĝoj estas stelumitaj %s menciis vin %1$s, %2$s, %3$s kaj %4$d aliaj - %1$s, %2$s, kaj %3$s + %1$s, %2$s kaj %3$s %1$s kaj %2$s %d nova interago @@ -245,11 +245,11 @@ Video Sekvado petita - en %dj - en %dt - en %dh - en %dm - en %ds + post %dj + post %dt + post %dh + post %dm + post %ds %dj %dt %dh @@ -260,15 +260,15 @@ Aŭdovidaĵoj Respondo al @%s ŝarĝi pli - Publikaj liniotempoj + Publikaj tempolinioj Konversacioj Aldoni filtrilon Redakti filtrilon Forigi - Akualigi + Aktualigi Frazo filtrota Aldoni konton - Aldoni novan Mastodon konton + Aldoni novan Mastodon-konton Listoj Listoj Ne povis krei la liston @@ -278,30 +278,32 @@ Ŝanĝi la nomon de la listo Forigi la liston Redakti la liston - Serĉi homojn ke vi sekvas + Serĉi homojn, kiujn vi sekvas Aldoni konton al la listo Forigi konton el la listo Afiŝi per konto %1$s Redakto de apudskribo malsukcesis - Priskribi por misvidantaj homoj\n(%d signoj maksimume) + + Priskribi por vide handikapitaj homoj +\n(%d signoj maksimume) Redakti apudskribon Forigi Ŝlosi konton Vi devas permane rajtigi sekvantojn - Konservi malneton? - Sendante la mesaĝo… + Ĉu konservi malneton\? + Sendado de la mesaĝo… Eraro dum sendo de la mesaĝo - Sendante la mesaĝoj + Sendado de la mesaĝoj Sendo nuligita Kopio de la mesaĝo estis konservita en viaj malnetoj Verki Via nodo %s ne havas proprajn emoĝiojn Stilo de emoĝioj - Sistema valoro - Vi unue devos elŝuti ĉi tiujn emoĝiarojn - Serĉante… + El la sistemo + Vi unue devos elŝuti ĉi tiun emoĝiaron + Serĉado… Pligrandigi/malgrandigi ĉiujn mesaĝojn Malfermi mesaĝon Restartigo necesas @@ -309,15 +311,15 @@ Poste Restartigi Dekomenca emoĝiaro de via aparato - La emoĝioj «Blob» konataj el Android 4.4−7.1 + La emoĝioj «Blob» konataj de Android 4.4−7.1 Norma emoĝiaro de Mastodon Elŝuto malsukcesis Roboto %1$s moviĝis al: Diskonigi al la originala atentaro - Eksdiskonigi + Maldiskonigi Tusky enhavas kodon kaj risurcojn el la sekvantaj malfermitkodaj projetkoj: - Laŭ la permesilo «Apache License» (kopio sube) + Laŭ la permesilo «Apache» (kopio sube) CC-BY 4.0 CC-BY-SA 4.0 Profilaj metadatumoj @@ -325,7 +327,7 @@ Etikedo Enhavo Uzi absolutan tempon - Subaj informoj povas nekomplete prezenti la profilon de la uzanto. Presi por malfermi la kompletan profilon en retumilo. + Subaj informoj povas nekomplete prezenti la profilon de la uzanto. Tuŝi por malfermi la kompletan profilon en retumilo. Depingli Alpingli @@ -337,7 +339,7 @@ <b>%s</b> Diskonigoj Diskonigita de - Stelumita per + Stelumita de %1$s %1$s kaj %2$s %1$s, %2$s kaj %3$d aliaj @@ -355,27 +357,27 @@ Stelumita - Publika + Publika - Nelistigita - Sekvantoj + Nelistigita + Sekvantoj - Rekta + Rekta Nomo de la listo Forigi kaj reskribi - Ĉu forigi kaj reskribi ĉi-tiun mesaĝon\? + Ĉu forigi kaj reskribi ĉi tiun mesaĝon\? enketoj finiĝis - Montri indikilon por robotoj - Moviĝi GIF profilbildojn + Montri indikilon pri robotoj + Ebligi GIF-profilbildojn Enketoj - Sciigoj pri enketoj kiuj finiĝis + Sciigoj pri enketoj, kiuj finiĝis Kradvorto sen # Viŝi Filtri Apliki Verki mesaĝon Verki - Ĉu vi certas ke vi volas proĉiame viŝi ĉiujn viajn sciigojn\? + Ĉu vi certas, ke vi volas porĉiame viŝi ĉiujn viajn sciigojn\? Agoj por bildo %s %1$s • %2$s @@ -383,15 +385,15 @@ %s voĉdonoj finiĝos je %s - finiĝita + finita Voĉdoni - Enketo al kiu vi voĉdonis finiĝis - Enketo kiu vi kreis finiĝis + Enketo, al kiu vi voĉdonis, finiĝis + Enketo, kiun vi kreis, finiĝis Kaŝitaj domajnoj Kaŝitaj domajnoj Silentigi %s %s malsilentigita - Ĉu vi certas ke vi volas tute bloki %s\? Vi ne vidos enhavon de tiu domajno en publika tempolinio aŭ en viaj sciigoj. Viaj sekvantoj de tiu domajno estos forigitaj. + Ĉu vi certas ke vi volas tute bloki %s\? Vi ne vidos enhavon de tiu domajno en publika tempolinio aŭ en viaj sciigoj. Viaj sekvantoj el tiu domajno estos forigitaj. Kaŝi la tutan domajnon La aktuala emoĝiaro de Google Balotenketo kun elektoj: %1$s, %2$s, %3$s, %4$s, %5$s @@ -405,14 +407,14 @@ Venigo de statusoj malsukcesis La signalo estos sendita al la kontrolantoj de via servilo. Vi povas doni klarigon pri kial vi signalas ĉi tiun konton sube: La konto estas en alia servilo. Ĉu sendi sennomigitan kopion de la signalo ankaŭ tien\? - Montri filtrilon de Sciigoj + Montri filtrilon de sciigoj Tuta vorto - Kiam ĉefvorto aŭ frazo estas nur litercifera, tio aplikos nur se ĝi kongruas la tutan vorton + Ŝlosilvorto aŭ frazo litercifera aplikiĝos, nur se ĝi kongruas kun la tuta vorto Kontoj Serĉo malsukcesis Aldoni baloton - Ĉiam pligrandigi tootoj markiĝita per enhavaj avertoj - Baloto + Ĉiam montri mesaĝojn kun enhavaj avertoj + Enketo 5 minutoj 30 minutoj 1 horo @@ -436,10 +438,9 @@ Aldonita al la legosignoj 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. + Eraro dum serĉo de la mesaĝo %s + Vi havas neniun malneton. + Vi havas neniun planitan mesaĝon. Petoj de sekvado Kradvortoj @@ -450,8 +451,8 @@ Sciigoj pri petoj de sekvado Montri buntajn transirojn por kaŝitaj aŭdovidaĵoj Kaŝi la sciigojn - Silentigi @%s\? - Bloki @%s\? + Ĉu silentigi @%s\? + Ĉu bloki @%s\? Malsilentigi la konversacion Silentigi la konversacion Malsilentigi %s @@ -489,39 +490,83 @@ Ebligi ŝovumadon por ŝanĝi inter la langetoj Mastodon havas minimuman intervalon de planado de 5 minutoj. Kunsendaĵoj - iu kiun mi sekvas afiŝis novan mesaĝon + iu, kiun mi sekvas, afiŝis novan mesaĝon Ĉu vi vere volas forigi la liston %s\? - Aŭdio + Aŭdaĵo Aboni Malneto forigita - Vi ne povas elŝuti pli ol %1$d aŭdovidaĵa kunsendaĵo. - Vi ne povas elŝuti pli ol %1$d aŭdovidaĵaj kunsendaĵoj. + Vi ne povas elŝuti pli ol %1$d aŭdovida kunsendaĵo. + Vi ne povas elŝuti pli ol %1$d aŭdovidaj kunsendaĵoj. Daŭro Nedefinita Malaboni Novaj mesaĝoj Forigi la legosignon - Ĉu forigi ĉi-tiun konversacion\? + Ĉu forigi ĉi tiun konversacion\? Animacii proprajn emoĝiojn - Kaŝi kvantecajn statistikaĵojn sur la profiloj + Kaŝi kvantecajn statistikaĵojn pri la profiloj Forigi konversacion %s ĵus afiŝis - Sciigoj kiam iu kiun vi sekvas afiŝis novan mesaĝon - Sendo de ĉi-tiu mesaĝo malsukcesis! - Kaŝi kvantecajn statistikaĵojn sur la mesaĝoj + Sciigoj, kiam iu, kiun vi sekvas, afiŝis novan mesaĝon + Sendo de ĉi tiu mesaĝo malsukcesis! + Kaŝi kvantecajn statistikaĵojn pri la mesaĝoj Demandi konfirmon antaŭ ol stelumi Bonstato Ŝarĝado de respondaj informoj malsukcesis Kelkaj informoj kiuj povas afekci vian mensan bonstaton estos kaŝitaj. Ĉi tiuj inkluzivas: +\n +\n — Sciigoj pri stelumo/diskonigo/sekvado +\n — Nombro de stelumoj/diskonigoj sur la mesaĝoj +\n — Statistikoj pri mesaĝoj/sekvantoj sur la profiloj \n -\n - Sciigoj pri stelumo/diskonigo/sekvado -\n- Nombro de stelumoj/diskonigoj sur la mesaĝoj -\n- Statistikoj pri mesaĝoj/sekvantoj sur la profiloj -\n -\n Puŝosciigoj ne estos influitaj, sed vi povas kontroli viajn sciigojn preferojn permane. +\n Sciigoj ne estos influitaj, sed vi povas kontroli viajn agordojn pri sciigojn permane. Kontroli la sciigojn Limigi sciigojn pri tempolinio - La mesaĝo al kiu ĉi tiu malneto respondas estis forigita + La mesaĝo, al kiu tiu ĉi 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 ŝlosita, 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 + Konservado de la malneto… + Ensalutu denove al ĉiuj kontoj por ŝalti sciigojn. + La salutpaĝo ne povis esti ŝargita. + Ŝargo de detaloj pri la konto malsukcesis + Ĉu forigi tiun planitan mesaĝon\? + Si vi ensalutas, vi konsentas je la regulo de %s. + Regulo de %s + (Neniu ŝanĝo) + %s (%s) + Ĉiam + Kiam vi uzas plurajn kontojn + Neniam + Montri uzantnomon en ilobreto + Mesaĝolingvo + Por ricevi sciigoj per UnifiedPush, Tusky bezonas taŭgan permeson el Mastodon-servilo. Tio postulas re-ensaluton por ŝanĝi OAuth-rajtoj donitaj al Tusky. Se vi uzas la opcion re-ensaluti ĉi tie aŭ en la agordoj de la konto, viaj malnetoj kaj kaŝmemoroj estos konservitaj. + Vi re-ensalutis en tiu konto por doni sciigo-permeson al Tusky. Vi havas tamen aliajn kontojn, ĉe kiuj vi devas re-sensaluti. Iru al ili, kaj re-ensalutu por ebligi ricevon de sciigoj per UnifiedPush. + %s (🔗 %s) \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index edab5f59..3bd4a5ca 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -9,14 +9,12 @@ Ocurrió un error de autorización no identificado. 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. + ¡La publicación es demasiado larga! No se admite este tipo de archivo. No pudo abrirse el fichero. Se requiere permiso para acceder al almacenamiento. Se requiere permiso para descargar al almacenamiento. - No se pueden adjuntar imágenes y vídeos en el mismo estado. + No se pueden adjuntar imágenes y vídeos en la misma publicación. La subida falló. Error al publicar. Inicio @@ -25,7 +23,7 @@ Federada Mensajes Directos Pestañas - Publicación + Hilo Estados Con respuestas Fijado @@ -49,8 +47,8 @@ Ocultar Nada aquí. Nada por aquí. ¡Arrastra hacia abajo para recargar! - %s impulsó tu toot - %s marcó favorito + %s impulsó tu publicación + %s marcó como favorita tu publicación %s te siguió Reportar @%s ¿Información adicional? @@ -100,7 +98,7 @@ Rechazar Buscar Borradores - Visibilidad del estado + Visibilidad de la publicación Aviso de contenido Teclado de emojis Añadir pestaña @@ -196,15 +194,16 @@ Nuevos seguidores Notificaciones de nuevos seguidores Impulsos - Notificaciones de estados que fueron compartidos + Notificaciones cuando impulsan tus publicaciones Favoritos - Notificaciones de estados que recibieron favorito + Notificaciones de tus estados marcados como favorito %s te mencionó %1$s, %2$s, %3$s y %4$d otros %1$s, %2$s, y %3$s %1$s y %2$s %d nueva interacción + %d nuevas interacciones %d nuevas interacciones Cuenta protegida @@ -251,10 +250,15 @@ Añadir cuenta de Mastodon Listas Listas - Publicando con la cuenta %1$s + Publicar como %1$s Error al añadir leyenda - Describir para invidentes\n(límite de %d caracteres) + Descripción para personas con problemas de visión +\n(Límite de %d caracter) + Descripción para personas con problemas de visión +\n(Límite de %d caracteres) + Descripción para personas con problemas de visión +\n(Límite de %d caracteres) Añadir leyenda Eliminar @@ -299,12 +303,14 @@ No fijar Fijar - <b>%1$s</b> Favorito - <b>%1$s</b> Favoritos + %1$s Favorito + %1$s Favoritos + %1$s Favoritos - %s impulso - %s impulsos + %s Impulso + %s Impulsos + %s Impulsos Impulsado por Marcado como favorito por @@ -313,6 +319,7 @@ %1$s, %2$s y %3$d más máximo de %1$d pestaña alcanzada + máximo de %1$d pestañas alcanzadas máximo de %1$d pestañas alcanzadas Menciones @@ -331,29 +338,34 @@ Añadir cuenta a la lista Eliminar cuenta de la lista 1Favoritos - Seguidores + Seguidores Aplicar Mostrar indicador de bots Votar %d día restante + %d días restantes %d días restante %d hora restante - %d horas restante + %d horas restantes + %d horas restantes %d minuto restante - %d minutos restante + %d minutos restantes + %d minutos restantes %d segundo restante - %d segundos restante + %d segundos restantes + %d segundos restantes %1$s • %2$s %s voto + %s votos %s votos cerrada @@ -383,14 +395,14 @@ 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 Filtro - Componer toot + Escribir publicación Redactar ¿Estás seguro de que quieres eliminar permanentemente todas tus notificaciones\? Acciones para la imagen %s @@ -421,7 +433,7 @@ El reporte será enviado a un moderador de tu servidor. Puedes añadir una explicación de por qué estás reportando esta cuenta a continuación: La cuenta es de otro servidor. ¿Enviar una copia anónima del reporte\? Mostrar filtro de notificaciones - Mostrar siempre toots marcados con avisos de contenido + Mostrar siempre publicaciones marcadas con avisos de contenido Cuentas Error al buscar Añadir encuesta @@ -450,7 +462,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. @@ -466,6 +477,7 @@ Habilitar gesto de deslizar para alternar entre pestañas %s persona + %s personas %s personas Etiquetas @@ -488,20 +500,21 @@ %s recién publicado No puedes cargar más de %1$d archivo multimedia adjunto. + No puedes cargar más de %1$d archivos multimedia adjuntos. No puedes cargar más de %1$d archivos multimedia adjuntos. Esconder las estadísticas cuantitativas de los perfiles Esconder las estadísticas cuantitativas de las publicaciones Revisar Notificaciones Bienestar - Notificaciones cuando alguien al que estoy suscrito publicó un nuevo toot - Nuevos toots - alguien al que estoy suscrito publicó un nuevo toot - Algunas informaciones que podríam afectar tu bienestar van a ser ocultas. Esto incluye: + Notificaciones cuando alguien al que estoy suscrito escribe una publicación + Nuevas publicaciones + alguien al que estoy suscrito hizo una nueva publicación + Se ocultarán algunas informaciones que podrían afectar a tu bienestar. Esto incluye: \n \n- Notificaciones de favoritos, impulsos y seguidores -\n- Conteo de favoritos e impulsos en toots -\n- Estadísticas de seguidores e toots en perfiles +\n- Conteo de favoritos e impulsos en publicaciones +\n- Estadísticas de seguidores y publicaciones en perfiles \n \nLas notificaciones Push no serán afectadas, pero puedes revisar manualmente tus preferencias. El toot al que redactaste una respuesta ha sido eliminado @@ -522,4 +535,54 @@ Darse de baja Eliminar conversación Mostrar diálogo de confirmación antes de marcar como favorito + una publicación con la que interactué se editó + Los archivos de video y audio no pueden pesar más de %s MB. + La imagen no pudo ser editada. + Siempre + Nunca + añadir reacción + alguien se registró + Error al seguir #%s + Error dejando de seguir #%s + Ingreso + Reingresa para activar notificaciones push + %s se registró + %s editó su publicación + Descartar + Detalles + Fallo cargando los detalles de la cuenta + Fallo cargando la página de ingreso. + ¿Eliminar publicación programada\? + Toca o arrastra el círculo para centrar el foco de la imagen, que será visible en las miniaturas. + ¿Guardar este borrador\? (Los adjuntos se subirán de nuevo cuando vuelvas a él.) + Mostrar nombre de usuario en la barra de herramientas + Se unió %1$s + 14 días + 365 días + Inicia sesión de nuevo en todas las cuentas para activar las notificaciones push. + Para poder usar las notificaciones push con UnifiedPush, Tusky necesita permiso para suscribirse a las notificaciones de tu servidor de Mastodon. Es necesario volver a acceder para cambiar los parámetros OAuth concedidos a Tusky. Usar aquí, o en las Preferencias de la Cuenta, la opción de volver a acceder conservarás los borradores y la caché. + Fallo al fijar + Fallo al quitarlo + Cuando hay varias cuentas activas + %s (🔗 %s) + Notificaciones de nuevos usuarios + Ediciones de una publicación + Notificaciones cuando se editan publicaciones con las que has interactuado + 1+ + %s (%s) + Fallo al establecer foco + Establece el foco + Idioma de publicación + 30 días + 60 días + 90 días + 180 días + (Sin cambios) + Escribir publicación + Guardando borrador… + Has vuelto a iniciar sesión en esta cuenta para dar permiso de notificaciones push a Tusky. Sin embargo, aún hay otras cuentas que no tienen este permiso. Cambia a estas cuentas y vuelve a iniciar sesión, una a una, para activar el soporte de notificaciones de UnifiedPush. + Al iniciar sesión aceptas las normas de %s. + Normas de %s + Creación de cuentas + Editar imagen \ No newline at end of file diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 1de2d88b..bfa0da1a 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 5d715500..a2bfd6dd 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -9,8 +9,6 @@ احراز هویت رد شد. دریافت ژتون ورود شکست خورد. فرسته خیلی طولانی است! - پرونده باید کمتر از ۸ مگابایت باشد. - پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد. این گونهٔ پرونده نمی‌تواند بارگذاری شود. این پرونده نتوانست گشوده شود. نیاز به اجازهٔ خواندن رسانه است. @@ -229,9 +227,11 @@ افزودن حساب ماستودون جدید فهرست‌ها فهرست‌ها - در حال فرستادن با حساب %1$s + فرستادن از طرف %1$s شکست در تنظیم عنوان + توصیف برای کم‌بینایان +\n(کران ۱ نویسه) توصیف برای کم‌بینایان \n(کران %d نویسه) @@ -342,7 +342,7 @@ نگارش ۴٫۰ CC-BY نگارش ۴٫۰ CC-BY-SA - %1$s برگزیدن + ۱ برگزیدن %1$s برگزیدن @@ -363,10 +363,10 @@ بدون شرح تقویت شده برگزیده - عمومی - فهرست‌نشده - پی‌گیران - مستقیم + عمومی + فهرست‌نشده + پی‌گیران + مستقیم نظرسنجی با گزینه‌ها: %1$s، %2$s، %3$s، %4$s؛ %5$s نام فهرست برچسب بدون # @@ -429,7 +429,6 @@ این حساب از کارسازی دیگر است. رونوشتی ناشناس از گزارش، به آن‌جا نیز ارسال شود؟ خطا در یافتن فرستهٔ %s قدرت‌گرفته از تاسکی - پرونده‌های صوتی باید کم‌تر از ۴۰م‌ب باشند. نشانک‌ها نشانک نشانک‌ها @@ -544,4 +543,27 @@ رد کردن جزییات ذخیرهٔ پیش‌نویس… + خطا در پی‌گیری #%s + خطا در ناپی‌گیری #%s + حذف این فرستهٔ زمان‌بسته؟ + قواعد %s + با ورودتان، قواعد %s را می‌پذیرید. + %s (%s) + شکست در سنجاق کردن + شکست در برداشتن سنجاق + پرونده‌های صوتی و ویدیویی نمی‌توانند بیش از %sم‌ب باشند. + تصویر نتوانست ویرایش شود. + زبان فرسته + همیشه + هنگام ورود چندین حساب + هرگز + نمایش نام کاربری در نوارابزارها + %s (🔗 %s) + افزودن واکنش + شکست در تنظیم نقطهٔ تمرکز + تنظیم نقطهٔ تمرکز + (بدون تغییر) + شکست در بار کردن جزییات حساب + ضربه زده یا دایره را کشیده تا نقطهٔ کانونی‌ای که همواره باید در بندانگشتی‌ها نمایان باشد را برگزینید. + ذخیرهٔ پیش‌نویس؟ (پیوست‌ها هنگام بازگردانی پیش‌نویس، دوباره بارگذاری خواهند شد) \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index cd3ada40..a9d21407 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,148 @@ 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ähettäminen 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 + Tapahtui 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! + Verkkovirhe! Tarkista yhteytesi ja yritä uudelleen! + Avaa media No. %d + Julkaisun lähettäminen 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. + Listaamaton + Poista tämä keskustelu\? + Lataa median esikatselu + Listaamaton + Näytä aina arkaluonteinen sisältö + Lähettäminen peruutettu + Seuraajat + Aina + Ei koskaan + Buustaukset + Suosikit + Äänestykset + Ilmoitukset päättyneistä äänestyksistä + Muokkaa suodatinta + lisää reaktio + Lähetetty! + Lähetetty! + Vastaus lähetetty onnistuneesti. + Yhdistetään… + Hiljennä @%s\? + Piilota ilmoitukset + Ulkoasu + Näytä buustaukset + Merkitse media aina arkaluontoiseksi + Asetusten synkronointi epäonnistui + Pienin + Pieni + Keskikokoinen + Suuri + Suurin + Jaa linkki postaukseen + Liitteet + Media + lataa lisää + Julkiset aikajanat + Keskustelut + Lisää suodatin + Myöhemmin + Sisältövaroitus: %s + HTTP-välityspalvelin + Käynnistä uudelleen + Tunnistautuminen valitsemasi instanssin kanssa epäonnistui. + Estä @%s\? \ 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 06ddc238..1d06d028 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,13 @@ 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) + ajouter une réaction + %s règles \ No newline at end of file diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index f902d742..cce1246d 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 f74f8370..2480c358 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,16 +275,16 @@ 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ó + Lódáil a thuilleadh Comhráite Cuir scagaire leis 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 294bdc56..2c2727a8 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 + Falaichte o liostaichean + 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,29 @@ 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 + A bheil thu airson am post sgeidealaichte seo a sguabadh às\? + Le clàradh a-steach, bidh tu ag aontachadh ri riaghailtean %s. + riaghailtean %s + cuir freagairt ris + Dh’fhàillig leis a’ phrìneachadh + Dh’fhàillig leis an dì-phrìneachadh + A bheil thu airson a shàbhaladh ’na dhreachd\? (Thèid na ceanglachain a luchdadh suas a-rithist nuair a dh’aisigeas tu an dreuchd.) \ 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 af350478..64deaaf6 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 @@ -260,11 +257,13 @@ Eliminar Escribir descrición - Describe para persoas con problemas de visión + Describe para persoas con deficiencias visuais +\n(límite %d caracter) + Describe para persoas con deficiencias visuais \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 +536,27 @@ 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 + Normas de %s + Ao iniciar sesión aceptas as normas de %s. + engadir reacción + Gardar borrador\? (Os adxuntos serán subidos outra vez cando restablezas o borrador.) + Mostrar identificador na barra ferramentas + Eliminar publicación programada\? + Fallo ao Fixar + Fallo ao Desafixar + Sempre + Cando hai máis dunha conta activa + Nunca \ 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 6d84611f..e1e13313 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 fc74f129..a4ae0b8b 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,25 @@ 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 + reakció hozzáadása + A bejelentkezéssel elfogadod a %s szabályait. + %s szabályai + Elmentsük a vázlatot\? (A mellékleteket újra feltöltjük, amikor a vázlatot visszaállítod.) + Nem sikerült Kitűzni + Nem sikerült a Kitűzés Visszavonása \ No newline at end of file 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 00000000..ea4b938d --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,251 @@ + + + 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 + Gambar dan video tidak dapat disematkan ke dalam post yang sama. + Federasi + Login ulang untuk notifikasi push + Permintaan mengikuti + %s ter-boost + Meluaskan + Tidak ada apapun di sini. Tarik ke bawah untuk menyegarkan! + %s telah meng-boost post mu + %s memfavoritkan post mu + %s mendaftar + %s baru saja memosting + %s mengedit post mereka + Komentar tambahan\? + Balas + Balas Cepat + Boost + Hapus boost + Favorit + Hapus favorit + Hapus bookmark + Lebih + Menyusun + Masuk dengan Mastodon + Keluar + Apakah kamu yakin ingin keluar dari akun %1$s\? + Sembunyikan boost + Tampilkan boost + Domain tersembunyi + Permintaan mengikuti + Tambah pemilihan + Sebut + Sembunyikan media + Buka laci + Hapus dan draf ulang + Bagikan sebagai… + Bagikan URL post kepada… + Visibilitas post + Bagikan post kepada… + Peringatan konten + Nama tampilan + Peringatan konten + Papan ketik Emoji + Tambah Tab + Hashtag + Buka penulis boost + Tampilkan boost + Tampilkan favorit + Menolak + Hashtag + Tautan + Buka media #%d + Buka sebagai %s + Bagikan media kepada… + Pengguna ter-unblock + Instansi yang mana\? + Bio + Tidak ada hasil + Apa itu instansi\? + Unduh + Tarik kembali permintaan mengikuti\? + Sembunyikan keseluruhan domain + Publik: Post untuk linimasa publik + Tak terdaftar: Jangan tampilkan di linimasa publik + Hanya pengikut: Post hanya untuk pengikut + Langsung: Post kepada pengguna yang disebut saja + Pemberitahuan + Pemberitahuan + Peringatan + tambah reaksi + Unfollow akun ini\? + post ku telah difavoritkan + Hapus post ini\? + Hapus dan draf ulang post ini\? + Beritahu saya ketika + disebut + diikuti + post ku telah di-boost + pemilihan telah berakhir + seseorang yang saya langganani menerbitkan sebuah post baru + seseorang mendaftar + \ 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 576ce3ee..f3233bcf 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. @@ -49,7 +47,7 @@ Falin lén Fylgjendabeiðnir Breyta notandasniðinu þínu - Áætluð tíst + Áætlaðar færslur Notkunarleyfi \@%s %s endurbirti @@ -87,8 +85,8 @@ Breyta Eyða Eyða og endurvinna drög - TÍST - TÍST! + BIRTA + BIRTA! Reyna aftur Loka Notandasnið @@ -116,11 +114,11 @@ Samþykkja Hafna Drög - Áætluð tíst + Áætlaðar færslur Sýnileiki færslu Aðvörun vegna efnis Lyklaborð með tjáningartáknum - Tímasetja tíst + Tímasetja færslu Frumstilla Bæta við flipa Tenglar @@ -139,8 +137,8 @@ Deila sem … Sækja myndefni Næ í myndefni - Deila slóð á tíst til… - Deila tísti með… + Deila slóð á færslu til… + Deila færslu með… Deila myndefni með… Sent! Hætt að útiloka notanda @@ -171,8 +169,8 @@ Sækja Afturkalla beiðni um að fylgjast með\? Hætta að fylgjast með þessum aðgangi\? - Eyða þessu tísti\? - Eyða og endurvinna þetta tíst\? + Eyða þessari færslu\? + Eyða og endurvinna þessa færslu\? Ertu alveg algjörlega viss um að þú viljir loka á allt %s\? Þú munt ekki sjá efni frá þessu léni í neinum opinberum tímalínum eða í tilkynningunum þínum. Fylgjendur þínir frá þessu léni verða fjarlægðir. Fela allt lénið Opinbert: Senda á opinberar tímalínur @@ -256,8 +254,8 @@ Villutilkynningar og beiðnir um nýja eiginleika: \n https://git.chinwag.org/chinwag/chinwag-android/issues Notandasnið Tusky - Deila efni úr tísti - Deila tengli á tíst + Deila efni úr færslu + Deila tengli á færslu Myndir Myndskeið Beðið um að fylgja @@ -298,9 +296,11 @@ Leita að fólki sem þú fylgist með Bæta notandaaðgangi á listann Fjarlægja notandaaðganginn af listanum - Sendi með notandaaðgangnum %1$s + Sendi sem %1$s Ekki tókst að setja skýringatexta + Lýstu þessu fyrir sjónskerta +\n(hámark %d stafur) Lýstu þessu fyrir sjónskerta \n(hámark %d stafir) @@ -309,11 +309,11 @@ Læsa notandaaðgangi Krefst þess að þú samþykkir fylgjendur handvirkt Vista drög\? - Sendi tíst… - Villa við að senda tíst - Sendi tíst + Sendi færslu… + Villa við að senda færslu + Sendi færslur Aflýsti sendingu - Afrit af tístinu þínu hefur verið vistað drögunum þínum + Afrit af færslunni hefur verið vistað í drögunum þínum Semja skilaboð Tilvikið þitt %s er ekki með nein sérsniðin tjáningartákn Stíll tjáningartákna @@ -321,7 +321,7 @@ Þú þarft fyrst að ná í þessi táknmyndasett Framkvæmi uppflettingu… Þenja út / Fella saman allar stöðufærslur - Opna tíst + Opna færslu Endurræsing forrits er nauðsynleg Það þarf að endurræsa Tusky til að breytingarnar taki gildi Seinna @@ -346,7 +346,7 @@ Nota algildan tíma Ekki er víst að upplýsingarnar hér að neðan endurspegli notandasniðið að fullu. Opnaðu fullt notandasnið í vafra. Losa - Pin + Festa Endurbirt af Sett í eftirlæti af %1$s @@ -362,10 +362,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 +416,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 @@ -483,10 +482,10 @@ Segja upp áskrift Gerast áskrifandi Hreyfa sérsniðin tjáningartákn - Tístið sem þú gerðir drög að svari við hefur veriið fjarlægt + Færslan sem þú gerðir drög að svari við hefur verið fjarlægð Eyddi drögum Mistókst að hlaða inn svarupplýsingum - Mistókst að senda þetta tíst! + Mistókst að senda þessa færslu! Viðhengi Hljóð Ertu viss um að þú viljir eyða %s listanum\? @@ -537,4 +536,27 @@ 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. + Eyða þessari áætluðu færslu\? + Reglur %s + Með því að skrá þig inn samþykkir þú reglurnar á %s. + bæta við viðbrögðum + Ýttu eða dragðu hringinn til að setja virknistað sem verður ævinlega sýnilegur í smámyndum. + Vista drög\? (Viðhengi verða send inn aftur þegar þú endurheimtir drögin.) + Mistókst að festa + Mistókst að losa + Alltaf + Þegar er skráð inn á mörgum aðgöngum + %s (🔗 %s) + Mistókst að setja virknistað + Setja virknistað + Aldrei + Birta notandanafn á verkfærastikum \ 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 ed445196..cdcf347f 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://git.chinwag.org/chinwag/chinwag-android/issues Profilo di Tusky - 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 Tusky 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 Tusky contiene codice e risorse dai seguenti progetti open source: Licenziata sotto la Licenza Apache (copia sotto) @@ -324,15 +328,17 @@ Contenuto Usa ora assoluta Il profilo dell\'utente mostrato qui sotto potrebbe essere incompleto. Premi per aprire il profilo completo nel browser. - Smetti di fissare + Non fissare in cima 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) \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5af565f8..3791e0fb 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未満にしてください。 その形式のファイルはアップロードできません。 ファイルを開けませんでした。 メディアの読み取り許可が必要です。 @@ -315,11 +313,11 @@ タブは %1$d 個が上限です - 公開 + 公開 - 未収載 + 未収載 - ダイレクト + ダイレクト リスト名 ネットワークエラーが発生しました!接続を確認してもう一度試してください! @@ -394,7 +392,6 @@ 検索に失敗しました 通知フィルターを表示 リセット - 音声ファイルは40MB未満にしてください。 ブックマーク ブックマーク 編集 @@ -402,7 +399,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 64c660ca..a740556e 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 7e1de7cc..a6cab897 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 00000000..8a9db25e --- /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 951ed475..6cfd0405 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 77% rename from app/src/main/res/values-no-rNB/strings.xml rename to app/src/main/res/values-nb-rNO/strings.xml index e93c8eea..adafce9c 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -4,25 +4,23 @@ En nettverksfeil har oppstått! Sjekk tilkoblingen, og prøv igjen! Denne kan ikke være tom. Ugyldig domene - Autentisering feilet. + Kunne ikke autentisere med den instansen. Fant ingen nettleser som kunne brukes. En ukjent autoriseringsfeil oppsto. 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. Trenger tillatelse for å lagre media. - Bilder og videoer kan ikke kobles til samme innlegg. + Bilder og videoer kan ikke legges til samme innlegg. Opplastingen feilet. En feil oppsto under sending av innlegget. Hjem Varsler Lokal - Forent + Føderert Direktemeldinger Faner Tråd @@ -36,28 +34,28 @@ Blokkerte brukere Følgeforespørsler Endre profilen din - Kladder + Utkast Lisenser \@%s - %s boostet + %s delte Sensitivt innhold Media skjult - Klikk for å vise + Trykk for å vise Vis mer Vis mindre Utvid - Kollaps - Her er det ingenting. - Her er det ingenting. Dra ned for å oppdatere! - %s boostet innlegget ditt + Skjul + Ingenting her. + Ingenting her. Dra ned for å oppdatere! + %s delte innlegget ditt %s favoriserte innlegget ditt %s følger deg Rapporter @%s Ytterligere kommentarer\? Hurtigsvar Svar - Boost - Fjern boost + Del + Fjern deling 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 delinger + Vis delte inlegg Rapporter Slett - TOOT - TOOT! + TUT + TUT! Prøv igjen Steng Profil @@ -101,29 +99,29 @@ Aksepter Avvis Søk - Kladder + Utkast Synlighet på innlegg Innholdsadvarsel Emoji-tastatur Legg til fane - Linker - Nevner + Lenker + Nevnelser Stikkord - Åpne toot-forfatter - Vis boosts + Åpne deler + Vis delinger Vis favoritter Stikkord - Nevner - Linker + Nevnelser + Lenker Åpne media #%d Laster ned %1$s - Kopier link + Kopier lenken Åpne som %s Del som … Last ned media Laster ned media - Del innlegg-URL til… - Del innlegg til… + Del tut-URL til… + Del tut til… Del media til… Sendt! Fjernet blokkering av bruker @@ -142,20 +140,20 @@ Overskrift Hva er en instans\? Kobler til… - more! -\n -\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there. -\n -\nAn instance is a single place where your account is hosted, but you can easily communicate with and follow folks on other instances as though you were on the same site. -\n -\nMore info can be found at joinmastodon.org. - Media opplasting er ferdig + Addressen eller domenet til enhver instans kan legges til her, f. eks. mastodon.social, icosahedron.website, social.tchncs.de og mer! +\n +\nHvis du ikke har en konto kan du gi navnet på instansen du vil bli medlem av, og lage en konto der. +\n +\nEn instans er en plass der du har kontoen din, men du kan lett kommunisere med og følge andre personer på andre instanser, som om du var på den samme nettsiden. +\n +\nDu kan finne mer info på joinmastodon.org. + Opplasting av Media er ferdig Laster opp… Last ned Trekk tilbake følgeforespørselen\? - Slutte å følge denne kontoen\? - Slette dette tootet\? - Offentlig: Vis i offentlig tidslinjer + Slutt å følge denne kontoen\? + Slett dette innlegget\? + Offentlig: Vis i offentlige tidslinjer Ikke oppført: Ikke vis i offentlige tidslinjer Bare følgere: Vis bare til følgere Direkte: Vis bare til nevnte brukere @@ -166,21 +164,21 @@ Varsle med vibrasjon Varsle med lys Varsle meg når - nevnt - fulgt - tootene mine blir boostet - tootene mine blir favorisert + jeg blir nevnt + jeg blir fulgt + innleggene mine blir delt + innleggene mine blir favorisert Utseende Apptema Tidslinjer Filtere Nettleser - Bruke Chrome tilpassede faner - Skjul skriv-knappen under scrolling + Bruke Chrome-tilpassede faner + Skjul skriv-knappen ved scrolling Språk Tidslinjefiltrering Faner - Vis boosts + Vis delinger Vis svar Last ned forhåndsvisning av media Proxy @@ -194,12 +192,12 @@ Størrelse på statustekst Nye følgere Varsler om nye følgere - Booster - Varsler når innleggene dine blir boostet + Delte innlegg + Varsler når innleggene dine blir delt Favoritter Varsler når innleggene dine blir favorisert %s nevnte deg - %1$s, %2$s, %3$s og %4$d anre + %1$s, %2$s, %3$s og %4$d andre %1$s, %2$s, og %3$s %1$s og %2$s @@ -213,7 +211,7 @@ \n https://git.chinwag.org/chinwag/chinwag-android/issues Tuskys Mastodon-profil Del inneholdet i innlegget - Del link til innlegget + Del lenke til tuten Bilder Video Forespørsel sendt @@ -235,9 +233,9 @@ Samtaler Legg til filter Endre filter - Slett + Fjern Oppdater - Frase å filtere + Filtrer frase Legg til konto Legg til ny Mastodon-konto Lister @@ -247,21 +245,23 @@ Kunne ikke slette liste Opprett en liste Gi listen nytt navn - Slett listen + Fjern listen Endre listen Søk etter personer du følger Legg til konto i listen - Slett konto fra listen - Standard proxy-personvern - Nye omtaler - Varsler om nye omtaler + Fjern konto fra listen + Standardinstilling for innlegg + Nye nevnelser + Varsler om nye nevnelser Tusky er fri og åpen kildekode. Applikasjonen er lisensiert under GNU General Public License versjon 3. Du kan se lisensen her: https://www.gnu.org/licenses/gpl-3.0.en.html Hjemmeside: \n https://chinwag.org om %dy - Poster med konto %1$s + Poster som %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) @@ -269,32 +269,32 @@ Slett 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 + Lagre utkast\? + 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 utkastene dine Skriv Instansen %s har ingen egendefinerte emojis Emoji-stil Systemstandard Du må laste ned emoji-samlingene før de kan brukes - Gjennomfører oppslag… - Utvid/kollaps alle statuser - Åpne toot - Omstart av applikasjonen er påkrevd - Du må starte Tusky på nytt for at endringene skal bli aktive + Søker… + Utvid/Gjem alle statuser + Åpne tut + Omstart av applikasjonen kreves + Du må starte Tusky på nytt for at endringene blir aktivert Senere Start på nytt - Din enhets standard emoji-samling + Standard-emojis for din enhet Blob-emojis kjent fra Android 4.4–7.1 Mastadons standard emoji-samling Nedlasting feilet Robot %1$s har flyttet til: - Boost til opprinnelig publikum - Fjern boost + Del til opprinnelig publikum + Fjern deling Tusky 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 + Delt %s gang + Delt %s ganger - Boostet av + Delt 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 @@ -340,10 +340,10 @@ Bruk Skriv innlegg Skriv - Vis at konto er en robot + Vis robotindikator Er du sikker på at du vil slette alle varsler\? Slett og skriv på nytt - Vil du slette dette tottet og skrive det på nytt\? + Vil du slette denne tuten og skrive den på nytt\? %1$s • %2$s %s stemme @@ -404,11 +404,11 @@ Skjulte domener Demp %s %s er ikke lenger skjult - Er du sikker på at du vil blokkere hele %s\? Du kommer ikke til å se innhold fra domenet i noen offentlige tidslinjer, eller i varslene dine. Kontoer som følger deg fra domenet vil bli fjernet. + Er du sikker på at du vil blokkere alt fra %s\? Du kommer ikke til å se innhold fra domenet i noen offentlige tidslinjer, eller i varslene dine. Kontoer som følger deg fra dette domenet vil bli fjernet. Skjul hele domenet Vis varselfilter - Hele ordet - Når nøkkelordet kun inneholder bokstaver og tall, vil det bare brukes dersom det stemmer overens med hele ordet + Helt ord + Når nøkkelordet eller frasen kun inneholder bokstaver og tall, vil det bare brukes dersom det stemmer overens med hele ordet Kontoer Klarte ikke å søke Ekspander alltid innlegg markert med innholdsadvarsel @@ -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 @@ -439,11 +439,10 @@ Velg liste Liste Du har ingen planlagte innlegg. - Du har ikke lagret noen kladder. - Lydfiler må være mindre enn 40MB. + Du har ikke lagret noen utkast. Mastodon har et minimums planleggingsinterval på 5 minutter. Vis forhåndsvisning av linker i tidslinjer - Vis bekreftelsesdialog før boosting + Vis bekreftelsesdialog før deling Skru på sveiping for å bytte mellom faner %s person @@ -474,13 +473,13 @@ 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 - Antall favoriseringer og boots på innlegg +\n - Varsler om favorisering, deling og følgere +\n - Antall favoriseringer og delinger av innlegg \n - Antall følgere og innlegg på profiler \n \n Push-varsler vil ikke påvirkes, men du kan se over dine varselinnstillinger manuelt. @@ -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,17 +497,17 @@ Er du sikker på at du vil slette listen %s\? Vedlegg Lyd - Tootet du kladdet et svar til har blitt fjernet - Kladd slettet + Innlegget du hadde opprettet et utkast som svar på har blitt fjernet + Utkast slettet Lasting av svarinformasjon feilet - Sending av toot feilet! + Sending av innlegg feilet! Animer egendefinerte emojis Avslutt abonnementet Abonner Selv om kontoen din ikke er låst, har %1$s administratorer markert disse følgeforespørsler for manuell godkjenning. - Slette denne samtalen\? + Slett denne samtalen\? Slett samtale - Slett bokmerke + Fjern bokmerke Vis bekreftelsesdialog når favoritt skal legges til 30 dager 60 dager @@ -516,27 +515,48 @@ 180 dager 365 dager 14 dager - Komponer toot + Skriv innlegg %s registrerte seg noen registrerte seg Registreringer Varslinger om nye brukere %s redigerte innlegget sitt - et innlegg jeg har hatt en interaksjon med er redigert + et innlegg jeg har interagert med, er redigert Redigerte innlegg - Varslinger når et innlegg du har hatt en interaksjon med er redigert - Innlogging + Varslinger når et innlegg du har interagert med er redigert + Logg inn Klarte ikke å laste innloggingssiden. Logg inn på nytt for pushvarsler Avvis Detaljer Ble med %1$s Logg inn all konti på nytt for å skru på pushvarsler. - For å kunne sende pushvarsler via UnifiedPush trenger Tusky tillatelse til å abonnere på varsler på Mastodon-serveren. Dette krever at du logger inn på nytt. Ved å bruke muligheten til å logge inn på nytt her eller i kontoinstillinger vil alle lokale kladder være tilgjengelig også etter at du har logget inn på nytt. + For å kunne sende pushvarsler via UnifiedPush trenger Tusky tillatelse til å abonnere på varsler på Mastodon-serveren. Dette krever at du logger inn på nytt. Ved å bruke muligheten til å logge inn på nytt her eller i kontoinstillinger vil alle lokale utkast være tilgjengelig også etter at du har logget inn på nytt. Du har logget inn på nytt for å tillate Tusky til å sende pushvarsler, men du har fortsatt andre konti som ikke har fått den nødvendige tillatelsen. Bytt til dem og logg inn på nytt på samme måte for å skru på støtte for pushvarsler via UnifiedPush. - Lagrer kladd… + Lagrer utkast… 1+ 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 + Kunne ikke slutte å følge #%s + %s (%s) + (Ingen endring) + Innleggspråk + %s (🔗 %s) + Sett fokuspunkt + Klarte ikke å sette 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. + Lagre utkast\? (Vedlegg vil bli lastet opp igjen når du fortsetter å jobbe på utkastet.) + Klarte ikke å feste + Klarte ikke å løsne + Legg til reaksjon \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index bb956f01..3d7bb5e6 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 @@ -234,8 +232,8 @@ Foutmeldingen & nieuwe functies aanvragen:\n https://git.chinwag.org/chinwag/chinwag-android/issues Tusky\'s profiel - Deel de inhoud van de toot - Deel de link van de toot + Inhoud van bericht delen + Link van het bericht delen Afbeeldingen Video Volgverzoek verzonden @@ -259,29 +257,31 @@ 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 Tusky herstarten om deze veranderingen te kunnen doorvoeren Later @@ -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 9a7b5e2d..0809c197 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 ed07e68c..3e0849cd 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 d01f4590..f1aaf677 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 e7d56559..7e1012da 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,33 @@ 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 + adicionar reação \ 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 e7a76456..8518bfe5 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 b2f03446..2fb603f0 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 7ba70498..14086536 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 e06555c7..be52a240 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 e24f55ac..9140040f 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 0d446fd3..f0392d68 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -9,23 +9,21 @@ Ett oidentifierat behörighetsfel inträffade. 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. + Inlägget är för långt! Den typen av fil kan inte laddas upp. Den filen kunde inte öppnas. Behörighet att läsa media krävs. Behörighet att spara media krävs. - Bilder och videoklipp kan inte båda bifogas i samma status. + Bilder och videoklipp kan inte båda bifogas i samma inlägg. Uppladdningen misslyckades. - Kunde inte skicka toot. + Kunde inte posta inlägg. Hem Aviseringar Lokalt Federerat Direkta meddelanden Flikar - Toot + Tråd Inlägg Med svar Fastnålade @@ -49,8 +47,8 @@ Dölj Ingenting här. Inget här. Dra ner för att uppdatera! - %s knuffade din toot - %s favoriserade din toot + %s puffade ditt inlägg + %s favoriserade ditt inlägg %s följer dig Rapportera @%s Ytterligare kommentarer? @@ -102,7 +100,7 @@ Avvisa Sök Utkast - Toot synlighet + Inläggssynlighet Innehållsvarning Emoji-tangentbord Lägg till flik @@ -210,14 +208,15 @@ Nya följare Aviseringar på nya följare Knuffar - Aviseringar när dina toots blir knuffade + Aviseringar när dina inlägg blir puffade Favoriter - Aviseringar när dina toots blir markerade som favoriter + Aviseringar när dina inlägg blir favoritmarkerade %s omnämnde dig %1$s, %2$s, %3$s och %4$d andra %1$s, %2$s, och %3$s %1$s och %2$s + %d ny interaktion %d nya interaktioner Låst konto @@ -279,10 +278,13 @@ Sök efter personer du följer Lägg till konto i listan Ta bort kontot från listan - Inlägg med kontot %1$s + Publicerar som %1$s Misslyckades med att ange bildtext - Beskriv för synskadade\n(%d teckengräns) + Beskriv för synskadade +\n(max %d tecken) + Beskriv för synskadade +\n(max %d tecken) Ange bildtext Ta bort @@ -340,10 +342,10 @@ %1$s och %2$s %1$s, %2$s och %3$d mer - max antal flikar %1$d uppnådd + max antal flikar %1$d uppnådd + max antal flikar %1$d uppnådda - Media: %s - + Media: %s Innehållsvarning: %s Ingen beskrivning @@ -351,19 +353,19 @@ Favoriserad - Publik + Publik - Olistad + Olistad - Följare + Följare - Direkt + Direkt Listnamn Ladda ned media Laddar ned media Hashtag utan # - Skriv toot + Skriv inlägg Skriv Rensa Filtrera @@ -424,7 +426,7 @@ Visa notifikationsfilter Helt ord När nyckelordet eller frasen enbart är alfanumerisk, appliceras den om den matchar hela ordet - Expandera alltid toots med innehållsvarningar + Expandera alltid inlägg med innehållsvarningar Konton Sökning misslyckades Skapa en omröstning @@ -454,7 +456,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 @@ -488,9 +489,9 @@ Din privata notering om detta kontot Det finns inga meddelanden. Meddelanden - Aviseringar när någon du följer skrivit en ny toot - Nya toots - någon som jag följer har skrivit en ny toot + Aviseringar när någon du följer skrivit ett nytt inlägg + Nya inlägg + någon jag följer har skrivit ett nytt inlägg %s skrev precis Dölj kvantitativ information på profiler Dölj kvantitativ information på inlägg @@ -498,10 +499,81 @@ Ändra aviseringar Information som kan påverka ditt välmående kommer att döljas. Detta inkluderar: \n -\n- Favorisering/Knuff/Följaraviseringar -\n- Favorisering/Antal knuffar -\n- Följare/Inlägg på profiler +\n- Favoritmarkering-/Knuff-/Följaraviseringar +\n- Favoritmarkering/Antal knuffar på inlägg +\n- Följare/Inläggsstatistik på profiler \n \nPush-aviseringar påverkas inte, men du ändra dina aviseringinställningar manuellt. Välmående + + Du kan inte ladda upp fler än %1$d mediebilaga. + Du kan inte ladda upp fler än %1$d mediebilagor. + + Radera detta schemalagda inlägg\? + Genom att logga in accepterar du reglerna på %s. + %s regler + Kunde inte avfölja #%s + Radera denna konversation\? + Alltid + För att använda pushnotiser via UnifiedPush behöver Tusky din tillåtelse att prenumerera på notiser på din Mastodon-server. Detta kräver att du loggar in igen för att ändra vilka OAuth-scopes Tusky har tillgång till. Genom använda alternativet logga in igen här eller i Kontoinställningarna behåller du alla dina lokala utkast och data i cache. + Tryck eller dra cirkeln för att välja fokuspunkten som alltid kommer synas i miniatyrbilder. + Varaktighet + Oändligt + Du har loggat in igen på ditt konto för att ge Tusky tillgång till push-prenumeration. Dock har du andra konton som inte har migrerats såhär ännu. Växla till dem och logga in igen för att aktivera stöd för UnifiedPush-notiser. + Sluta prenumerera + Inläggsspråk + %s (🔗 %s) + När flera konton är inloggade + Aldrig + Registreringar + Notiser om nya användare + Inläggsredigeringar + Notiser när inlägg du interagerat med redigerats + %s (%s) + Redigera bild + 14 dagar + 30 dagar + 60 dagar + 90 dagar + (Ingen ändring) + Visa användarnamn i verktygsrader + Visa bekräftelsedialog före favoritmarkering + Vill du verkligen radera listan %s\? + Det här inlägget kunde inte skickas! + Kunde inte ladda information om svar + Utkast raderat + Inlägget du skrev ett utkast till svar på har raderats + Även om ditt konto inte är låst så tänker administratörerna på %1$s att du kanske ändå vill granska följförfrågan från dessa konton manuellt. + Prenumerera + Skriv inlägg + Gick med i %1$s + Sparar utkast… + Logga in igen på alla konton för att tillåta pushnotiser. + Video- och ljudfiler kan inte överskrida %s MB i storlek. + Bilden kunde inte redigeras. + 365 dagar + 180 dagar + Kunde inte sätta fokuspunkt + Sätt fokuspunkt + Kunde inte följa #%s + %s registrerade sig + %s redigerade sitt inlägg + Ta bort bokmärke + Radera konversation + någon registrerade sig + ett inlägg jag interagerat med har redigerats + Animera skräddarsydda emojis + Ljud + Bilagor + 1+ + Logga in igen för pushnotiser + Avvisa + Detaljer + Kunde inte ladda kontodetaljer + Logga in + Kunde inte ladda inloggningssidan. + Spara utkast\? (Bilagor kommer att laddas upp igen när du återställer utkastet.) + Kunde inte fästa + Kunde inte lossa + lägg till reaktion \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index dba2311b..3fb624b5 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 - பின்பற்றுபவர்கள் - பட்டியலிடப்படாதவர்களுக்கு - அனைவருக்கும் + பின்பற்றுபவர்கள் + பட்டியலிடப்படாதவர்களுக்கு + அனைவருக்கும் எழுது \ No newline at end of file diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index beb1a3e8..f3c310ac 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 41029173..022bf7a2 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 1a970944..41f27aa1 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 медіавкладення. @@ -468,14 +471,14 @@ Приховати кількісну статистику дописів Обмеження сповіщень стрічки Переглянути сповіщення - Ваша особиста примітка щодо цього облікового запису + Ваша особиста примітка про цей обліковий запис Добробут Сховати заголовок верхньої панелі інструментів Запитувати підтвердження перед просуванням Показувати попередній перегляд посилань у стрічках Найкоротший час планування 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,25 @@ 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 fba2310e..83a16653 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,25 +181,25 @@ 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 Ghim Trả lời Tút - Tút + Nội dung 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,25 @@ 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 + Lưu bản nháp\? (Bạn sẽ cần tải lên lại file đính kèm) + Không thể bỏ ghim + Không thể ghim + biểu cảm \ 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 722543ca..3f1eac14 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -10,15 +10,13 @@ 授权被拒绝。 未能获取登录令牌。 嘟文太长了! - 文件大小限制为 8MB。 - 视频文件大小限制为 40MB。 无法上传此类型的文件。 - 打不开此文件。 + 此文件无法打开。 需要授予 Tusky 读取媒体文件的权限。 需要授予 Tusky 存储媒体的权限。 无法在一篇嘟文中同时插入视频和图片。 上传失败。 - 嘟文发送时出错。 + 嘟文发送时发生出错。 主页 通知 本站时间轴 @@ -27,11 +25,11 @@ 标签页 嘟文 嘟文 - 有回复 + 嘟文和回复 已置顶 正在关注 关注者 - 喜欢 + 收藏 被隐藏的用户 被屏蔽的用户 关注请求 @@ -53,7 +51,7 @@ %s 喜欢了你的嘟文 %s 关注了你 举报 @%s - 是否有更多信息需报告? + 报告更多信息? 快速回复 回复 转嘟 @@ -287,11 +285,11 @@ 搜索已关注的用户 添加用户到列表 从列表中移除用户 - 以 %1$s 发布嘟文 + 以 %1$s 身份发布嘟文 设置图片标题失败 为视觉障碍用户提供的描述 -\n(限制 %d 字) +\n(限制 %d 字符) 设置图片标题 移除 @@ -355,14 +353,14 @@ 没有描述信息 被转嘟 被喜欢 - + 公开 - + 不公开 - 仅关注者 - + 仅关注者 + 私信 列表名 @@ -397,9 +395,8 @@ 剩余 %d 秒 重置 - 音频文件大小限制为 40M。 书签 - 隐藏的域名 + 被隐藏的域名 定时嘟文 书签 编辑 @@ -512,7 +509,7 @@ 新嘟文 显示动态自定义Emoji 关注的人发布了新嘟文 - %s 发送了新嘟文 + %s 刚刚发送了新嘟文 即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。 删除此对话吗? 删除对话 @@ -547,4 +544,25 @@ 编辑图片 无法编辑图片。 加载账户详情失败 + 音视频文件大小不能超出 %s MB。 + 关注 #%s 出错 + 取关 #%s 出错 + %s (%s) + (无更改) + 帖子语言 + %s (🔗 %s) + 设置焦点失败 + 设置焦点 + 轻按或拖动圆圈选择始终在缩略图中可见的焦点。 + 登录多个账户时 + 在工具栏中显示用户名 + 始终 + 从不 + 删除这条定时嘟文吗? + 登录即表示您同意 %s 的规定。 + %s 的规定 + 保存草稿?(当您恢复草稿时附件将被再次上传。) + 固定失败 + 取消固定失败 + 添加反应 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 26863f15..4812448d 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。 無法上傳此類型的檔案。 此檔案無法開啟。 需要授予 Tusky 讀取媒體檔案的權限。 @@ -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 caceb922..fc144a5e 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 無法上傳此類型的檔案 此檔案無法開啟 需要授予 Tusky 讀取媒體檔案的權限 @@ -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 ab16016a..3122a70a 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 无法上传此类型的文件 此文件无法打开 需要授予 Tusky 读取媒体文件的权限 @@ -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 b40dc573..6211847d 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。 無法上傳此類型的檔案。 此檔案無法開啟。 需要授予 Tusky 讀取媒體檔案的權限。 @@ -289,7 +287,8 @@ 以 %1$s 發嘟文 設定圖片標題失敗 - 為視覺障礙用戶提供的描述\n(限制 %d 字) + 為視覺障礙用戶提供的描述 +\n(限制 %d 字) 設定圖片標題 移除 @@ -361,16 +360,16 @@ 被轉嘟 被最愛 - + 公開 - + 不公開 - + 僅關注者 - + 私信 列表名 @@ -435,7 +434,6 @@ 編輯 編輯 書籤 - 音檔必需小於40MB。 隱藏個人頁面中的狀態數量資訊 隱藏貼文上的狀態數量資訊 限制時間軸通知 @@ -524,4 +522,59 @@ 帳號 登入 無法載入登入頁面。 + 確定要刪除這則排程嘟文嗎? + 登入既代表您已同意 %s 的規定。 + %s 的規定 + 確認要刪除此對話嗎? + %s (%s) + 輕按或拖動圓圈來選擇總是在縮圖中可視的關注點。 + 嘟文語言 + %s (🔗 %s) + 是否要儲存草稿?(當你重開草稿時附檔將會被再次上傳。) + 編寫嘟文 + 釘選失敗 + 取消釘選失敗 + 在工具列顯示使用者名稱 + 標註為喜歡前顯示確認對話框 + 加入自 %1$s + 總是 + 登入多個帳號時 + 從不 + 註冊 + 新使用者通知 + 嘟文編輯 + 當你互動過的嘟文被編輯時發出通知 + 雖然您的帳號未上鎖,管理者 %1$s 認為您或許需要手動處理來自這些帳號的追蹤請求。 + 訂閱 + 取消訂閱 + 正在儲存草稿… + 重新登入所有帳號以啟用推播功能。 + 設置關注點失敗 + 設置關注點 + 重新登入以啟用推播功能 + 影片和音訊檔案大小不能超過 %s MB。 + 1+ + 添加反應 + 有人進行了註冊 + 編輯圖片 + 30 天 + 60 天 + 90 天 + 180 天 + 365 天 + (無更改) + 追蹤 #%s 時發生錯誤 + 取消追蹤 #%s 時發生錯誤 + 移除書籤 + 刪除對話 + 撤銷 + 詳情 + 我互動過的嘟文被編輯了 + 14 天 + 為了透過 UnifiedPush使用推播功能,Tusky 需要獲得訂閱您 Mastodon 服務器上的通知之權限。這會需要重新登入才能更改授予 Tusky 的 OAuth 範疇。在此頁面或帳戶設定頁面中使用重新登入選項將會保留您所有的本機草稿和快取。 + 您已重新登入當前帳號並授予 Tusky 推送訂閱的權限。 然而,您仍擁有其他帳號未以此種方式遷移。 請切換到該帳號,並且逐一重新登入,以啟用 UnifiedPush 的通知支援。 + 加載賬戶詳情失敗 + %s 已註冊 + %s 編輯了他們的嘟文 + 這張圖片不能編輯。 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 4bf215b0..c595e74c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -48,4 +48,8 @@ #cecec1 #f0f0ec #f2f2ef + + + #09497b + #39acff diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5ec69307..ea2e744b 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 86dfb26a..9619f568 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -90,7 +90,7 @@ cs cy de - en-gb + en-GB en eo es @@ -103,7 +103,7 @@ it hu nl - no-nb + nb-NO oc pl pt-BR @@ -118,8 +118,8 @@ uk ar ckb - bn-bd - bn-in + bn-BD + bn-IN fa hi sa @@ -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 @@ -190,6 +196,8 @@ 31536000 + <b>%1$d%%</b> + @string/duration_indefinite @string/duration_5_min @@ -212,5 +220,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 c97fbb3d..2a2bd224 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 03f076a9..4bc31747 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 Login Home @@ -152,6 +153,7 @@ Show favorites Dismiss Details + add reaction Hashtags Mentions @@ -292,6 +294,10 @@ Large Largest + Always + When multiple accounts logged in + Never + New Mentions Notifications about new mentions New Followers @@ -382,6 +388,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 @@ -399,18 +406,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 @@ -458,6 +469,8 @@ Unpin Pin + Failed to Pin + Failed to Unpin <b>%1$s</b> Favorite @@ -498,21 +511,22 @@ Bookmarked - + Public - + Unlisted - + Followers - + Direct Poll with choices: %1$s, %2$s, %3$s, %4$s; %5$s + Post language List name @@ -601,6 +615,7 @@ 90 days 180 days 365 days + (No change) Add choice Multiple choices Choice %d @@ -611,6 +626,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 @@ -655,6 +671,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/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..3c2bb007 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 503a0317..4c777999 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -88,7 +88,8 @@ class BottomSheetActivityTest { pinned = false, muted = false, poll = null, - card = null + card = null, + language = 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 ef863560..403f25ed 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,43 @@ 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) + } + + @Test + fun modernLanguageCodeIsUsed() { + // https://github.com/tuskyapp/Tusky/issues/2903 + // "ji" was deprecated in favor of "yi" + composeOptions = ComposeActivity.ComposeOptions(language = "ji") + setupActivity() + assertEquals("yi", activity.selectedLanguage) + } + + @Test + fun unknownLanguageGivenInComposeOptionsIsRespected() { + val language = "zzz" + 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 +490,15 @@ 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", + 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 521f01d6..b8ca181e 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 = "", @@ -208,7 +303,8 @@ class FilterTest { ownVotes = null ) } else null, - card = null + card = null, + language = 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 00000000..14142455 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -0,0 +1,130 @@ +package com.keylesspalace.tusky + +import android.app.Activity +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +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.entity.Account +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.TimelineAccount +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.doReturn +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() + activity.eventHub = EventHub() + activity.accountManager = mock { + on { activeAccount } doReturn accountEntity + } + activity.mastodonApi = mock { + onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) + onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList()) + } + 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 21340560..268ae5d3 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/TuskyApplication.kt b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt index 9598f2c1..a9b06631 100644 --- a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,9 +16,6 @@ package com.keylesspalace.tusky import android.app.Application -import android.content.Context -import android.content.res.Configuration -import com.keylesspalace.tusky.util.LocaleManager import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper @@ -29,19 +26,4 @@ class TuskyApplication : Application() { super.onCreate() EmojiPackHelper.init(this, DefaultEmojiPackList.get(this)) } - - override fun attachBaseContext(base: Context) { - localeManager = LocaleManager(base) - super.attachBaseContext(localeManager.setLocale(base)) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - localeManager.setLocale(this) - } - - companion object { - @JvmStatic - lateinit var localeManager: LocaleManager - } } 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 c117cf59..927495cf 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 8781f6d9..ad1d4ae6 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(), @@ -43,14 +51,36 @@ fun mockStatus(id: String = "100") = Status( pinned = false, muted = false, poll = null, - card = null + card = null, + language = 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 00000000..e1d690a1 --- /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 620f7340..a3cbe4e3 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, ) 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 00000000..f217a18b --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt @@ -0,0 +1,101 @@ +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, + ) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt index 57f3bed4..435cbbc8 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt @@ -27,11 +27,11 @@ class AbsoluteTimeFormatterTest { @Test fun `same year formatting`() { val nextDay = Date.from(Instant.parse("2022-04-12T00:10:00.00Z")) - assertEquals("04-12 00:10", formatter.format(nextDay, true, now)) - assertEquals("04-12 00:10", formatter.format(nextDay, false, now)) + assertEquals("12 Apr, 00:10", formatter.format(nextDay, true, now)) + assertEquals("12 Apr, 00:10", formatter.format(nextDay, false, now)) val endOfYear = Date.from(Instant.parse("2022-12-31T23:59:00.00Z")) - assertEquals("12-31 23:59", formatter.format(endOfYear, true, now)) - assertEquals("12-31 23:59", formatter.format(endOfYear, false, now)) + assertEquals("31 Dec, 23:59", formatter.format(endOfYear, true, now)) + assertEquals("31 Dec, 23:59", formatter.format(endOfYear, false, now)) } @Test 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 4a2cdc53..6264d86b 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,155 @@ 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) + val oldContent = content.toString() + Assert.assertEquals( + context.getString(R.string.url_domain_notifier, displayedContent, maliciousDomain), + markupHiddenUrls(context, content).toString() + ) + Assert.assertEquals(oldContent, 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://$domain/"), 0) + .append(domain, URLSpan("https://$domain"), 0) + .append(domain, URLSpan("https://www.$domain"), 0) + .append("www.$domain", URLSpan("https://$domain"), 0) + .append("www.$domain", URLSpan("https://$domain/"), 0) + .append("$domain/", URLSpan("https://$domain/"), 0) + .append("$domain/", URLSpan("https://$domain"), 0) + .append("$domain/", URLSpan("https://www.$domain"), 0) + + val markedUpContent = markupHiddenUrls(context, content) + Assert.assertFalse(markedUpContent.contains("🔗")) + } + + @Test + fun spanEndsWithUrlIsNotMarkedUp() { + val content = SpannableStringBuilder() + .append("Some Place: some.place", URLSpan("https://some.place"), 0) + .append("Some Place: some.place/", URLSpan("https://some.place/"), 0) + .append("Some Place - https://some.place", URLSpan("https://some.place"), 0) + .append("Some Place | https://some.place/", URLSpan("https://some.place/"), 0) + .append("Some Place https://some.place/path", URLSpan("https://some.place/path"), 0) + + val markedUpContent = markupHiddenUrls(context, content) + Assert.assertFalse(markedUpContent.contains("🔗")) + } + + @Test + fun spanEndsWithFraudulentUrlIsMarkedUp() { + val content = SpannableStringBuilder() + .append("Another Place: another.place", URLSpan("https://some.place"), 0) + .append("Another Place: another.place/", URLSpan("https://some.place/"), 0) + .append("Another Place - https://another.place", URLSpan("https://some.place"), 0) + .append("Another Place | https://another.place/", URLSpan("https://some.place/"), 0) + .append("Another Place https://another.place/path", URLSpan("https://some.place/path"), 0) + + val markedUpContent = markupHiddenUrls(context, content) + val asserts = listOf( + "Another Place: another.place", + "Another Place: another.place/", + "Another Place - https://another.place", + "Another Place | https://another.place/", + "Another Place https://another.place/path", + ) + asserts.forEach { + Assert.assertTrue(markedUpContent.contains(context.getString(R.string.url_domain_notifier, it, "some.place"))) + } + } + + @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/assets/splash.xcf b/assets/splash.xcf deleted file mode 100644 index 1a796141..00000000 Binary files a/assets/splash.xcf and /dev/null differ diff --git a/assets/tusky_banner.xcf b/assets/tusky_banner.xcf index d0372695..5ab43ef3 100644 Binary files a/assets/tusky_banner.xcf and b/assets/tusky_banner.xcf differ diff --git a/assets/tusky_logo_borderless.png b/assets/tusky_logo_borderless.png deleted file mode 100644 index f921034d..00000000 Binary files a/assets/tusky_logo_borderless.png and /dev/null differ diff --git a/build.gradle b/build.gradle index 725ab8da..fe71eb07 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/cs/changelogs/58.txt b/fastlane/metadata/android/cs/changelogs/58.txt new file mode 100644 index 00000000..0822c58d --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/58.txt @@ -0,0 +1,13 @@ +Tusky v6.0 + +- Filtry časové osy jsme přesunuli do Předvoleb účtu a budou se synchronizovat se serverem. +- Na hlavním stránce můžete mít vlastní hashtag jako kartu +- Seznamy lze upravovat +- Zabezpečení: odstraněna podpora TLS 1.0 a TLS 1.1 a přidána podpora TLS 1.3 v systému Android 6+. +- Vlastní emotikony jsou nabízeny při psaní +- Nové nastavení motivu "následovat systémový motiv" +- Vylepšená přístupnost časové osy +- Tusky teď ignoruje neznámá oznámení a už nepadá +- Nové nastavení: Nyní můžete v aplikaci Tusky nastavit jiný než systímový jazyk. +- Nové překlady: čeština (!) a esperanto +- Mnoho dalších vylepšení a oprav diff --git a/fastlane/metadata/android/cs/changelogs/70.txt b/fastlane/metadata/android/cs/changelogs/70.txt new file mode 100644 index 00000000..ac9aef10 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/70.txt @@ -0,0 +1,8 @@ +Tusky v10.0 + +- záložky na statusy a seznam záložek. +- plánování tootů: Čas, který vyberete, musí být alespoň 5 minut v budoucnosti. +- seznamy na hlavní obrazovce. +- odesílání zvukových příloh. + +A spousta dalších drobných vylepšení a oprav chyb! diff --git a/fastlane/metadata/android/cs/changelogs/72.txt b/fastlane/metadata/android/cs/changelogs/72.txt new file mode 100644 index 00000000..22da5ff9 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/72.txt @@ -0,0 +1,11 @@ +Tusky v11.0 + +- ipozornění na nové žádosti o sledování, když je váš účet uzamčen +- nové funkce, které lze zapínat v předvolebách: + - swajpování mezi kartami + - potvrzení boostu + - zobrazení náhledů odkazů v časových osách +- konverzace lze ztlumit +- Výsledky ankety se nyní budou počítat na základě počtu hlasujících, a ne na základě celkového počtu hlasů, což usnadňuje přehlednost anket s více možnostmi volby. +- oprava mnoha chyb, z nichž většina se týká psaní tootů +- vylepšené překlady diff --git a/fastlane/metadata/android/cs/changelogs/74.txt b/fastlane/metadata/android/cs/changelogs/74.txt new file mode 100644 index 00000000..c62a7e47 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- Vylepšená hlavní obrazovka - karty lze nyní přesunout do spodní části. +- Při ztlumení uživatele se nyní můžete také rozhodnout, zda chcete ztlumit jeho oznámení +- Nyní můžete sledovat libovolný počet hashtagů na jedné kartě hashtagů +- Vylepšený způsob zobrazování popisů médií, takže funguje i u superdlouhých popisů + +Úplný seznam změn: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/cs/changelogs/77.txt b/fastlane/metadata/android/cs/changelogs/77.txt new file mode 100644 index 00000000..54b71b9f --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- podpora poznámek k profilům (Mastodon 3.2.0) +- podpora oznámení pro správce (Mastodon 3.1.0) + +- avatar vybraného účtu se nyní zobrazuje na hlavním panelu nástrojů +- kliknutím na zobrazené jméno na časové ose se nyní otevře profilová stránka daného uživatele + +- mnoho oprav chyb a drobných vylepšení +- vylepšené překlady diff --git a/fastlane/metadata/android/cs/changelogs/80.txt b/fastlane/metadata/android/cs/changelogs/80.txt new file mode 100644 index 00000000..d7c8c10a --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/80.txt @@ -0,0 +1,8 @@ +Tusky v14.0 + +- upozornění na příspěvky sledovaného uživatele - klikněte na ikonu zvonku na jeho profilu! (funkce Mastodon 3.3.0) +- redesign funkce návrhu. Teď je rychlejší, uživatelsky přívětivější a méně chybová. +- Byl přidán nový zen režim, který umožňuje omezit některé funkce Tusky. +- Tusky nyní umí animovat vlastní emotikony. + +Úplný seznam změn: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/cs/changelogs/82.txt b/fastlane/metadata/android/cs/changelogs/82.txt new file mode 100644 index 00000000..29dcb1c6 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Žádosti o sledování se vždy zobrazují v hlavní nabídce. +- Výběr času pro naplánování příspěvku má teď design odpovídající zbytku aplikace. +Úplný seznam změn: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/cs/changelogs/83.txt b/fastlane/metadata/android/cs/changelogs/83.txt new file mode 100644 index 00000000..ae0ac524 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Toto vydání opravuje pád při popisování obrázků diff --git a/fastlane/metadata/android/cs/changelogs/87.txt b/fastlane/metadata/android/cs/changelogs/87.txt new file mode 100644 index 00000000..746d196b --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- Logika načítání časové osy byla kompletně přepsána, aby byla rychlejší, méně chybová a jednodušší na údržbu. +- Tusky nyní umí animovat vlastní emotikony ve formátu APNG a Animated WebP. +- mnoho opravených chyb +- podpora systému Android 11 +- nové překlady: skotská gaelština, galicijština, ukrajinština. +- vylepšené překlady diff --git a/fastlane/metadata/android/cs/changelogs/89.txt b/fastlane/metadata/android/cs/changelogs/89.txt new file mode 100644 index 00000000..f06d8404 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Otevřít jako..." je nyní k dispozici také v nabídce profilů účtů při použití více účtů. +- Přihlášení je nyní zpracováváno ve webovém zobrazení v rámci aplikace +- podpora systému Android 12 +- podpora nového API pro konfiguraci instancí Mastodon +- a mnoho dalších drobných oprav a vylepšení diff --git a/fastlane/metadata/android/cs/changelogs/91.txt b/fastlane/metadata/android/cs/changelogs/91.txt new file mode 100644 index 00000000..ab56d3d8 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- podpora nových typů oznámení (Mastodon 3.5) +- Odznak bota nyní vypadá lépe a přizpůsobuje se zvolenému tématu. +- V zobrazení detailu příspěvku lze nyní vybrat text +- Opraveno mnoho chyb, včetně jedné, která znemožňovala přihlášení v systému Android 6 a nižším. diff --git a/fastlane/metadata/android/cs/changelogs/94.txt b/fastlane/metadata/android/cs/changelogs/94.txt new file mode 100644 index 00000000..4808320b --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- podpora Unified Push: Pro aktivaci podpory se musíte znovu přihlásit ke svým účtům. +- Počet odpovědí na příspěvek se nyní zobrazuje v časových osách. +- Obrázky lze nyní při vytváření příspěvku oříznout. +- U profilů se nyní zobrazuje datum jejich vytvoření. +- Při prohlížení seznamu se nyní na panelu nástrojů zobrazuje jeho název. +- Mnoho opravených chyb +- Vylepšení překladů diff --git a/fastlane/metadata/android/cs/changelogs/97.txt b/fastlane/metadata/android/cs/changelogs/97.txt new file mode 100644 index 00000000..c7325957 --- /dev/null +++ b/fastlane/metadata/android/cs/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Nová ikona aplikace od Dzuk https://dzuk.zone/ +- Nyní můžete sledovat hashtagy. Klikněte na hashtag a poté na ikonu v panelu nástrojů. +- podpora systému Android 13 +- Nová rozbalovací nabídka v při psaní příspěvku, která umožňuje nastavit jazyk příspěvku. +- Karta médií v profilech nyní respektuje citlivá média a načítá se plynuleji. +- Před odesláním příspěvku je nyní možné nastavit bod střed zvětšení obrázku. +- nová možnost zobrazení celého uživatelského jména v panelu nástrojů diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index a9c4b267..6333e4f1 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -4,9 +4,9 @@ Chinwag Social je lehký klient pro Mastodon, svobodný a otevřený server pro • Implementována většina API Mastodonu • Podpora více účtů • Tmavý a světlý motiv s možností automatického přepínání podle denní doby -• Koncepty – komponujte tooty a uložte je na později +• Koncepty – pište tooty a uložte je na později • Vyberte si mezi různými styly emoji • Optimalizováno pro obrazovky všech velikostí -• Zcela otevřený kód – žádný nesvobodný provázaný software jako služby Google +• Zcela otevřený kód – žádný nesvobodný uzavřený software jako služby Google Chcete-li o Mastodonu vědět více, navštivte https://joinmastodon.org/ diff --git a/fastlane/metadata/android/cy/title.txt b/fastlane/metadata/android/cy/title.txt new file mode 100644 index 00000000..0238ffc0 --- /dev/null +++ b/fastlane/metadata/android/cy/title.txt @@ -0,0 +1 @@ +Tusky diff --git a/fastlane/metadata/android/de/changelogs/58.txt b/fastlane/metadata/android/de/changelogs/58.txt index e16973f9..9db5b778 100644 --- a/fastlane/metadata/android/de/changelogs/58.txt +++ b/fastlane/metadata/android/de/changelogs/58.txt @@ -1,11 +1,13 @@ Tusky v6.0 -- Timelinefilter wurden in die Kontoeinstellungen verschoben und werden mit dem Server synchronisiert -- Hashtags können jetzt als eigene Tabs hinzugefügt werden -- Listen können jetzt bearbeitet werden -- Sicherheit: TLS 1.0 und TLS 1.1 entfernt, Unterstützung für TLS 1.3 auf Android 6+ hinzugefügt -- Automatische Vorschläge von Emojis beim Tippen -- "Systemthema verwenden" hinzugefügt +- Timeline-Filter in Kontoeinstellungen verschoben; werden mit dem Server synchronisiert +- Eigenes Tab für Hashtags +- Listen sind nun bearbeitbar +- TLS 1.0 und 1.1 entfernt, 1.3 für Android 6+ hinzugefügt +- Automatische Emoji-Vorschläge beim Tippen +- „Systemthema verwenden“ hinzugefügt - Verbesserte Barrierefreiheit -- Eine Sprache kann jetzt in der App gesetzt werden +- Tusky ignoriert nun unbekannte Benachrichtigungen +- Neue Einstellung: Individuelle App-Sprache +- Neue Sprachen: Tschechisch und Esperanto - Fehlerkorrekturen diff --git a/fastlane/metadata/android/de/changelogs/80.txt b/fastlane/metadata/android/de/changelogs/80.txt index e8115536..c9ba80b0 100644 --- a/fastlane/metadata/android/de/changelogs/80.txt +++ b/fastlane/metadata/android/de/changelogs/80.txt @@ -1,7 +1,7 @@ Tusky v14.0 -- Notifikationen wenn ein Nutzer dem du folgst postet - Klicke auf das Glockenicon in deren Profil! (Funktion von Mastodon 3.3.0) -- Die Entwurfsfunktion in Tusky wurde vollständig neu gestaltet um schneller, nutzerfreundlicher und weniger fehleranfällig zu sein. -- Ein neue Wohlbefinden-Modus der dir erlaubt bestimmte Funktionen von Tusky zu beschränken wurde hinzugefügt. +- Werde über neue Beiträge benachrichtigt – Klicke auf das Glockensymbol in Profilen! (Funktion von Mastodon 3.3.0) +- Die Entwurfsfunktion in Tusky wurde vollständig neu gestaltet, um schneller, nutzerfreundlicher und weniger fehleranfällig zu sein. +- Ein neuer Wohlbefinden-Modus, der dir erlaubt bestimmte Funktionen von Tusky zu beschränken, wurde hinzugefügt. - Tusky kann jetzt animierte GIF-Emojis darstellen. Alle Änderungen: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/de/changelogs/91.txt b/fastlane/metadata/android/de/changelogs/91.txt new file mode 100644 index 00000000..1fd95cb2 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Unterstützung für neue Benachrichtigungstypen aus Mastodon 3.5 +- Das Bot-Symbol sieht jetzt besser aus und passt sich dem gewählten App-Thema an +- Der Text in den Beitragsdetails kann jetzt ausgewählt werden +- Viele Fehler behoben, inklusive einem, der Anmeldungen auf Android 6 und älter verhindert hat diff --git a/fastlane/metadata/android/de/changelogs/97.txt b/fastlane/metadata/android/de/changelogs/97.txt new file mode 100644 index 00000000..90a94829 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Neues App-Icon von Dzuk https://dzuk.zone +- Du kannst nun Hashtags folgen. Tippe einen Hashtag und anschließend das Symbol in der Hauptleiste. +- Unterstützung für Android 13 +- Neues Auswahlmenü zum festlegen der Beitragssprache +- Der Medien-Tab in Profilen achtet nun Medien mit Inhaltswarnung und lädt schneller +- Es ist nun möglich den Fokuspunkt eines Bildes vor der Veröffentlichung festzulegen +- Du kannst nun deinen vollständigen Nutzernamen in der Hauptleiste anzeigen lassen diff --git a/fastlane/metadata/android/en-US/changelogs/97.txt b/fastlane/metadata/android/en-US/changelogs/97.txt new file mode 100644 index 00000000..146cf247 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- New App icon by Dzuk https://dzuk.zone/ +- You can now follow hashtags. Click on a hashtag and then on the icon in the toolbar. +- Support for Android 13 +- new dropdown in the compose view to set the language of a post +- The media tab in profiles now respects sensitive media and loads smoother. +- It is now possible to set the focus point of an image before posting +- New option to show your full username in the toolbar \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02_compose.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02_compose.png index eadb78be..2a098a12 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/02_compose.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02_compose.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png index 3e0cc5f7..84449e68 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03_profile.png differ diff --git a/fastlane/metadata/android/eo/full_description.txt b/fastlane/metadata/android/eo/full_description.txt new file mode 100644 index 00000000..54185ea9 --- /dev/null +++ b/fastlane/metadata/android/eo/full_description.txt @@ -0,0 +1,12 @@ +Tusky estas malpeza klienta aplikaĵo por Mastodon, libera kaj malfermitkoda socireta servilo. + +• „Material Design“ +• Funkcias kun la plejmulto de API de Mastodon +• Subtenas uzadon de pluraj uzantkontoj +• Hela kaj malhela etosoj kun la eblo de aŭtomata ŝanĝo inter ili depende de la momento de la tago +• Malnetoj: ekredaktu hupojn kaj konservu ilin por poste +• Elektu inter malsamaj emoĝi-stiloj +• Optimumigita por ĉiuj ekrangrandoj +• Tute malfermitkoda: sen ajna dependo de fermitkodaj servoj kiel tiuj de Google + +Por ekscii pli pri Mastodon, vizitu https://joinmastodon.org/ diff --git a/fastlane/metadata/android/eo/short_description.txt b/fastlane/metadata/android/eo/short_description.txt new file mode 100644 index 00000000..88d8f8ae --- /dev/null +++ b/fastlane/metadata/android/eo/short_description.txt @@ -0,0 +1 @@ +Pluruzanta aplikaĵo por la socia reto Mastodon diff --git a/fastlane/metadata/android/eo/title.txt b/fastlane/metadata/android/eo/title.txt new file mode 100644 index 00000000..0238ffc0 --- /dev/null +++ b/fastlane/metadata/android/eo/title.txt @@ -0,0 +1 @@ +Tusky diff --git a/fastlane/metadata/android/es/changelogs/87.txt b/fastlane/metadata/android/es/changelogs/87.txt new file mode 100644 index 00000000..4dd74942 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v6.0 + +- La lógica de carga de linea de tiempo fue reescrita para ser más rápida, tener menos bugs, y ser más fácil de mantener. +- Tusky puede animar emojis personalizados en formatos APNG y Animated WebP +- Muchos arreglos de bugs +- Soporte para Android 11 +- Nuevas traducciones: Gaélico escocés, Galiciano y Ucraniano +- Traducciones mejoradas diff --git a/fastlane/metadata/android/es/changelogs/89.txt b/fastlane/metadata/android/es/changelogs/89.txt new file mode 100644 index 00000000..6d004d92 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky versión 17.0 + +-"Abrir como..." ahora disponible en el menú del perfil cuando se usan varias cuentas. +- Para el registro ahora se utiliza WebView dentro de la app +- Soporte para Android 12 +- Soporte para la nueva API de configuración de instancias de Mastodon +- y muchas otras mejoras y correcciones diff --git a/fastlane/metadata/android/es/changelogs/91.txt b/fastlane/metadata/android/es/changelogs/91.txt new file mode 100644 index 00000000..2478c766 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky versión 18.0 + +- Soporte para las nuevas notificaciones de Mastodon 3.5 +- El símbolo de bot ahora se adecúa mejor al tema seleccionado +- Ahora se puede seleccionar el texto de una publicación en la vista detallada +- Correcciones de muchos errores, incluyendo el que impedía registros en Android 6 y anteriores diff --git a/fastlane/metadata/android/es/changelogs/94.txt b/fastlane/metadata/android/es/changelogs/94.txt new file mode 100644 index 00000000..8a59f9df --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky versión 19.0 + +- Soporte para Unified Push. Para activar el soporte tendrás que volver a iniciar sesión en tus cuentas. +- Ahora se muestra el número de respuestas a una publicación en las cronologías. +- Ahora se pueden recortar imágenes cuando se escribe una publicación. +- Ahora se muestra la fecha en la que se crearon los perfiles. +- Cuando se ve una lista, ahora se muestra el nombre en la barra de herramientas. +- Correcciones de diversos errores +- Mejoras en las traducciones diff --git a/fastlane/metadata/android/es/changelogs/97.txt b/fastlane/metadata/android/es/changelogs/97.txt new file mode 100644 index 00000000..09c98511 --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky versión 20.0 + +- Nuevo icono de la aplicación, de Dzuk https://dzuk.zone/ +- Ahora se pueden seguir etiquetas. Clica en una etiqueta y después en el icono de la barra. +- Soporte para Android 13 +- Se puede seleccionar el idioma en el que se escribe una publicación +- La pestaña de multimedia en los perfiles ahora respeta el contenido sensible y carga mejor. +- Ahora es posible fijar el foco de una imagen antes de publicarla +- Nueva opción para mostrar el nombre de usuario completo en la barra diff --git a/fastlane/metadata/android/fa/changelogs/97.txt b/fastlane/metadata/android/fa/changelogs/97.txt new file mode 100644 index 00000000..7b810a1f --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/97.txt @@ -0,0 +1,9 @@ +تاسکی ۲۰٫۰ + +- نقشک‌کارهٔ جدید به دست https://dzuk.zone +- اکنون می‌توانید برچسب‌ها را دنبال کنید. روی برچسبی ز ده و سپس نقشک داخل نوارابزار را بزنید. +- پشتیبانی از اندروید ۱۳ +- پایین‌افتادنی جدید در نمای نوشتن برای تنظیم زبان فرسته +- زبانهٔ رسانه در نمایه اکنون به رسانه‌های خسّاس احترام گذاشته و نرم‌تر بار می‌شود. +- اکنون می‌توان پیش از فرستادن تصویر، نقطهٔ تمرکز را تنظیم کرد +- گزینهٔ جدید برای نمایش نام کاربری کاملتان در نوارابزار diff --git a/fastlane/metadata/android/fi/title.txt b/fastlane/metadata/android/fi/title.txt new file mode 100644 index 00000000..0238ffc0 --- /dev/null +++ b/fastlane/metadata/android/fi/title.txt @@ -0,0 +1 @@ +Tusky diff --git a/fastlane/metadata/android/fr/changelogs/97.txt b/fastlane/metadata/android/fr/changelogs/97.txt new file mode 100644 index 00000000..07d9b8e0 --- /dev/null +++ b/fastlane/metadata/android/fr/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Nouvelle icône d'application par https://dzuk.zone/ +- Vous pouvez maintenant suivre des mot croisillon. Cliquer sur un mot croisillon puis sur l’icône dans la barre d'outil. +- Support de Android 13 +- Nouveau menu déroulant dans la fenêtre de composition permettant de choisir la langue de la publication. +- L'onglet Media dans les profiles respecte maintenant les images sensibles et charge de façon plus fluide. +- Ajout de la possibilité de choisir la partie visible d'une image avant de publier. +- Nouvelle option pour voir votre nom d'utilisateur complet dans la barre d'outils. diff --git a/fastlane/metadata/android/ga/short_description.txt b/fastlane/metadata/android/ga/short_description.txt new file mode 100644 index 00000000..041f207f --- /dev/null +++ b/fastlane/metadata/android/ga/short_description.txt @@ -0,0 +1 @@ +Cliaint ilchuntasach don líonra sóisialta Mastodon diff --git a/fastlane/metadata/android/ga/title.txt b/fastlane/metadata/android/ga/title.txt new file mode 100644 index 00000000..0238ffc0 --- /dev/null +++ b/fastlane/metadata/android/ga/title.txt @@ -0,0 +1 @@ +Tusky diff --git a/fastlane/metadata/android/gl/changelogs/97.txt b/fastlane/metadata/android/gl/changelogs/97.txt new file mode 100644 index 00000000..5c707f7e --- /dev/null +++ b/fastlane/metadata/android/gl/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Nova icona da app por Dzuk https://dzuk.zone/ +- Podes seguir cancelos. Preme no cancelo e depois na icona da barra de ferramentas. +- Soporte para Android 13 +- Cando escribes unha mensaxe podes seleccionar o idioma da publicación +- Nos perfís, a lapela multimedia agora respecta o marcado como sensible e carga máis suavemente. +- É posible establecer o foco nunha zona da imaxe antes de publicala +- Nova opcións para mostrar o identificador de usuaria completo na barra de ferramentas diff --git a/fastlane/metadata/android/nb-NO/changelogs/97.txt b/fastlane/metadata/android/nb-NO/changelogs/97.txt new file mode 100644 index 00000000..a379f940 --- /dev/null +++ b/fastlane/metadata/android/nb-NO/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Nytt applikasjonsikon av Dzuk https://dzuk.zone/ +- Du kan nå følge stikkord. Klikk på et stikkord, og deretter ikonet i verktøylinjen. +- Støtte for Android 13 +- Ny nedtrekksliste for å konfigurere hvilket språk et innlegg er skrevet på +- Media-fanen i profilvisning håndterer nå sensitivt media og laster fortere +- Det er nå mulig å sette fokuspunkt på et bilde før det publiseres +- Mulighet for å vise ditt fulle navn på verktøylinjen diff --git a/fastlane/metadata/android/nl/changelogs/58.txt b/fastlane/metadata/android/nl/changelogs/58.txt new file mode 100644 index 00000000..59111c2c --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/58.txt @@ -0,0 +1,13 @@ +Tusky v6.0 + +- Timeline filters have moved to Account Preferences and will sync with the server +- You can now have a custom hashtag as tab in the main interface +- Lists can now be edited +- Security: removed support for TLS 1.0 and TLS 1.1, and added support for TLS 1.3 on Android 6+ +- The compose view will now suggest custom emojis when starting to type +- New theme setting "follow system theme" +- Improved timeline accessibility +- Tusky will now ignore unknown notifications and no longer crash +- New setting: You can now override the system language and set a different language in Tusky +- New translations: Czech and Esperanto +- A lot of other improvements and fixes diff --git a/fastlane/metadata/android/nl/changelogs/70.txt b/fastlane/metadata/android/nl/changelogs/70.txt new file mode 100644 index 00000000..eb242874 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/70.txt @@ -0,0 +1,8 @@ +Tusky v10.0 + +- You can now bookmark statuses & list your bookmarks in Tusky. +- You can now schedule toots with Tusky. Note that the time you select has to be at least 5 minutes in the future. +- You can now add lists to the main screen. +- You can now post audio attachments with Tusky. + +And a lot of other small improvements and bug fixes! diff --git a/fastlane/metadata/android/nl/changelogs/72.txt b/fastlane/metadata/android/nl/changelogs/72.txt new file mode 100644 index 00000000..0ba9c637 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/72.txt @@ -0,0 +1,11 @@ +Tusky v11.0 + +- Notifications about new follow requests when your account is locked +- New features that can be toggled on the Preferences screen: + - disable swiping between tabs + - show a confirmation dialog before boosting a toot + - show link previews in timelines +- Conversations can now be muted +- Poll results will now be calculated based on the number of voters and not on the number of total votes which makes multichoice polls easier to understand +- A lot of bugfixes, most of them related to composing toots +- Improved translations diff --git a/fastlane/metadata/android/nl/changelogs/74.txt b/fastlane/metadata/android/nl/changelogs/74.txt new file mode 100644 index 00000000..2999b8bb --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- Improved main interface - you can now move the tabs to the bottom +- When muting a user, you can now also decide whether to mute their notifications +- You can now follow as many hashtags as you want in one single hashtag tab +- Improved the way media descriptions are displayed so it works even for super long descriptions + +Full changelog: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/nl/changelogs/77.txt b/fastlane/metadata/android/nl/changelogs/77.txt new file mode 100644 index 00000000..bb3c9662 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- support for profile notes (Mastodon 3.2.0 feature) +- support for admin announcements (Mastodon 3.1.0 feature) + +- the avatar of your selected account will now be shown in the main toolbar +- clicking the display name in a timeline will now open the profile page of that user + +- a lot of bug fixes and small improvements +- improved translations diff --git a/fastlane/metadata/android/nl/changelogs/80.txt b/fastlane/metadata/android/nl/changelogs/80.txt new file mode 100644 index 00000000..14d28f0a --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Get notified when a followed user posts - click the bell icon on their profile! (Mastodon 3.3.0 feature) +- The draft feature in Tusky has been completely redesigned to be faster, more user friendly and less buggy. +- A new wellbeing mode that allows you to limit certain Tusky features has been added. +- Tusky can now animate custom emojis. +Full changelog: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/nl/changelogs/82.txt b/fastlane/metadata/android/nl/changelogs/82.txt new file mode 100644 index 00000000..6e6afdd4 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Follow requests are now always shown in the main menu. +- The time picker for scheduling a post has a design consistent with the rest of the app now +Full changelog: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/nl/changelogs/83.txt b/fastlane/metadata/android/nl/changelogs/83.txt new file mode 100644 index 00000000..5eb77c2b --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +This release fixes a crash when captioning images diff --git a/fastlane/metadata/android/nl/changelogs/87.txt b/fastlane/metadata/android/nl/changelogs/87.txt new file mode 100644 index 00000000..81411b48 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- The timeline loading logic has been completely rewritten in order to be faster, less buggy and easier to maintain. +- Tusky can now animate custom emojis in APNG & Animated WebP format. +- A lot of bugfixes +- Support for Android 11 +- New translations: Scottish Gaelic, Galician, Ukrainian +- Improved translations diff --git a/fastlane/metadata/android/nl/changelogs/89.txt b/fastlane/metadata/android/nl/changelogs/89.txt new file mode 100644 index 00000000..4b666c35 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Open as..." is now also available in the menu on account profiles when using multiple accounts +- Login is now handled in a WebView within the app +- Support for Android 12 +- support for the new Mastodon instance configuration API +- and a lot of other small fixes and improvements diff --git a/fastlane/metadata/android/nl/changelogs/91.txt b/fastlane/metadata/android/nl/changelogs/91.txt new file mode 100644 index 00000000..e1d98303 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/91.txt @@ -0,0 +1,6 @@ +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/nl/changelogs/94.txt b/fastlane/metadata/android/nl/changelogs/94.txt new file mode 100644 index 00000000..f584c02f --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/94.txt @@ -0,0 +1,9 @@ +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 diff --git a/fastlane/metadata/android/nl/full_description.txt b/fastlane/metadata/android/nl/full_description.txt new file mode 100644 index 00000000..fdebe1ea --- /dev/null +++ b/fastlane/metadata/android/nl/full_description.txt @@ -0,0 +1,12 @@ +Tusky is een lichtgewicht app voor Mastodon, een vrij en open-source decentraal sociaal netwerk. + +• Material design +• Meeste Mastodon-API's worden ondersteund +• Support voor meerdere accounts +• Donker en licht thema, met de optie om automatisch tijdens zonsop- en ondergang te wisselen +• Concepten - schrijf berichten en bewaar ze voor later +• Kies tussen verschillende emojistijlen +• Geoptimaliseerd voor verschillende schermgroottes +• Volledig open-source - geen niet-vrije afhankelijkheden zoals Google-services + +Ga naar https://joinmastodon.org om meer over Mastodon te leren. diff --git a/fastlane/metadata/android/pl/changelogs/58.txt b/fastlane/metadata/android/pl/changelogs/58.txt index ed5d7b35..1a2d1126 100644 --- a/fastlane/metadata/android/pl/changelogs/58.txt +++ b/fastlane/metadata/android/pl/changelogs/58.txt @@ -3,9 +3,11 @@ Tusky v6.0 - Filtry zostały przeniesione do Ustawień konta i synchronizują się z serwerem - Można ustawić własny hashtag jako zakładkę w głównym interfejsie - Listy mogą być edytowane -- Usunięto wsparcie dla TLS 1.0 i 1.1, i dodano wsparcie dla TLS 1.3 na Androidzie 6+ +- Usunięto wsparcie dla TLS 1.0 i 1.1, dodano wsparcie dla TLS 1.3 dla Android 6+ - Widok tworzenia wpisów sugeruje teraz niestandardowe emoji - Nowe ustawienie motywu "Użyj motywu systemu" -- Możesz teraz ustawić inny język niż systemowy +- Ulepszona przystępność osi czasu +- Aplikacja ignoruje nieznane typy powiadomień oraz nie będzie się zawieszać z ich powodu +- Możliwość ustawić inny język niż systemowy - Nowe tłumaczenia: czeski i esperanto - Wiele innych ulepszeń i poprawek diff --git a/fastlane/metadata/android/pl/changelogs/61.txt b/fastlane/metadata/android/pl/changelogs/61.txt index 0134a9ef..cbea28a4 100644 --- a/fastlane/metadata/android/pl/changelogs/61.txt +++ b/fastlane/metadata/android/pl/changelogs/61.txt @@ -4,4 +4,4 @@ Tusky v7.0 - Nowy przycisk dla filtrowania powiadomień i usuwania wszystkich powiadomień - Możesz usunąć i napisać ponownie własne wpisy - Przy ikonach kont ustawionych jako bot pojawia się mała ikonka robota (opcję można wyłączyć w preferencjach) -- Nowe tłumaczenia: Norweski Bokmål i Słoweński +- Nowe tłumaczenia: Norweski Bokmål i Słoweński. diff --git a/fastlane/metadata/android/pl/changelogs/91.txt b/fastlane/metadata/android/pl/changelogs/91.txt new file mode 100644 index 00000000..82409604 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Wsparcie dla nowych rodzajów powiadomień w Mastodon 3.5 +- Nowy wygląd plakietek botów dopasowujący się do wybranego motywu +- Możliwość zaznaczenia tekstu w szczegółowym widoku postów +- Wprowadzenie wielu poprawek, w tym naprawienie błędu uniemożliwjającego logowanie na urządzeniach z systemem Android 6 i niższym diff --git a/fastlane/metadata/android/pl/changelogs/94.txt b/fastlane/metadata/android/pl/changelogs/94.txt new file mode 100644 index 00000000..7a7dc8b8 --- /dev/null +++ b/fastlane/metadata/android/pl/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Wsparcie dla Unified Push. Aby aktywować wsparcie należy zalogować się ponownie na swoje konta. +- Ilość odpowiedzi na wpis jest teraz pokazywana na osiach czasu. +- Obrazki mogą teraz być przycinane podczas tworzenia wpisu. +- Profile pokazują teraz datę, w której zostały stworzone. +- Nazwa przeglądanej listy jest teraz pokazywana na górnym pasku. +- Mnóstwo poprawek +- Ulepszone tłumaczenia diff --git a/fastlane/metadata/android/sv/changelogs/70.txt b/fastlane/metadata/android/sv/changelogs/70.txt index eb242874..463c9bbb 100644 --- a/fastlane/metadata/android/sv/changelogs/70.txt +++ b/fastlane/metadata/android/sv/changelogs/70.txt @@ -1,8 +1,8 @@ Tusky v10.0 -- You can now bookmark statuses & list your bookmarks in Tusky. -- You can now schedule toots with Tusky. Note that the time you select has to be at least 5 minutes in the future. -- You can now add lists to the main screen. -- You can now post audio attachments with Tusky. +- Du kan nu bokmärka inlägg & lista dina bokmärken i Tusky. +- Du kan nu schemalägga inlägg med Tusky. Observera att tiden du väljer måste vara minst 5 minuter framåt i tiden. +- Du kan nu fästa listor på huvudvyn. +- Du kan nu skicka ljudbilagor med Tusky. -And a lot of other small improvements and bug fixes! +Och många andra mindre förbättringar och buggfixar! diff --git a/fastlane/metadata/android/sv/changelogs/94.txt b/fastlane/metadata/android/sv/changelogs/94.txt new file mode 100644 index 00000000..09f42abe --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Stöd för Unified Push. För att aktivera stöd behöver du logga in igen till dina konton. +- Antalet svar på ett inlägg visas nu i tidslinjer. +- Bilder kan nu beskäras medan du skriver ett inlägg. +- Profiler visar nu datum då de skapades. +- När en lista visas,syns också dess titel i verktygsraden. +- Många buggfixar +- Förbättrade översättningar diff --git a/fastlane/metadata/android/sv/changelogs/97.txt b/fastlane/metadata/android/sv/changelogs/97.txt new file mode 100644 index 00000000..bf18d815 --- /dev/null +++ b/fastlane/metadata/android/sv/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Ny appikon av Dzuk https://dzuk.zone/ +- Du kan nu följa hashtaggar. Tryck på en hashtagg och sen på ikonen i verktygsraden +- Stöd för Android 13 +- Ny rullgardinsmeny för att ställa in vilket språk inlägget är skrivet på +- Mediafliken i profilvyn hanterar nu känsligt media och laddar snabbare +- Det går nu att sätta fokuspunkt för en bild innan den publiceras +- Nytt alternativ för att visa ditt fullständiga namn i verktygsraden diff --git a/fastlane/metadata/android/uk/changelogs/87.txt b/fastlane/metadata/android/uk/changelogs/87.txt index 9b8c1732..3a5e4a7c 100644 --- a/fastlane/metadata/android/uk/changelogs/87.txt +++ b/fastlane/metadata/android/uk/changelogs/87.txt @@ -4,5 +4,5 @@ Tusky v16.0 - Tusky тепер може анімувати користувацькі емоджі у форматі APNG та Animated WebP. - Виправлено багато помилок - Підтримка для Android 11 -- Нові переклади: шотландською гельською, галісійською, українською +- Нові переклади: шотландською гельською, галісійською, українською мовами - Удосконалено переклади diff --git a/fastlane/metadata/android/uk/changelogs/97.txt b/fastlane/metadata/android/uk/changelogs/97.txt new file mode 100644 index 00000000..233daff7 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Нова піктограма застосунку від Dzuk https://dzuk.zone/ +- Додано можливість слідкувати за хештегами. Натисніть на нього, а потім на піктограму на панелі інструментів. +- Підтримка Android 13 +- новий спадний список у вікні створення допису для вибору мови допису +- Вкладка «Медіа» у профілях завантажується плавніше для чутливих носіїв. +- З'явилася можливість установити фокусувати зображення перед оприлюдненням +- Нова опція показу вашого повного імені користувача на панелі інструментів diff --git a/fastlane/metadata/android/vi/changelogs/97.txt b/fastlane/metadata/android/vi/changelogs/97.txt new file mode 100644 index 00000000..492c7217 --- /dev/null +++ b/fastlane/metadata/android/vi/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Biểu tượng app mới của Dzuk https://dzuk.zone/ +- Theo dõi hashtag. Nhấn vào một hashtag và sau đó nhấn vào biểu tượng trên thanh công cụ. +- Hỗ trợ Android 13 +- Chọn ngôn ngữ của tút +- Tab media trong hồ sơ hiện tôn trọng media nhạy cảm và tải mượt mà hơn. +- Đặt điểm lấy nét của ảnh trước khi đăng +- Tùy chọn mới để hiển thị tên người dùng đầy đủ của bạn trên thanh công cụ diff --git a/fastlane/metadata/android/zh-Hans/changelogs/89.txt b/fastlane/metadata/android/zh-Hans/changelogs/89.txt new file mode 100644 index 00000000..808af8e6 --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- 使用多个帐户时,“打开为...”现在也可以在帐户配置文件的菜单中使用。 +- 登录现在在内嵌的 WebView 中处理。 +- 支持 Android 12。 +- 支持新的 Mastodon 实例配置 API。 +- 一些其它小修复和改动 diff --git a/fastlane/metadata/android/zh-Hans/changelogs/91.txt b/fastlane/metadata/android/zh-Hans/changelogs/91.txt new file mode 100644 index 00000000..e91fe6db --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- 支持新的 Mastodon 3.5 通知类型。 +- 机器人徽章现在看起来更棒,并将适应所选主题。 +- 嘟文详情视图上的文本现在可以被选择。 +- 修复了一些 bugs,其中有一个会阻止在 Android 6 及更低版本的设备上登录 diff --git a/fastlane/metadata/android/zh-Hans/changelogs/94.txt b/fastlane/metadata/android/zh-Hans/changelogs/94.txt new file mode 100644 index 00000000..f6330063 --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- 支持统一推送。 要激活支持,您必须重新登录您的帐户。 +- 嘟文的回复数量现在显示在时间轴中。 +- 现在可以在撰写嘟文时裁剪图片。 +- 配置文件现在将显示创建日期。 +- 查看列表时,标题将显示在工具栏中。 +- 很多错误修正。 +- 翻译改进 diff --git a/fastlane/metadata/android/zh-Hans/changelogs/97.txt b/fastlane/metadata/android/zh-Hans/changelogs/97.txt new file mode 100644 index 00000000..4879192b --- /dev/null +++ b/fastlane/metadata/android/zh-Hans/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- 来自 Dzuk 的新图标 https://dzuk.zone/ +- 您现在可以关注主题标签。 点击主题标签后再点击工具栏中的图标即可。 +- 支持 Android 13 +- 撰写视图中用于设置嘟文语言的新下拉菜单 +- 配置文件中的媒体选项卡现在尊重敏感媒体且加载得更流畅。 +- 现在可以在发布前设置图片的焦点 +- 在工具栏中显示完整用户名的新选项 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..3f384f4e --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,149 @@ +[versions] +agp = "7.2.2" +androidx-activity = "1.6.0" +androidx-appcompat = "1.6.0-rc01" +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" +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] +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" } +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 7454180f..249e5832 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 d7e66b5c..8fad3f5a 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 3da45c16..a69d9cb6 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 ac1b06f9..53a6b238 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 a7095124..90f6c2fb 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