From 3592318dc1f20388dececc71e6286e2a8bb79622 Mon Sep 17 00:00:00 2001 From: Goooler Date: Sun, 5 Feb 2023 02:58:53 +0800 Subject: [PATCH 001/418] Modernize a bit (#3171) * Remove redundant ignore file * Add .gitattributes * Generate new wrapper * Apply plugins in `plugins` * Adopt new dsl * Enable stable config cache * Ignore all build folders * Enable build scan * Disable buildFeatures flags by default * Migrate to nonTransitive R class * Tweak flags * Bump AGP to 7.4.0 * Bump deps * Run `ktlintFormat` * Add an icon for IDEA to display * Revert "Bump deps" This reverts commit bc0d5b69d59f70289d5d5c4887a85e6af23cc662. * Revert "Enable build scan" This reverts commit 1568e5e84f1ee51064b3f426b1da0cf35fb67856. * Remove com.android.library * Enable Gradle cache * Enable room incremental build * Cleanups * Cleanups * Add .editorconfig * Defer clean task * Migrate `flavorDimensions` * Merge instance-build.gradle into app's build.gradle * Declare compileOptions & kotlinOptions * Bump jvmTarget to 17 * Fix conflicts * Xmx4g * Rename output apks * Revert "Bump jvmTarget to 17" This reverts commit e4d1543bda65b6d2979ae0712bceee33fa8298a6. --- .editorconfig | 11 ++ .gitattributes | 4 + .gitignore | 5 +- .idea/icon.png | Bin 0 -> 27414 bytes app/.gitignore | 2 - app/build.gradle | 99 ++++++---- .../com/keylesspalace/tusky/BaseActivity.java | 2 +- .../com/keylesspalace/tusky/MainActivity.kt | 6 +- .../components/account/AccountActivity.kt | 4 +- .../account/media/AccountMediaGridAdapter.kt | 2 +- .../account/media/AccountMediaViewModel.kt | 2 +- .../announcements/AnnouncementAdapter.kt | 2 +- .../components/compose/ComposeActivity.kt | 2 +- .../compose/dialog/AddPollDialog.kt | 2 +- .../followedtags/FollowedTagsViewModel.kt | 2 +- .../com/keylesspalace/tusky/db/Converters.kt | 2 +- .../tusky/util/CustomEmojiHelper.kt | 2 +- .../keylesspalace/tusky/util/LinkHelper.kt | 2 +- .../keylesspalace/tusky/view/LicenseCard.kt | 2 +- build.gradle | 26 +-- gradle.properties | 28 +-- gradle/libs.versions.toml | 14 +- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 12 +- gradlew.bat | 183 +++++++++--------- instance-build.gradle | 19 -- settings.gradle | 18 ++ 28 files changed, 241 insertions(+), 215 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .idea/icon.png delete mode 100644 app/.gitignore delete mode 100644 instance-build.gradle diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1b58baf4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..411c0777 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore index 80cd3ef9..b40257dd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ /local.properties /.idea .DS_Store -/build +build /captures .externalNativeBuild -app/release \ No newline at end of file +app/release +app-release.apk \ No newline at end of file diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3132225d5d36bb285f96518ab5ab867941503de8 GIT binary patch literal 27414 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 literal 0 HcmV?d00001 diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 3f1ce47a..00000000 --- a/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -app-release.apk diff --git a/app/build.gradle b/app/build.gradle index 53c9e9d2..f05496dc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,30 +1,33 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: 'kotlin-parcelize' - -apply from: "../instance-build.gradle" - -def getGitSha = { - def stdout = new ByteArrayOutputStream() - try { - exec { - commandLine 'git', 'rev-parse', '--short', 'HEAD' - standardOutput = stdout - } - } catch (Exception e) { - return "unknown" - } - return stdout.toString().trim() +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.kotlin.parcelize) } +final def gitSha = providers.exec { + commandLine('git', 'rev-parse', '--short=7', 'HEAD') +}.standardOutput.asText.get().trim() + +// The app name +final def APP_NAME = "Tusky" +// The application id. Must be unique, e.g. based on your domain +final def APP_ID = "com.keylesspalace.tusky" +// url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend. +final def CUSTOM_LOGO_URL = "" +// e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen +final def CUSTOM_INSTANCE = "" +// link to your support account. Will be linked on the about page when not empty. +final def SUPPORT_ACCOUNT_URL = "https://mastodon.social/@Tusky" + android { - compileSdkVersion 33 + compileSdk 33 + namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" - minSdkVersion 23 - targetSdkVersion 33 + minSdk 23 + targetSdk 33 versionCode 100 versionName "21.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -35,12 +38,6 @@ android { buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"") buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"") buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_ACCOUNT_URL\"") - - kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } - } } buildTypes { release { @@ -51,20 +48,22 @@ android { debug {} } - flavorDimensions "color" + flavorDimensions += "color" productFlavors { blue {} green { resValue "string", "app_name", APP_NAME + " Test" applicationIdSuffix ".test" - versionNameSuffix "-" + getGitSha() + versionNameSuffix "-" + gitSha } } - lintOptions { + lint { disable 'MissingTranslation' } buildFeatures { + buildConfig true + resValues true viewBinding true } testOptions { @@ -74,17 +73,18 @@ android { } unitTests.all { systemProperty 'robolectric.logging.enabled', 'true' - } + } } sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } - packagingOptions { - // Exclude unneeded files added by libraries - exclude 'LICENSE_OFL' - exclude 'LICENSE_UNICODE' - } + // Exclude unneeded files added by libraries + packagingOptions.resources.excludes += [ + 'LICENSE_OFL', + 'LICENSE_UNICODE', + ] + bundle { language { // bundle all languages in every apk so the dynamic language switching works @@ -95,6 +95,26 @@ android { includeInApk false includeInBundle false } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + + "${variant.flavorName}_${buildType.name}.apk" + } + } +} + +kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.incremental", "true") + } } // library versions are in PROJECT_ROOT/gradle/libs.versions.toml @@ -132,11 +152,7 @@ dependencies { implementation libs.photoview implementation libs.bundles.material.drawer - implementation libs.material.typeface, { - artifact { - type = "aar" - } - } + implementation libs.material.typeface implementation libs.image.cropper @@ -156,5 +172,4 @@ dependencies { androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.androidx.test.junit - } diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index ad173170..8f665fed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -79,7 +79,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab /* set the taskdescription programmatically, the theme would turn it blue */ String appName = getString(R.string.app_name); Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher); - int recentsBackgroundColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK); + int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK); setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index e3eb81a8..2bbf86d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -519,8 +519,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { - textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorOnPrimary)) - color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, R.attr.colorPrimary)) + textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary)) + color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, androidx.appcompat.R.attr.colorPrimary)) } }, DividerDrawerItem(), @@ -618,7 +618,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupTabs(selectNotificationTab: Boolean) { val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { - val actionBarSize = getDimension(this, R.attr.actionBarSize) + val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin binding.topNav.hide() 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 01f9e25e..1320bc91 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 @@ -178,9 +178,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI * Load colors and dimensions from resources */ private fun loadResources() { - toolbarColor = MaterialColors.getColor(this, R.attr.colorSurface, Color.BLACK) + toolbarColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK) statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) - statusBarColorOpaque = MaterialColors.getColor(this, R.attr.colorPrimaryDark, Color.BLACK) + statusBarColorOpaque = MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimaryDark, Color.BLACK) 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/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index fcc3bcf9..48b3a21d 100644 --- 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 @@ -40,7 +40,7 @@ class AccountMediaGridAdapter( } ) { - private val baseItemBackgroundColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK) + private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) 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 index 5c3528e9..ddbcb71d 100644 --- 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 @@ -25,7 +25,7 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject -class AccountMediaViewModel @Inject constructor ( +class AccountMediaViewModel @Inject constructor( api: MastodonApi ) : ViewModel() { 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 6ebe76b7..8f30c5e4 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 @@ -80,7 +80,7 @@ class AnnouncementAdapter( item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? - ?: Chip(ContextThemeWrapper(chips.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply { + ?: Chip(ContextThemeWrapper(chips.context, com.google.android.material.R.style.Widget_MaterialComponents_Chip_Choice)).apply { isCheckable = true checkedIcon = null chips.addView(this, i) 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 fb7c24e3..79a043c0 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 @@ -569,7 +569,7 @@ class ComposeActivity : } private fun setupAvatar(activeAccount: AccountEntity) { - val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize) + val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) val a = obtainStyledAttributes(null, actionBarSizeAttr) val avatarSize = a.getDimensionPixelSize(0, 1) a.recycle() 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 005e6729..4d7c0f81 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 @@ -63,7 +63,7 @@ fun showAddPollDialog( var durations = context.resources.getIntArray(R.array.poll_duration_values).toList() val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration } binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply { - setDropDownViewResource(R.layout.support_simple_spinner_dropdown_item) + setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item) } durations = durations.filter { it in minDuration..maxDuration } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index efe5661a..bcb23a26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -11,7 +11,7 @@ import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject -class FollowedTagsViewModel @Inject constructor ( +class FollowedTagsViewModel @Inject constructor( api: MastodonApi ) : ViewModel(), Injectable { val tags: MutableList = mutableListOf() diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index d211eb49..9b858b32 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -36,7 +36,7 @@ import javax.inject.Singleton @ProvidedTypeConverter @Singleton -class Converters @Inject constructor ( +class Converters @Inject constructor( private val gson: Gson ) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index df7b4d9f..b3632904 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -1,5 +1,5 @@ /* Copyright 2020 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 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 3e994d09..ff225951 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -253,7 +253,7 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { * @param context context */ fun openLinkInCustomTab(uri: Uri, context: Context) { - val toolbarColor = MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK) + val toolbarColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) val navigationbarColor = MaterialColors.getColor(context, android.R.attr.navigationBarColor, Color.BLACK) val navigationbarDividerColor = MaterialColors.getColor(context, R.attr.dividerColor, Color.BLACK) val colorSchemeParams = CustomTabColorSchemeParams.Builder() diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 631b6e4e..44febf40 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -36,7 +36,7 @@ class LicenseCard init { val binding = CardLicenseBinding.inflate(LayoutInflater.from(context), this) - setCardBackgroundColor(MaterialColors.getColor(context, R.attr.colorSurface, Color.BLACK)) + setCardBackgroundColor(MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)) val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0) diff --git a/build.gradle b/build.gradle index fe71eb07..4ee7a054 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,15 @@ -buildscript { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } - dependencies { - classpath libs.android.gradle.plugin - classpath libs.kotlin.gradle.plugin - classpath libs.ktlint.gradle - } +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.kapt) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.ktlint) apply false } allprojects { - apply plugin: "org.jlleitschuh.gradle.ktlint" - repositories { - google() - mavenCentral() - maven { url "https://jitpack.io" } - } + apply plugin: libs.plugins.ktlint.get().pluginId } -task clean(type: Delete) { +tasks.register('clean') { delete rootProject.buildDir } diff --git a/gradle.properties b/gradle.properties index 8144ece0..24b3ba99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,19 +1,19 @@ -# Project-wide Gradle settings. - -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. - -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html - -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. - -org.gradle.jvmargs=-Xmx4096m - +org.gradle.caching=true +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 # use parallel execution org.gradle.parallel=true +# https://docs.gradle.org/7.6/userguide/configuration_cache.html +org.gradle.unsafe.configuration-cache=true +# https://blog.jetbrains.com/kotlin/2022/07/a-new-approach-to-incremental-compilation-in-kotlin/ +kotlin.incremental.useClasspathSnapshot=true + +# Disable buildFeatures flags by default +android.defaults.buildfeatures.aidl=false +android.defaults.buildfeatures.buildconfig=false +android.defaults.buildfeatures.renderscript=false +android.defaults.buildfeatures.resvalues=false +android.defaults.buildfeatures.shaders=false android.enableR8.fullMode=true +android.nonTransitiveRClass=true android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23a92f0a..e9f2f083 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "7.3.1" +agp = "7.4.0" androidx-activity = "1.6.0" androidx-appcompat = "1.6.0-rc01" androidx-browser = "1.4.0" @@ -30,7 +30,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" @@ -50,8 +49,14 @@ photoview = "2.3.0" sparkbutton = "4.1.0" unified-push = "2.0.1" +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ktlint = "org.jlleitschuh.gradle.ktlint:11.0.0" + [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" } @@ -105,7 +110,6 @@ kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v 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" } @@ -145,5 +149,3 @@ 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 249e5832f090a2944b7473328c07c9755baa3196..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 36524 zcmZ6yQ*&aJ*i+pKn$=zKxk7ICNNX(G9gnUwow3iT2Ov?s|4Q$^qH|&1~>6K_f6Q@z)!W6o~05E1}7HS1}Bv=ef%?3Rc##Sb1)XzucCDxr#(Nfxotv ze%V_W`66|_=BK{+dN$WOZ#V$@kI(=7e7*Y3BMEum`h#%BJi{7P9=hz5ij2k_KbUm( zhz-iBt4RTzAPma)PhcHhjxYjxR6q^N4p+V6h&tZxbs!p4m8noJ?|i)9ATc@)IUzb~ zw2p)KDi7toTFgE%JA2d_9aWv7{xD{EzTGPb{V6+C=+O-u@I~*@9Q;(P9sE>h-v@&g ztSnY;?gI0q;XWPTrOm!4!5|uwJYJVPNluyu5}^SCc1ns-U#GrGqZ1B#qCcJbqoMAc zF$xB#F!(F?RcUqZtueR`*#i7DQ2CF?hhYV&goK!o`U?+H{F-15he}`xQ!)+H>0!QM z`)D&7s@{0}iVkz$(t{mqBKP?~W4b@KcuDglktFy&<2_z)F8Q~73;QcP`+pO=L}4yjlzNuLzuvnVAO``skBd=rV%VWQTd0x6_%ddY*G(AJt06`GHq zJVxl`G*RiYAeT=`Cf(SUN$kUEju!>SqwEd8RWUIk$|8A& zAvW|Uo<=TWC~u}V?SNFv`Fq9OeF_VpfyXHPIIay@Pu5J6$$pg{;xE9D7CROVYV>5c zv^IYXPo_Z4)bg5h?JSUX!K`q_u{>F%FzrG>*!Db_^7*7(F@f%i34Ps`JBAH6{s=ygSr^CVO)voP`v=SO z7v;4cFM_D>iVl{&X*N7pe4_^YKV%`5J774`5!DC}g;D@50h?VA!;fU1?Hf%%`N8R1 zSg@hZ8%Dq^eYV1!g8;`6vCSJoK+V1Q6N8ImtfE3iXs!s~B>js)sLHB9w$r+6Q>Oh#Ig&awvm%OBLg!7alaf}9Cuf;M4%Ig9 zx4K}IQfPr&u?k8xWp!wI4{CP#GTs#qR0b+G{&+=vL}I{b-Pha43^%8=K3997~* z>A|oxYE%Vo4~DiOih`87u|{8!Ql5|9Y+(ZY2nRP+oLdGErjV&YeVKw>A$JyPPAL+C zA36S!dNVf z;xJ)YR;^VPE1?`h-5>{~gwY2pY8RqhrsiIBmJ}n3G@Zs!!fD6y&KWPq&i8HEm*ZAx`G} zjq2CD5U==ID^we8k?=geue4Y>_+%u3$-TzVS6QMlb4NoS%_V>;E2hQ)+1Q@v(reC5 zLeK*f%%{PNO-mtrBVl|-!WaiKAkZv-?wnOwmZ=Tv57k=4PX=C?=I4V*THRFRE8a_{ zb>5YwDf4o>>$o{XYlLN{PZ^Ff?0FJl4>A9C-q9A$$&44l122Qsc|6Fd6aTam{=JO3 zBFfFe9seUPSUeyXQc*RA>2{WoKIYVltA&@5spdIW;rzOOqoQo`CN;~UNgU{{m9^c1 zTrN|8w_7+Nws4}Z-4eS9WMpF3h<@81a)oK9njh;-TB74vR;u{vE?>6FDG7<%GVXFL zUR9l{z*eEND6pp)+hpNT$VVM^Pw*S;#NrbCmH{dhBm?%6D|k)0C@Z9H>T|kby1^)# zOPmJ8Hq`8waoEK(9}IfP_q4yr(s?ME+T%UV-ikxW!XFb^6w02t30j$n_VSwevg;{9 zx0OXK_uGBFej=gbG>G^pEv^`I8&_a@t9>Nr;#r?XNKquD&Ho|`)qK6C^-7SCdo=S& z)vUi;m5*qIePEIbL=wJ|WCBNY;zCm2F-+@N2i{I^uR9UVZm$o`I|@<&2}w)C`h)vV zW{)yGJ3?GCZNtFe53Kb#uzrC7v-{JygKZUiXDV5mR z5la_vAFOvoh#yn)B`$^ZN*Dxp5Uo~_k8G9skn2)Tb>Kw#Vgxi`bti)^(z--X9F~oR zZ6=^_x@mDT~=h_@GGVcgBtLzssB1|Xy(xc(lUYJ#_ zgwc&ajE%^cCYW7d;xAxi{#LN*1}s>{K79MZrq!tYMpRA{T!#^tgXP=J5FvkbZ@gx~ ztq-E&c$`|KX8GS2a_voZHf=y8C{6~f~`DpC- zjQfrt2OGi-WGx}Y4>vM`8<4frU*!bq*NJ*Tyn0cqk=zpDdYth-PJIfz5>pLF@qnai zzj2FEhuOa-7$JR=U!L{UWWJBA%~SW-6Nh&3;<}iQO)DvOI&VKi1L8rmICePWqoY^F z-dC8X8~1T}=C9m&yb1kZzbKd2;29_Pm*Cs=y{Z06QZDlT7Poci>1@hFa%t0<`1()UTxcQ}e`fAh6K`<5C_SG`dw$IqzwEYNKvIH3VWlhz z_#^(T53W}jeWF#WIhj^U7AdIB~3feC--5iUiiT4Qyu81 z;Xa^8#~M@p%6B`LCKWWTa7I+35BLP=EOa&Gp2pbTWw5HOIjrx;2J(KI$$HT|w8}R-8fbp9sot&LiLs7ILlyZc8 zWbss7=*Ah|X$LEt1O|T?ABkIn-0NN`I8+ipfoBZcW>(WiaASG_khBtKM{hfkm5VBS zy0Q`4*G6HRRa#9G)10Ik3$C3|nQbFzmU-dA`LjKQY8icnx?2OE40%z852{OJH=?mbvwr9 zhlx0RDo^D;p*xKx?yT(`s7wj7BHA~rHF2yxnL<1PcU7FM57;?g^ z&CyPh9W4KvZ;T8w;AuNMn|nQ-xJ~CvVT7gAPAGi7w8udw_LOp+p4eZiI`JEC@Mq9F z#dA2AM_};CnL=y0#tZALdB(P~Rz*KqGqjwec%Fy?K(PGoO0tfskWw-aGhd7$ zTi~x1G>4h5q>ek=tIoT(VBQxrq)&#`_0UHC(j*ZO%%}%C)|EzTWEpvYDqCYXLexR9 zlww1ESB+IiO}=oq)8WZj%cY_FTQcEJ`JdABa=_S;O|kLhX*|5|D>0c{12DoC?K95f ztNxm(sTU6cWWd$tv`5X(=x?yAo)IYQ3G*2+o#|EfXko6erF;M4Pc;G0)pUDY)t`H9 z76Z8V9HqbWA@!`BelAT&ErrGTz7}%M*605PEY@3{gv+`yEhr{=EVp_tU%`b54Pn4a zz8nN7`eNx=*`f1t#^7>7G07IEnbnn&`RWZ}4Cp8W_DFDs-5)GU`bw}uBmOQfKmi2@ z(cWWmvHFTUNInRH!0y_ZtuI9Eh@O3+64wy-_2DF~E@KF3abM`0gC%|kHi@&hP_#B$ zLN{Z?$V_;+h?%2zEC{2ITyWOup*w*K?~vpwB(DX1i6oY+F)??;nyHpzaPLIt6G$4; z6>iAsB+&&NN0;ObWVOL+-^ZwD?nHgY>0k>0I3iA7o)f# zN&aX$lM@r_Iu|nSdPjoF{#QD9M6>|JSNPLxX^T2!jCKjS5mwNaO+SmBfOY z;6ZdwfzhO6Vs|9u81f4e%7*mU%8K>A7QWO0;QcX7W@|NSUVl)_>7VEf#&N6E~ zn9Wv88@Suo9P+M_G2(f+JFf#Q^GV#7QQ`qH#$N1y{A*_t^`5H1=V^u?Ec|EF6W+6B z(@Q8ChIUyq;+I5CmjEa1*v%d5{WHyhcHSjQuwzQq?;^BmfV#okq3v8bp7dBdk z54B+%D3=JWd-2w$)puXxZyZH>-$O-?tbSIlGc{em9xHN!44iaCr}6uZ^FpN7IvNh8 zbp!%4xR9np`>AOEd1e2_y}xW#v@@h3wYc?WiwL6Q>fxPQA81V^J)XtGs|Z&er6w~M z!1Ph~85TMG>R&ixNUnevc(w>fgb%+X#Wds6Yl+wH29aE%;RuDeZz5dEt%#p&2VK1n zKkqgl&*_YwnO%9`0<6MVP=O3{02EcR7PvvZPbL2KMuoRsU|Y%zw38qeOL#!YFp#_~+rtNJVl>lJSh_*B0A6n3XkE5po z9RpE_h=pnmDJFX*n6wmsWJ9GLu2=L8y!_R;;Aa2Jl|)I}Qff&`Fy@iOhop8>Y2{F} zbVk3rNMi$XX(q1JrgcIhC08@d5Zc>wLUL3wYm}hzS^!5d&Mec$Sp^$DUS1lD1>KAt z|Efof3nJ4^k(WKL_t-u8ud4L(t>q#9ECj?v#W~W#2zTt>|MCh&*H8Wh1_I&^2Li&M zq9j0`(zk~P7}dB`+15b*j%VPGr$;@4MBQ5AT>-y?0Fxfr2nC1kM2D(y7qMN+p-0yo zOlND}ImY;a_K$HZCrD=P{byToyC7*@;Y$v6wL!c*DfeH#$QS6|3)pJe68d>R#{zNn zB0r*Es<6^ZWeH`M)Cdoyz`@Z&Fu_^pu8*089j{gbbd!jV@s7`eI5_X5J3|poVGlq` zDo9}G;CsjW!hgN2O9=1|GpE;RpQvrBc+&dF)L>V&>9kd6^YIL?+*WDmcQlvwnq`Lf z&N$gF>3+E*NcJojXXI^}B(B-;@ebpVY}l#EcDWles7s;Ft+KZ@m+6FWaD^oYPBXVw z3sq|aKIDh1x5Ff=tW$(LO|!e&G?Xvh^H!GfiA(emluL!LmD=EV@|u|8S7w6ibUePJ z>{sOC6L27R+b&}e?VH;KvV3a;O3G=gwG}YzrkSTV6(&=;o)EV~2OD(Eh4mu@K0G)i z3#44IZhqN6+Hb2h#3R8YwJW7LesDA9=n)75u#46_ZmSh@6Q-4oHvGxFPY8x;Q+)d@ z*-SDqhVeyPGkoD)iq;z0r*M)IhY5I>gMA@RS&EIYPq}Z{$Q4Jbfd76EVhSF-sR^TO z!=o?>V(^bx!pG$26J~Z>Tvu&Uu+0;>m+pg(fmbu(97^(OHBH4;J8WIfv-f5}VP#VS z$Y$}SHKdphDUHlbdIVW!k$L6T{LY)|H}MT=l$22kIl>|46FK9dt$?3Fjk2RA-~AX7 z1|Xe`n)%h~e-O_qLpoFXJ$%gmocq`v0%hRw1k_6nh|+3pvJDy}m)V|xjL&!Z6?%pU z+m)r2*pWjEl!etAYxdzWb0{mGc;#$>rE%)b z@Rnj78P;$lrzY!XCa0&x+8a^YF*G|Q|C}bGeczz(5m_gq08wJHIH`WqHH?A}!~_3{ zQEvMXmL<*nThl^pL58nbHgQ1n9cYmN{C8J^6AKS%?~>1DCt70Q2Vp0;E@`GF%Tzkc zSUt&LJ=wHI6@#8_%=2s=j^4VBd1-h_)3 zeozYua!|{x(qk#z;tavf28rj_5Oen-cYG%;R6I}Hz$yMXeg^)_$OUUXx1r^qrl!DG zYXkAXKBMrVM-rJwAo<5J{NW1XJhW;Nh*&`nFV-Z;Vd({KSkMxV#cn|bXJ z50GtvFE##sqGhV#lv2s6?^yeBShlhR%XaPIo)iXOue}jwZ;Zq#dgDn8H?74Y+$Z?C z2Y5mCC66>dp%sVMecUzCirWq99Ea(TDwClZxtEB~4N-2JmlH#>Z2jOcaNaw4tn?P->BBGNHxUHez7>C@TZNT5Z zHerlG0a4~06L%>tn!~$s^L5`~{ueLZ5?`$46nHvwKxM0V9VQ(k{A40xDVw{+Qt)RV zQ)T2Df)cp0nv!lUFt3D=i~k!V|7dUjpz?K2ZiynO)$d{2*YT$N^CQ{t=luZ>WcE!> zg25p}If9RTho%G@PZp;5zBwv`n+e9iO=6dx1V^|4Ty%`oE=f7O&QC^s!4MJ+lMG>^ za!mgpz*^SHT+M_zm;{H#E~SaU^Kn*y)nTAF*2@t5mF+l)bte+a+goaA*zXJ4P)H|y z{4OwbJnIPtMp4E~=64gM-Y{#o{x)+8YCg$C7Yy=;9hdyBgRFIY2_L9DL3*B@%$5#m z8P}+)glf*}UPD$C;_yntx}9VPmSSnY9`Thd09nfoR;3`kar*FRfS)`+as*t2l*USWgmaZ!qFubr1DegTGZspyYMgic{inI0dSt+rJR z((jjMrdq^?VSZ8FCO;0NW@>O_b67gDHP%W*^O?J z91NQ7ZFODMSvHj3cvT#6RJUF7x=-BJFQ^6<&mOd15Z&M!?b+3Tg!UcgldD9tOAt5K z3X>MlE-a=sj;K&}sSng48jQ7sp|&u3;@e>V4Cuf(!s@9lZ0Cg^DKWmki%>$<85tOG zU;e{%zHU~KREBUg?FbcseK{lmK-`*S1p9j_4hF=F$y)NB;HsHwuf_A0Zhy395eU7o8^A zi2t7Ch|KVprUn03N0T2XshT!g$HTErcQBBG=TWaHkYtaI2CJY7ajI%yr&9 zVC^zJ3WW03bjwGNx{l}#+D&Ml_uI4PQhV}qZPXOP7ffSv(O;hX{Ff1|HoA~v)V!4y{CdALyi2YPjrRVmRYilRv z5PSkj*Z_8Fa*sCqGN?7YTnkr9=i9X`qcw7nqz#{bj?B7NiV9fWF+%~Rb1X@MuS^Mw zC)d#K{(-9!?xStM2K5x%x~ogWxgIK>s5r_RT1jU_lxdTtIEFWvi4eJSAiGec&HXQ( z5t7!J1b#SL|8s4)u147PWQUq_e33!5Z#f$Ja&az)(Htl`Z0@Ez)0d74BzNHHfH|<-8q*ZMf?%eJzoGS!0S6Y zSU7y^1+;V$Je9F027>1eN#_tz+2t}Y^N zYfi9}J!N^SU1CYoNBDbD39@84xLroY@0f%%c^(5CE+}!b5-Mt3oXe2nBdyicgGIL+rzTTKv`}Pp%fG1f^s?sgNH8=Q}s4Z>0ZCZ8ZYF z4og8nK%OA~zZMJX01uFtrmwhcgg*XbiMP9kfkPYFASbp7*Bk^5ZBzV)dL)JhPwDkM zkgdHeKw)orJcj4^)a^wQC2|->G=OBzuc-SskRrrf+H-E%HQ==Ex}d*504#GbIUXIB zcZs@Oo0i61MG}&0bu%@2N?MMJMRXyTVb8@3wF5eY3G6-1NdT~{{~YFs8f&SNebdaq zKmP>XqCQ@iaamuvY2m%xJ~gdSLSj~DBhB`NCj_c}NbSjB{r(E`_-+6a#vx*|S>-GU zHsw^dxxu`e)q1HbH==rLFap?cebKumnTo=iJQ zJD1#=o>0%Y@&jP?^)Q5bTV!pzrf=FoHq2c_59pq@my{D4AW8VU*7LVp;LF-qESV;L zClRfyQ6CcD$sd84K@e@p_ALH%j(Pz@Em@QFyY`AG&(|!(cG8!oV#ejr`y(LolX}Iu zL$)G)8^y4sUAYCWprzVR?`#OJ%NU)9U^B!OGSj>Ly;<)<(nNh`?z*GvJ|ZBKfZ`0 z=q_yGHWPp~R+J+{{@APVwmp8`=%N!L7AT^l^oaM|JrCFu7J#@frf=z(vGq2>sQ^@u zk=^d#gDf}ME!~9PaLfw44~rsG!)T7h8~dY^VcZQa+ueWPGG$mWXB|H2$$0BT(QAIu|=DJXPQDNes3Q>-|Mh=Ih zy{WR)QmhL5rQbBYPBa+e7)8Vo;_aKrg`}izmN>#ATuSDu!QUFA zsgM|Kv@W(S}Ag^6e8)9pQc@JLj_2ZIkO=8)#ARm#mU=NncWbmd-SbO;ad=y|k`shy3b z*8o0@EJo3b$#zSgmnlT7KAp)U!qI2M`hiC@Gp0)pNGHYMe1$MBNE}Hd{Sv^`wI7>MzNwgVv1ZzL zttmyv!=TKuPH$b>r7$lgP5?vho;#Ks4+zLzaz-1b{p-Fn6dWy1Agg7O2{&VQ5@s3A zAqzC9QokRD59!@ex#k>xy61kq6h~O$lb;lB;Q|chv&wzR+N zgXdIo%?q1Y$TzsdCo+n$^NODN7yd}cAv+rkG|u-(wTp?zUSUxaA-W3dwqikdrokwz) z68)Gn$Nwc1zB$F9`#(af|C3v;|2$bo7fU8f7h^NK6h&@xi2m`)g4mW$?l@5JEc*VV z6d67@Fl2w6mO;MYUl2U>R996gQUX$d>$D>)TNGq*arz}f21yh^uvIM!3u$H{_CH5! zrjt9L^&J8UqEV_lLn&}nc|Q=MDei6t=vL_>X-i8B%f5FDi)|qQ;2V-T!qOi*uqq{U zElET6#2cb>Z_6p_vw44&mN!;T&~ubi&p`XGepCNAfa0-T zC84V@VN^R6%z({m=$%iXrbiggxvMiBpww~ktD&=9-JPK3kPCOGCJNQj8+l9k#!QeS zv3h$Ej>@j<-zBW0Qr`5tNQVRfYK_$3>nWUzf&c*tCpl@aYwa%b;JNeTX10OevcxY7 zqnLgKU-X9G8~&?Dr)`*7GryqhN#;9v`D_c=_xBcD{j-cLop~pSnM?&7HggX6gb++ftBq$idM1|>5t+68sWf{ixREbMkZesmpjJsAFPQ#2+8Uek z$BPbu3cQuNDQq+^M}&ZuSHjxUgxOjF<^%4 z*8lc$CgA<$n=DYg_DsrHB7zYM0Ro|gS8ZnUq$u3GQ+{owv9RdB$wG%d-;R+I>?i?b z+r_mu{IL6WTYftdz?0#pbHkmQP31LvXcMK6;mAP+;q^L@q}v~TD}Ni>f7@QYcbM!T zX5kShHv3X1U=>B!2*si9=AEJCBt~GIH7DL4^+gHj+q}tk0F_?Q-=z{JY%77nkw>$F zG}6ROaL_)3t$jX=ZtFG{Q=LZfNjNb2LK=m9l|7iaB++N|S$vAr1 z_gf3JpIB|?dptfQ{sOZGlhyj~D;T#hjaNh0X5(o&7)87^t@@Hteh{0DOM{tCu$l#& z&NhA&V4VR}nzZP{7i(5bGB17<7bu+RJ1}k}=ffSg%=+213Oy@Aj1vv2U>U>8tRhKM z=*e<21)u6SSb{CC&We%#6X@duqLWGJ>O)Ls`uM98``34g11;D}*7>c3+^c|Os&;t}`(BWMD zfbyr~$j%{6%DZ`kR-}s~p?0#&-5a}b?6tDqwtqY%ep0ypSRIB54G@|0J5E#LkxQk# z_&xE=d(U}q?*Rh7L7f8AM5{qdGpC<&t~9YI!%j2G@nUPoLPSiWHjCVP{JAe?cBjQ zTqI=R{nv5c@|R)8Oi3cTL{&6%XdTgDP4CNYT}q2f5|Xf_hID#;83kd+v0RRyNKYn} zyPahwd=4ncDORLvatBc~KzT+jiiD{tzd3d*T(f7ayS;J&I1X!xaL2~POrw2ST=Pr5 zu*c}fb@)0P6jv))kNl38C7gmnWGmlL@{PWOVYt9se*cS0w#@W=N+dY#V08ci=Zmg9 z+${f#Qfs5)hOPxC;q{(J{Kx4HF)2QMzlVtXz0-O&h2$VxtT;ROvZ13nN{IG>Asv{% zHuDqgZ{R2(X*hkO+!HYHHWvRYrvN9fl-1?x6b)oseZY)@dQ6O>9Y#8*23~%bzN~Nf zpHGMdS-G|%F^v3Gnlsc$s4Wl=ZEu+J6y~*Ih2tpmHfO56JXKjldm$BxDvW6ZH>JrU zdRo}=^466lAq6!qY_@nQ}5ETUEoF;`>7b8W910_Z17!r`D?QNvC z+WF%@IkPi43n4;0Ks`M{x*0-^GK7oCAp?pFK1`~RoMSe@jAlV8vQruCUNyQ_7wk?` zSKe*|!4ar@VSA}!ThlIB*Qa5){pu&HS!a)-{lWL2@o1486ZK_!!}FSZ>vyUPIOX#+ z5d3~J24Op?!f!oNytub~egnkB`}h?eh!QyX6&^LbNuA#9vH#N_7IL|#6kIDhLL=be zEg3Cwmw{A(cm{&T zPg>XIWX24$Mj_#^k2I91C@h;b$8WNVr&MLjEwgAUtSeJ2W0)6Fit}PF!K&1j=*+6g zL{XOUrqhNyPLemIF4C&hThR8fie9^fYg$yl$m!1|YgcPlO>TB-(X{lkN~X}R=GA!Q zou<9ZJV6*}SN_4WRsqzRGI&p$;9DxDFTlyPw6Q9rlo@E3tMN&Wo4eFs{1=RCUij$V z`8)kmh0fhTTiEyvRl90B%q2(Moh$jg7{NeQiy> ze!H{zbG7<3BcK}XE&V_1kFfGA7D^ODxn*@nqlp!{LhYb47zIUlV^m+7kZh^a7L1^D zvI?m^9PECMnnN$0hi^Ur0b-~QgEORanrv|`dd;ek$4rAgEEof3HyvuYoZ)H*;+TgO z8CJY~4YDI^7RD7O)m&2h2K`-4e-I$1zcZ*K>Cd7~sSxEXc{d7-;f z5Ykr56Nkie%=z4_LIA}H>c81e$%ey=2hjqzTxoO0MDe!J&PE@EmX49jQJJg?HNw;B zHRHr)3do7CGDa3lPAZ4LAnpT)spnk8(ZiFz$|F$1m*A@!qCPug>Isp|MPI24i>jp~ z((9EQ9W#Rz)0AYT&ZWOWKBNtdNYYm2QytK$o-_|W5j7Abr&73(MG+Ar4K!Ij=nKu# z;SNkveY?Oc!I|Vta2{rb@c50#p_byn|_tu>Pv}6YDydl|}X#4oZW2 zvq)Y@8iG5@6c3?uu4vdLSBq23P&qUSvtGcu_qgH*?KfaT)@QueLx6apA97FI7sXP=foe zmrEu7;%Z=yTTGUsHsjR(wU54xNPI$hLFZUOwh=uhZ&rLammOQ?w*)}?Ah#%&K~OZc zl#Owj1OCEeXt!ALV7LgJ=MVbCo}<%92WX$wCS~Ins}%5+sb*C{WoOT5*2%sgjya;~ z|A#;k?j~J9qB)Tku1BGX=MrZ}<%Z4}i$OvCHv_3vtH_NZoK zjJljjt(~Yh%aI@gFnM*e*@_*N190p^@w5?SjRMb66N_^3EZ#Yoh<8FM>Yx$+mTbp$ zjQQS7(rs2j^54CJXdkH|$1&$wPOGDvm^@1o1pl9~!5&B+I=U-f_M-M&r3zfp2%TH%Ib3lz-^t)+Z9E+>W1Bt1`B}rZ$hZ3{0n|nZKM9O z$?_1+y}fB2$zEzE$zC#46=0E_4x7-VXY5}<+d!g2+Kg$gvU-Xm-A9DBZz+bZ*zDTx z$Wfb93))oLQf;wKi5JBJ%$yq}m42lacy`bC9PjFg*}pCnqn@dv{k9WiwCC07;6n#e zJ499v3YGQ^WyYY=x*s`q*;@R_ai1NKNA}<6=F8IvJArr{-YbdY#{l1K{(4l$7^7We zo~>}l=+L8IJ`BhgR&b$J3hW!ljy5F`+4NA06g$&4oC-`oGb@e5aw-1dSDL}GOnUuy z)z1W)8W9t(7w%OCn_~#0;^F)xic6It5)3h);vuLAKFS4b)G;Z$n-R&{b6h@yGxGo> zT-cq0W7~n+qN10;1OS+*c>H$(GoKq4hGG% zL&XJG$PDQ6K^BD#s_MsnlGPE+$W^B`&a+Z+4;`*nyKil99^E(wW?t>#V_xYWHLl2} zIV`uiR-__g+<&m#Z*4E|wjKY1R2mCm%k2ayMSDw`Rz_KA!3P$uIbB`dl`3&A zmT@gMT@ZpAxBys8zRtgoH+ebSaVA)maP?G1=G4x^Nw3mV0?qehWL35vMI~p$y0hGL z6@vHf-50P~uoe6yY&*D)Ekmi06LF!Jqz9#7kMvWexYMbAn{}`{3ZBsd6$5jBCujDp z<0N?b*1%T<-_Nxh`lKtla|FFqs7RZMtjHAwZ0Ck&s{x`#^S?36BNQN1JU^0f&TRoC z$}c)LW7)-n$CmAg&n(96AycC4!4_*D(~HvXyLW>HORuI0;ny$f9h{!Ud0=X0x%{l6NH$ z?lttWn}DQL521;-r~Kf$N_YPo)7H>3gI@Ivt}GnR=8W~Nn7_PE_3{sRNn`R~bs`g1 zoTh`7o4H*TRp7VBp=%>&t&Cd*Ny~@;{C)P;62d^dipuJYUV3-Dh<#a&AIxtrmX42( zYEH-8F3|^nY-=yw(?^d!hTojNxr~A!n$Ao+2mq*kZ&>Zm+BDC*sul=~!LUtWiokIB zxc(dNwyk&5o;>WRt)Q-Wj;fvuvJO&DLPe%mt@t!Oq^VsoIN0iTh%fh#`-{Ha?a8gf zj^yA3`=_NEONO0Z?}YVP*dL{T}v|A&cE7$_0G=g;1s*WDQuRcq>cJ?z=8b5&i<)=3ELSW%Kff zs=my9Q%8?aMxZeDq=RBHg*&HnIeQ_}X@oh=f#?C^HSg?1dwLn#wu(o^uANrRZD;H; zYbOec$#wJB(u?w22{gV+zb~pv|Ag!q$N@^|6n+FV5-X=lR$jajjeRh$1tjht$URz1 zhw)(ksAr2;QBXH9T#A$6V4PsR7K)){JQb?79o6&*IwDPZknNqySIa6pwcs)~xN81I zKc-GmzZ$i(8RaU==$Dx{tD@4nph-V*=W{Ln97*VEN^F+u0!F<%$l=K`ikIp#<^Yt} z{rx1gk>;rVccPIo6hD=xPQ$PxVwl6Cl;YI6iLf3!aevhsyXXZovK#TOv0|*T+^ii5 z+YO`u(SO3@ybv-DG)w)E;@+ULoj_+<;mc#iW8{9Y!99vE`HdAK=Utac&Eq1uy!TLgOS-C1E90Am)B{Tiw z$>$Er{s{snLEaO5@u&zqxE@v;p6D&?u@40t{#VNA&7SZael};kGEwnHgD4V5RNM@g z(EL~B=A8&?pPPW-fTja0Oi6SVtI_(3ME!qWLg-uK2afWhBn(C2PAmUyu^2h?Y402i z9P03g5$1#etGdUUo?#skjQ|$*()ybRGMXM`-2?jjThnTcPV==7sg$k{GxYdF+S*zz z%dtBo(R9!7SW6Utq|wFpsKMSAH-x{WB|Cz62A8!p8!kHz1tM=9I=M&xqQG zz17xBW7t?Q?C%@4YC`p*za(>hOrK&ELyDQu{5ACOg9noZS1SGh{-FcLy_W;nf$N`N zGYxdIzy7mL3K@Kw65DmvPH0@&;T{y&jP^AsaYENi}q|A z3}l}5V?z_VvpHf%CkpN@IK`czOuLPY=yBUf8Q3b9$X|kEiYROV$`T8T7ZjFPvKhbK zDYxzz99JRNzsx0f1Y>IrIQq9o+W(TsB(ZtN@4*)DMGr3?4~Jt|37IBI|7oQknQI3X zAWs`45xiCHga9;8+W{|!Yy>tic?%SNq=3EX@z2Mk!P0dKG0NCHNz0*F-a z`7K?6d*D4ri*=>wyQyQt{_t=t95*gB1|tdTg45fR{KmKD|3ZuM$QlkX{-tUkq@3Qd z-6X|jEyZa@tuxB}qrdlJdc0{8``%3M$xl8$9pUzkFa$Ww{Jocp9>;5~oNC8o`3GK& zy7_X8YoQDCO1TU_a%#Q+rC?Rr`r)W8CdpEe=>uMYDx6^46V_1DthgX`6CnF*E+%bY z=GYih(DizXEVFDuQRPQY&dc2p;Pwo7L{I2r3;QV8IEPg1McP{PchEUDf} zbtSAoBMPt?&Q@{fG_3a7gzHl58O7e(h_F6^rKgU=a&(^WpgH3U%`tpj3CMVRA-uol z(hA)(VF{4@`k@PREUQJ_8w6CcMW4Pm06{fw^*>aMH%#ik6lD{{j~nT}Vw=wZ(;Ct& zi1nt}RmOGrVHP++5;Z@eE*lkdw~?>AJL_Yg!~p*adS_s1`_oT1B26S zt&1-4twO45pMl<5B9T;SLH9Q?E>dBXcy@5k-{YQ5K!A`=YMYMlLOYc(+LdC<@@UIZ zxq%vI<;6P)=W4nRb7nxQ9KGzXsOjWs_3V-2*V+r}?dAZA7{7f*>^PxEw|6+WS0wAs zen2zj2cFKIr`~Ai`YU|OR4%DQw8uM=|g2B{;1Ho`mx@??e)rX!p$MSlA70pKVcvZ@|fYLpEV~s7G z>#?88yv{ekJpeJL<-?FY7wf10XpS{B4}jy{uc)7esm&J1)ZYt5LI_{)0BkN8Nc}ep zg%SYD0Cub3?KXLY*-dYntrghE|}%?RY5i3yVcPFlheiJUMLIr=Xp=U-^siywr8MF^JAEwl2uQ$VIfuDFPisd}4W2ZxY$C`2`tBTA~ zG2P62@*~(9gYmO6#Ya<1TG#3rQd0BwVyNP@Ayt7B(h%z<@N>Iz;|2VkT8T3`anW@3 z03^F>TCLS9Y*sY)#=BX5!LYD9Z;z4QSOL2^Zw~0e;OutRfp)Xu83Yz~srLh8rR}fp z=#yHH{&=!mHgDg!b;9K@Ux99VmQ*K2Xn%gV6YWHHw(<_uA&($p}$2U2TIs7y+ zM7X5Yk#^wpDE4kQZmN3&VC{!nno7wD2`bEeAwS;W6>$oUt#~E57Imre?b54{c$`tHdB6GMC`IZWLL(%j20Bh zW@}9_@4EsYT$u1Q3ZPWkvYxUX{6AcsV{;{1w60^@wv!dJW7}rOw!LE8wrwXJr(>&Q z+xFe(e7mP=RLy@dYSfEoS{pC8KXH4kGf zd``z`=z(*mSdLiXj&Y{>&akI{IMzo@tD>a^<(r*Ssf6Nz;ZsaLra9mcD`MN8$2`!w zj#+BZCrV}b_c=qEqt7{oF$>wI5*0B0kP{DNQ5_-V9dZ<9u;vm!(L2I_#p*nprX%tU z!{;Gb7IuVBg7pdB2!{X!ZgHqp5+?drImJ(UE6~P2|C?+`E9th5QSv!}?=L}=tvcFMQuyE`=pek1zbRxBAFdgqqB#0~EkA_CpTe0`e$i(eyMD!C!D0SjSaixQMIl zQ>-Dj?K($9qMGwhRqIt28n$`*FH_6v*JjZRnIMxz-qVe_KzSGY5Ph0$(^e$r-hLD4T4m@eV#69bG7_fQ>o`!yu97p=$)>fb; z&!>)wS*Fj!ag#iKWRWiC735;`@XxXFT)nniSe~^1r0v?bQ6_Fokmx~(-O5D{7$d>R z#Us$PxL8^}t1rpnJ@#E}+O?`@a4wB;n{#!lX6WlOwo}C3TgP%?N=BT*FrxR=JR(g$ zJn3EhTI~xj_mVxhFImqt22JE`CI;B~Pb~*cFE>{uL*2mnfeKb_aYO6sDC{Khp%ba`v>+M4WqY2KK4@w{=P~Tzx42!1yHniJT#~*CHF5|TVC_n_ z&;r3b9d!f0;?+iQ8rT1N>MM-D(HQrU-WWU9=w|>nbeG#luD0;ayPj`4=&7Ik$Z{Z3~ z!oob~d$cMHx9;vjAfJ{XC6R@pzkLW4q1ak{?IimWUVBKithq`vKQD14&60gGKCCale{X}Ft0By269l*P6r zuTm0E33lN!&zezRh=5l@mQP_RAR5sr^}&4j;(eFAj2@K*7>|(4IdGb4yB%g88|TKZ z^M@nOtS|f?{!z}s#}S=w{R0`LbVP{k5xhlw?;F>N1tIByWsnp`Bg)hb4sZR>Y12=3 z!#Anh?EEZFm==f$1I@Zw1Y6-%6aE;!l&t#!4vB-%4AfB{X;!sT(jBKx*-5qZn|89Z zK%Is6JLf#w>eauBET9VUE&>aD*^+~!ilaiM?p&mM&kqY3D1*5QUGBbUOI)=eY1dMv zJ=ybPA_VaWPE1+MDhiYq4$DfAeVIv!IP-*#v53?V-c^a) zG6p$+O#_1{V`nNcS`{^%iBn8Oi4fO$#Q7x-$tp2dRs-etYmui-mt@P{hh?ldJJP!? z`!i88d>h`9rIRd6=^pZVuo5}3zUbAX>~uzA4C%servKlplCW0(Ta+B&Eey1CQ5DDV zf2Mk*YRAVjE>){hi_9poOCsx=BU4gQV)kovP|^v!npW_>^LFUzYHx;MKo!BEj7Xy9Xg-A6>kWs*$)aMAWh^_0Fnx;eR|2;L0ZjLl*+F1Moh4?D&8h6H6jJQ+OxgwJV51#)zSmqvRnQ5 zz~62JXPCCiwK9W;yo9-%7Xka%OtQeVDK5SGr51}$q@i)OE>BHgfOFiV%SZ5E(VC*q zYujoHFnnF^qs^WhZG}uBRIs4{4xGP&Tbtr=RJ?=4?;IaVA9Yzp!}H z9QDT#L{7Y?)r=m^ucWOjUuJh*FSmqL?!<1x{iOcP?l7BCorp91#(gUNGIQf@1)d1lXx(RAI zhm*TFNYgXZn_A}FPfh;WMHE%oCs8d+1emobQCt@YTjxcWoK81LeXY~+9)^+UOmeCk z)#LMg9G1`jWr;WZrrR$Gwve9&X+lKpB~*OkxAEnRpO&^BwsOm&TDeQBlvTv^nuju5 zyB8jH2{_Xtz=1n}8hD4nhhZvyxynbGz%2iKM-8|$N`wX8O-Toi=&@x087+joKHd4@ zsx+@?mPB(R?mMWCIeejm^dhs63ARzdm}jsA(O)QqT|m}QRWm-(Hzh#M1)wVV%1iJL zg(a=;b~-ZkGDk#mk1~G*z!7zGrRGL-8}=VILi|%;0knSAjJX1jZXYa@^cU6K|NAIP zkrpm_?r8?!`$D^>c>@hwX{b1l4f&cY;wwU&Q2vPM9oGB`Uj2&haf>bY84LFfn>4P} zUwt~VVTwui2oj$uGt#`OH>|MYjm8`R#n z{C%^u?$@fW&NV}iCuMF`&DU3gT0TNA(vM@&mV$M7yWD^p3 zN996Z8he29k4NFCg+9PbnZ$<&>5-W0fbtK7!ePTkfP37tvtUFQiW$|1%XoEZO`#0Q z2^XjxY40!DruxCn-p%m|j1RfInIaROco}Cf&3zhkkBHj&Rt=WZ_VkNJdliOb-H{>p z4n>c+XW~q#1M6<*boFS%=vdUE3ndU*iM+EFUvAM1=)%}A49e~^iF9Tr^(nqF(J^n~ z49*I<-WXCZ`1EG0hYOd%nsoM{LT8_q$a&QSBz;#S3YCwj?)0mjn_saa@O3c^sMqwF z!ZcWHQHCT~S|SVe5eVTt=z64&T=nI)wG<+4e2@}Gp9#uWEM+p-{L1PUC zM9N-bN73qWRRpT*YCLuK_D+uRgFcwsV}^odrD$A zI~cJDK#5qb8UPL(A_=P(=)Z0U`Aq`WLGuPhE^-isi?g-0`OZ?4kK^MyAsY+mxqt5G z-B14#h=^(sGv*CF8}cd}Xwl*_z1KEt!uP`_(wPBT8=FmK<+VOOk}fZ4Gj*{W-MSmu zygps+?d@%?tx#Fn|0(KF86C^QEgcz^1&!sUz|u||p8_`(gR(h#GELI8FrjSjfNCc zYJ9BHx9555<@$3ttNMYtIMa?NQe?V&_luijx2?!gBJ8tg}l4R@z5x73q4 zfZVtX0lZOzVV%@yTg!w5oMcYuMfGrD!RFwqChHhY`G22|vNLn!6a7VRi4gD!@Ae2K zT6A|%SwkYp{k$!ki4db&5nZ!Hg{8dj)h57Z<$r$9=s?;uzmx54DcKt)m0_ow(XjO@ z{}vbrW9)Fk2;8-9>tkzX!IEOW7lMb$gf~wwZgu2{whBB$YvW7BQSPQZQDy~)5Wh@8*P!VrB-YNi~zFb27ia7UtoAd`4C|JS~iU%&Qw1UMjN zC(CRqwMFj@{DT5Q%Z!g{RpCq?CpzVQqdKjxHQ1xa=u_EKr1ec5)TH;7hvWIn?hs@&K~48_$RK3+ zdu{2({Eh&7HD%B{)|+9CYaV^V1<$`JDFoj0UB!kwzCp*vlO(9kJe-Iv4aj7J^fJER zTEQS`H@RGhfs9w?M)S`;LliZ`Qvu3g2?r)nr?wT^cRJy(wBCr0MDqtRFHm$E%-!6g zMLRw$2+YPDN~0`{Vm}H&to@Nr&fF{~L0>m}Ghn>Vj81s`EIQnE@l@Jse`#}N0!!DL zkzs?x4I;fLH-LS+=E9Vl88}Td=@l&5&xyb1KaYf^1>c=cC+$#bcr7(`-gQsjD7Tws zxszZy^8Sv(2%nbY|4UVV<}>Y_l1lTjrKy;Y5${ej*V%OT0+D~Ec3-9;X zs?8%af6+X@s}jQO+NREG?W&1rhl(x1!Yfpt@?JLkH~UV_9l*DG6qvuakx_O+bAq=s z({A;t{jPMtJAA3|O@KE~J3M!)@g5`5KHrMBrNC_Vh4B|&pimlm=+i4!K-R<3m20bD zzS$Ki+QfH%hnUo)1S~{GWomug`!{WD(v+ zuvqIy(f7nrv3AgZ=8rf6?es-84@=OK6qbY0wJ-G zL(2?kPhb zZ{|(D3#69jUn8s@S7FY>F%&HMCc-%c24`6k2TkwB}T>7a66k$Rk>2x3dp&D-EP;6vCr%iE>GKFx;(izH3Le$SQsp0A%5 zm-Se9<@jb?{00JSx_;^KuDtmei!?oLZDoJ59(**b_6Y`2ZP$kvK4#2^Lk;B5oCirY zRlPg?{iEPr_J_ES2=O`sJ_qloEFsXBDQ+Z4sZubH45vc)72Y|~@)oVTzXL$U?w#*n zclYx8f%j*|f#eOo&_;}Am3`vA@XpB}-9L>H4kiQkO%r&~{%W@YWSeD_%B5+F67d*j z?Utu*W~cd#8x`Co76I~a0hZ}GzEOX;;hDT#z2m$G4zcHYIefxJIe3HizO!1pDziPE z*|lfM&rHZW`dhSY#7rpieqo!w>m&7!e)!(++5So5!vv0pL0Wxlkw z;_!rN(U5yR9=>CNO_J%S#)QEl@X^i< z$-v~-byW{BRXav4GT1VHt3jrFK9-@DZunt&iHnR->YIe?0!h%8oHlN&$VawG{+?<< zoY3lysffn`42Anr(od87p_%kBvtEl~1Jq51oU>0Cs?E%&n0t{t#)ExsgW$H{YuO*? z(`4X_deFhMU*%36&*Y&?o78sAOZl$&98gl@b9zEa>Ul`Eht&~4&@b1AzPD7{!Ati$ zwXVr7)>u0Sv&p#{4{|Qcx56H> zF?_X1-NV9Zi{jD!EQY!op(nLS=XU(DmJtXhf;wDL&4dvd`O>zAaBzN(?%law3sn1p z_#_Z!M+Gw0@Qk>REY&5+l&ECBG20Y4{6#618u0a_FxP38r-^@-!(PFvJl*UdjdBDn z11S4BYW3AgDE#Gc`TX_x<1XiTCER)+z?$_X z7n&6Ev$hKOggBsrg&CpBUpqPE1~%I*WKQW)@&B^`ZW5)SBHYAX27S#;6vo)8c5BcH z!iREPvmG%-xk%IahqAZVSke7KH%Rm!>V_tpH`>bSS4Y|tT-m!g!=Ni9VbK>Rx}WE8 z1ss1w(!|#dy?b|&w)Q0+&&lInD4O`WjJ{*tN3GHw8{8SD?rdB!ZRgxa1F<=81)1({ z2JvQ>m?i8VI<$}9MmtE)MyKN(H%%Ec)=3jmP)K#QS&7qL0o;%>!jhlVO3 z&jsJtdo5DnGgt&A^6{Y8a8ne9+lmC2B)oq7mWC?KoKbd`r)Uj|vMQx$o%)qPrk?b_ zW1Nh}Mw*Y_&LN|blw(R7 zFqMcuihIjBcSQDyLEoxd@%w52JEp%6+H?S#HPt_I1T@F@jW@935OmoG zE^SH~5V5=!n&E+yvOEFgM<8j%Fift}(j53d3V%1r9NT`}I%2p0$%QVx!#G2{NyO0x+|GF&XFcta601En$nx7I1 zQqAX}hG!*oND@sdrvXZQ=WU5MOE7QtKbgX45%?B?waqj`sNjDd- zUTH|{!iKvo{j~L-X=^?Us9D+2O!SG>$w%in^7zGGy+BMpnFr)#L4Zc0>7HJeEGS(u z(RiPD!>0L<(^-m_3%r!)MMdobk+T+6rOX^H>@PRjP^E3Fvx;U$0pz%a=(m-W6LZ}U zX2QnW7lPQm!-pgsRh$Rxq+tS|LfE_T9hZ*a3%%5EE8!rlmCi9s zC%T&Q39zQ(krY&I&{y3pYWA%5nHIL{j;9dmcaU{*@}l1i1fbF-HD&(6I+spEHr?l5 z6XUR+=CRY)I%wupKQI4-`6@A*Z2p1C5}Q+EOD4Yb@LB`10Ghl=YqM}RO`lWgijdXcY?-_PlpTe z5*pPp$8~kOI0r-}EJwDCeZBX!`~Vja_Xl`%VEZe$l0N#Q`pQFV5Kk9_nkJD}iNtEl z0C^Kr-ATPgZ(oeg!%ExcVXg|I_d=BoM=ZHAT`5PDZJr04Ur3RdN~zCSJui+P?cOm? zZ_4uvSbO6q9^3ohA?X&NT{--uRs)j1^n_QP0Q$3&rxFIzTz7O`nX?jRXhg1DeB#5) z(GfV1DF?0?JQ|Qk@MriD8NQBaWeKv2Q%Q{4hBkh-u_vne>zF%J~@`u;J25*=?$ zdhu8F1#*^Vel)g8@`n!4w}b9O5MZ9mGr6l(IoOWq9%{A1u0kLk75}< z&VTouJCQe<1WILdAsGA2MManwFz@+UBd8q0t~Z?>7i9wlMSc4rIngyRBL7^uYc7hA zBHUFVhg$Uoyx@ss=>vt^E5y7o;$7KRvv{t|CpAnB&qk`W5$c_mfC9N(b79uh8{1b@ z`%f{Lmb-*Z{$${zz}Myib@*kI7yMEizc6;Irq>h1)$KEnLBTf!E}{B15VVoV)p+aT z76}rh#zlkeIT-ez_6b@mR`!5_WT}T{kciOQ8yX_<@OT6_PmxrmJyWnWqxT>-Aho3b*pIl1(z(06k|pbILiK8h1e<%dkjsXB~8Vf{m4 z;ClZn{kzSkl4$w-j^Qx`(3BIce`g>_bgmJy8*cgJ=8Ty6LZs*o(tJ?TUi$1Et5WlE zPm1hE>IZ@-G>o3sf#8sEAr@8W4+aYgQTPkDDhUV$hNQpvpEmwC*qRWQY}4A92_0DZ zmPs>)&dZ8l5)X-zicS159QB4{Zwz=3=NVHv+vF*NB9 z1yz|msvE4PVio9vx4?D z{ZQdbB!aR@k>T3)149tjYac!k9CIDV$2WZDZLI0o-b>X4G9HSuePIX}6fDMrw_{k4w^WTJKctikHje-7u zn7gF^^f9vkrII_IBPZA9zyVn%O~I^a3h^!RY1?E;v_(46klc%M2I=TV%+aGbx1n_|{GwNit$QzspH)ZRKc+9Ky0a-Mj~~W; z9=1QW{@mQWZ0CL4h$4e)g#u@U;Tecj_=E}U`TnGM7>o{0dU4MT*|8>hhQ`?UB!zFB>>~9<{V@O>aC9U~Une3IWIR5R z_5_;sDvxI0ns0l_QeF?}X5QNM`1(*9drDI7dr~8llWtCKyo`HdZv%?+Yo+%2`Fb=5 zKSVr%FvKu>!KA)Y5&sPD zuJbS|=5`k){vruC`iTofuv9tp)kTGFd-$o@dfQ&XgVVImF;1#Xx#`I3vul#F$qWYb z%LOU(SbQDVH4RnT>9}Wa7hO`?yKvd%M<7B)^-9gvI0d9NpIMkS zRT00KAyowFDZ=SlDLo`s`r?978R0T>hJCU9`HXoWFBuyu7Ifhz-OU9hFUQuonGfWr zokmWPK)otgYn@!v?`Dtcubl8K1%*k2j$mrp>~SkW z=^_So$+T1|P2fC#QyVCNlVUHq?y@pBngYPoosbeTuE5F>N&Y)$kL=WDpkyH~cO!1J zMU8RHS*10ceS^H7l>?Ax-ySAEq;fFak>8M}foyYCs-;Rmzg$T;k1$Bi^ZQD=+=cv~ zbPGjC8@KD2%G>R7`kXxj(wO;v?YYy^+8h$cQIphb3NS8{p_AkYO+3 z@r-QEvcg|3shClf+$g=3b_M|nrQ|lu+E$yX&=MQ;_k3cF{6!0wx6Dg;;-oBc9EN>k zD#NH0R)&||qCZOZwIv9erOFWBUabK&8^iW^&#Oat0LxZ=F3cTrBau=&v4cK^>5k@gj#zWtyXj%YL_X!h>bYx@JNuVPpBwJE56w;HXl zZ1;k@d>8+2?a%T+rZv`KSlm|ckXJH62?JJAR z7ldHyEgPiZ7!yX$7!&3vTs-Y7hkx;Id(DrB6cEMyABU(*M((X7YWt-L#i`S$!5}fl zC#oXNEBbfMF4HSLYC0$tY1Q-u&Ykz7^Eumbt#?%(T*Y>yC7L`~p}oAkt~tH*7e4Q& z$EWB(at2C8c9em~sOw`1CvA#}IOF9Z2~%FBmb4G8IYeC!Dm&P!zH#Jna-NO;Qd{(7 zATVoYNg}*h`Jn02H$^WRu1L+psWjwYMr~!BZZ{afjMr|Rh^JQYjck*m8ZE0?)~vqw zSAykMDOKwNT}~IGR-3e435!bEmBPlvKn{**+>sru9y;ynv+RdQX`cNo_%uiQyM~gY zkNXTcZ~J38fc(I+Tg@T>ta#K|CyTKv73iu?Y3>J!+07C?lcTyZWvw|?(w33jJN{5- zynWxvFsqw231<32Aj^xVe zS{qBm^{P2re~|C%4rPHF|F>PqE#D4Gqy(PQqW(YSb36aV+ngr7;Z^rsa`1CFOVGl|5mBdB0*q*?%XBXPjPm^A~cwh}`D~ z?6gO&d^<6m>+l5?;>v6BSph|=1uthK(GEITC3RddQQ6I%I8e=$ZwLj#N5a1>8ivCg zc9PxY9k%zK80_2>^XcdCV4!Dqbplas_v^F62wKZCbfyb7Wbkyg+t5R?jVp_p=87)rAsVG;p?@}0DhfjF2KY=ur_sDRN5Z@ zBoczZ8+*l`4CNsWF7`5M9V-hSSKJz^0xO62%BvUldB37t{XX4Ba8~4nB7(_iRUV7C zZ;UVO848`?$wGFpL>#F1+QXS!7Eecu#h!577tuSg z6^-(>A_N+VK1MVMP=Fhb(cBTDWU#U9m4gz0I*3`Ekeu#d_-kiPg!qv3`67kym=Gc@ z4AmeEJ6{D5GT9l)0Nt?D)UZ!J6$_sfK%VCX&4dy{lH3oNgOFQ2La|}=(_+;?BPZhJ zbklwJ?_h@!#;1t8lY{2DbWMd63lRBe~A zUI018Hx{L;2 zP!4pmu_b}ynHxga0}8?m18nj=$kLnve9s^Ie^-H@{|7@7h%5N$^Is(t_dm!303><- zFJ^N8IbO0tDI&&}NbSz6da0ByoGx4z$_S2h1eJKQLn#puSq70^es*d-_l4(XJ#*_n zK*J}P(truL6NXuaq7uz`1IeN|p&1V&u2eyhN#=m1r|%dhlWusBQB&9Kj?1K#Hhvs^ z-dw2ubqArME!@rtqD~^LMn}(jgSFkP6{lq?QJpdKZ;mfckF6(uBjSn{+8(#`kG@;n zm3xcjQ0qycjaDG+MetaBT!=+z$|gzdx#dMIAswr_Th_kYiKDKk!&_UmUaRf(O6SR6 zzMcwVclitdu{K&Gt?B%0$DH%Ka)m`JL6Z#Jpcu<41@jFbBz1!FpuJbOJ)Z8kHKT}Q z_!}IRR?c>0&Nt&Qj;h!jwPEdQD`+lYT-#aWIWB5Cq~_MoaCWl~Jf%0pW3b z-Ku(nGC90fjj`rXh7Cc(Xf)$}yt?d+VM=r=6)FS@`OQ&6LV5%jY**8LDEo=q2-2;W zXLFz5Yj$C0KPF35%Za62bizyq5V&Un=D1ejqYy`jNUkEZx`7gG{jZU)SoHqE-`bUo zsxgy5URx|pOM9qlM|Bp2^+Otw#8?sx1ynFD)OACtwIT+Y1B}#snwfkd`ZNWUuZ1Dg z3J5J&JYAt6fN_#GTqdGv#wb8&nj)t%)0R_2(EHvf6Pta)r*dD@@=u{net~%WnTTt@ zjak199mId#cZ9@4m$bZo{wloNngnd}jm87j!n|hi9Gq)eq)1}J2NY6a=#-LWMACKc?Fn0eJgkvFVwzHPJSCda^P{jTCuDdIo7gYl<=sY)}+_Q3T%^*<8y46+?f*t zH^<~z8%7i-y{g&sZx`Wx(?%_9eB=1?F3Q=~ZWpcXS2{)%Z9?Cz?VlQHnd}xq*zI2y zC9dbVFHaskv)NGv?a~q}@_}vlro>|<@v`XmF4Xxq2O;^%wnr{e?a?y4zMGVO?J%x^ zqr6{Bq#9Sdib%!nZ>kG=6?f%d7)P_OZ)Dq)iWU>+(HwnZ2ea?AwD@Sgm6u&|?0uVx zHxW#~O1#4B=U!!E>x~yKjHM?d#H@c!rP-Zxm{VDkNw8W`WrERLYXUVKYIYoFqPj*A zFD}v?HkI1j_Hx{o@ika5m+~!ax#-9xYI>XIWkO7@)a8b3_C=V??O4fZ7soW&yvXmK z-Ps1%D+Tf_>unWrYEhe=B?nJ0+0j#f@%V`N7WrAJ=nVTZJE zu||VpNVe*I9}B7xo>6jqrpD3elbe=GMt4c$PzD=N*o1C^{TEqP{ol-`R~MW*V!kQ% zn+%OSPE%}dn?Wye?nKP0-xm5TJ80J_9&2daEWBpADhIPefDBt{al>tbKt)<2snTIu zZ=8K+!iMD>YoHCf*0G)b%;7n6H#1R~!v@As4^5D1lst)5TM3#`b+OnbI8 ze2bnPSnwdjYL}M91Q_*VgiH&E$IwTZ8S_za4*+yAgj5BfnG{is4=6UmO(6JZKUR5SgyC~B8+P%s38NFVIE@Q6rfXPzmilun?o|)VM7f+` zBdcF#M3FbOR$Q@j4_G#;NQenj3gRkK>d0ZD3{BN3G>@?AF2^t#o1j%e<=&-KcS+6# zm6Eq30rjfpO$--s?Bj7Y=s=H~<(V?^04ns*QVD^CIxlO0hb~rThyP*JH%;Os3o-J4%j@DjkQ* zLeNu35%fvejsqOEvSa^M)%+~Sb>V1HspK+y1Fw_zI1{Y*=POV}KhLx<6ibQ~4s47T z9GzXb!%Psmx}s#;glavT22gg7+Otqq7wiTH1hgtBRnI*GQ#>D9U4?Q(U=8Ef&r_)N z0=gyY`$sC*AdM`2lT31sy!%Z?Ys5TOU?=+5bRrov=-JL8B#s+Yvyd!I7ej~T!?yqB z0G*_hL^v2o@bg96In$!D)){V8(7HmoIrS38vkt=Hk`(G)a-;#YyjiDcdB0a)e+l(c zZm;JipJkXo>r!!n|Drb)#WeSzW$q%|2m4c~$7Z)uqb+w8Cuw%9_w^&^?xo*ck_nj3 z@uxkG#F&A0mw=OGT>nKcYT1XP=j~}ze zn><9CpZC;te(7Psr&pm%h}d%@$tGvUmk74-*flv?d+qOAVh6;i))(ag1T^!K6{7w~ue z!|EGUtV7CwfxW&=hxs>+K1hz!@B+U!ly3QxjW>KHQcY2c$WirWOqv|mZz>>sCYc8( zb%Zcz*FDj9+sw}1&G{$)chro>?Mq@q&LmDOu;2mtO(FN?UjNt5^ovxp;t5fo@QHzU z;@Re6YR|x?3ORQ%4G;Mm9#`^!7H|`;Xumbak->7ftC1n_fQOOC(Y%4vPXoHvvjLG> zc8D~=@;n6U(W)GDu&xX|!V_A-YIzVVtZDOu0=ci9mBwRhz zFqbia8@GeR7L*&w&8f2`d^!*4v5n9uA^pY1j~onD8Uz=Xti(&Y5Vt=jP7-gF6G4=5qf>o$TuBF<{bDQW z0b?DoR%bxUoO?s<1AS5!>{}@}*5I}_zrca*l2lfIwAeWp8$3sC3 ztEe~-=&EHrxI++EdY}cv7fZKqiMa;iYSBl>2Oym1mZ4f5e0y;F2GSZMs^!hUS$x*a z2x9lgyVN0Mf+2;s^Orv`y{3ztYA$?w2dJ!1D4*;^h;JGzMmFu3ry}jIu)6VTR`}{ypXCA07t@KT>O#Gs%@vd7>me@^RA7eN=#Q>CzXb-L%&MZzWdOV}12D8!Qm# z!NxL)Cak9k8f)TR!7r3e|{Z$-S|MS9FN8DrR3$qkh}! z<`ucgSNcmAQP!FnVJ+dIMQmR>##46@b&ruT(WY`9yt%YXg3x?K^J#|)6Kj>n_;2)0 zm3y_Qk*;Ud)nT%?iqrJm(>i>`eX-3+%cjK$o3rJfDbTKEad5T1T|O7#9NrqHu~rmt zN#ozS^(SDrA zsv(RB8@C1~R?f8Zekms{TPVD5IM3Z5td7{^#dnE0>oo=gjzot0pc|W2-CS6Sq_xY2 zKMDYyz&m62bzH&UjDIx#Y3dY%4v<=hB-68UFkV`UdO2n=$ z#L&BUcq-2)V8}*ybjF?kFjFJjt1T<@KGe!$-^(q=N1LgKCHaX=4v=|7;o~<0rzSEhRMu+*`oOKW z5?SX<;N?sF@l6-Kc}=7kTvS>_d~#^UkwD#!5W!16`VLA}O#fomaSk+2EKlne)J(XWzpHxYn7?p-1nR=c# zTBjb)7n*)FYNEN|o3!YkmYQ&hI$^e|!bc*!!0>rekNz!DNYZ#$6A^S^LvoH_P$Rlp7@a zv#OyyvAiwaMX5Am9pv?V@u_5A0mA!KU|3&r8 zpROC7?dY#2mr0fJZOR46^c1;}+FVaQ9q~Ysb}-iX@Fj05!hZBw3NZdz=k&|W(w7ht zbW%mADXI^t)}f#^V80V&k3;4+rO}GH9b8#W9#VgsSAjF*maJdH`dPzgJo81_2Xj6B zJ?M*!zA#+fIE5N^f$!-N9dpW~a%ubr zd_d2GxJYsVk4Ts)vAZiCi+n{SDW=MO5zSQ=ui$AD&S~!p9(aku@VF^KE&Dp%D0f|I?$O6l|8FC5g+$-iz8m9mo|L&C8{W5`2ds*u}tmk?Njg-NH$ zuYOT^Z6+X4k3hP4;z6TETdvNR=lR#Nrl9yIl_xy=)8Zrf?T?DGarFi;1Ez}5*}eDF z*k0GJ++IymAM%H#tFlzTmafY98Ox-XcLSY8SwvFPht`ItUu$z4q86N?zTuX>LiAb= zlK=f#yCxc&orpOyjF0y`XPSLU#kcRfrbv8KNQJvbMg)Z051D(nq^I#O+N~k_rE3^b z7d~@V=<*_xEmBf5X;pk)FMi%&)Db#b=!dc5kMQgRc5;-gb;nNfstPyH)^Ix8@L!5{ zlF1VP3$6U7zVU~d<_qiWn#c2qxq?4l>5EY05pwrj9OV5a;9Pd1I5*(JJPX!(wjzNZ ztk+_oHW*koHw&sj%v}q8^&1R8`YYHU@|{TOdBLH70I};=UY@EUkS01XT#dOHO5)we zAg~vu^3FrMVKr&i1H#u2m-wJuqWB1}w_x5H(JExSxDp4Qq{9U}k>OtiWp+5U@H6vL zBilZ%XL1Ifs^Mk%ad$;&xX#5S+!T>@H@Oek$1*TUQ21Cg<@w+eVAbh%`sIUJ;&s28 z&b|j-P)*TP#fmBIGS^y9D=0=;SE@SUw34e=<)|rOh7_X)eQ7I@l7#=2=zL~?Q_zyY-NH*)p__8 zXl=T?l&$Mk;T~zeH{2`IHP5}e<7FBv*>4~b*qco{T4Fe{QmTwndm8vgt**DfC7CYj^x4(3e#4BnUZyCm>k zsypku(lIZ7|KRtdLkDg0(`D|@fP#}ehZPFpUFrPB%_3QBQU4Pv^DH7{W{U;8ceoPy zV~^F5{ZZp<93x z9h#!%4@8_||RJ`FEIb~EFW}a)A)E--&5iii? z%}-rwtJHPYM=>hb??##Q1)hIGlDOZ+-FDeHJ%>og3OCN~H?Z~H=Cn>dYeGTf&^G!HJ;=j{ObHef}gi_Ld zJJ5hmjNqRtez^0*hgfd>{R0Zxyw&rJ0*4)#u8s9yzg-C?d25;-n4+(`D1;FQ>!(sUC3!(_REC? zbP^_^zyPg9hK;2vAV8PR6|A__<*1qLq6$Eq8l4S6miweXq5?a-nHN^HdIY!f_-o@u zp>Y<5g14Q{Vq)T-cj+<(iSIn49(9+qkL2C3?9iuc1&4aE89IqL*f&6a^^zfQ!1XvI zfXQM>34_t9t82$vL;XRil9PbsK+TGPzDy#&S3cjbOdEm~NI6t9>84uAq4u_*#>l9q z>VI>bQwUr-2dEYXydv#&S)X**ktfYGV57CIm05Omhc}Jl(!cnjYr1cFV7GftkGncB z&Hn2ZS{d3RwD9IFW43<+gepDlSxb;sKMd4%92<=IMHrjqXOhMtmgBT~)AzY1_Q_Nj zw@j(JDHekRvv=jqG7SP@l9|N~)7YfFU*pUw<#ReCAH21<$J61cB~wM-4wnZuf?!x8 z&@&FDqPxuKW1#{Qs|nwITE(P<^g=KYP1JZt=8t1#dyQx~P)ChKLSV$ir527yem+}C z&!-)ct4_`<5j}3Z5e_5){UC0`%OIs5&V!TEOyxa5zGJiDegY_wdbk620d=Q*!#?^i z2(l5VjooD9Z%&w*U%NHIDy}RGVS6`mlYp4y-LVW1;yhH5ADCa|jvjb^77b)wd5-wz zEa)Y94>QRui~kZH!G|4I!~88=%0&5G0eO<-nmHrap#K1XR^grjSe|Z|icAjz75nrP zACVIcUvi7-|NNp!+-;Hwr2EQhS0&}q%-04`%he-MLZ%u)DE3(ue zxb}WfOasYLv|TI5YXcSpqy`fNgeG}+nlPF93JI91>1BvY--xvJTv2LSv#U(gM20pcy6m*!qT-REi98kj;igw`RKd( zC~Lj(W4oNOhm!qSdy9MN+v(nUxk~==dUOJzzjMH4O1xV@F(@m5V@h|b4a{J?WriGBkzCCt>v1AD;OO~ud zS+hiL*0B>p#vMeuS<-!EH+B=*GRP8IgoH@h#@K0WF;|rG%kOEr_vJO6f6jBx^PclP zbLRXpXXg8SK7qpH#M2sM(~zwCG;wtNyn?vMWGJEWiqBj0IAtfzk9VBXz_y~AHU6~9 zecjKYtN>+acdRx@uVVO?`NcJ&LhT1VM{@&HtRG3?=|2^Z60B~K*p@boc23}r-TbaD z!>XBP(u5m`S#SH_8J3gct?H5V^cvy_&#begx)Yl6h2xK*oRO@Z_Bk#4%g%EXE^a;b zkdlQ0F~ST`@j9*Ukp#&{yF1LU&!?+q4-voEIiw6U1cY^&#p3_)YP{yLY(Agqbw4*} z8(ZHtUQ70I_%0rD;mz}WmdC+0xKo3QFeYCmLt{d-lfmT;q-hFyBwF=F%k9>_`t!PruazqK8B3CmUW_dDa zB)FO$wiBn55}KS%KJ)C|1^w#z0|)Q6S9)z{ffONO7hcJN5)R|W9vdu zoyY?Fc{jh}d(4(E0)-LvT6x;Xw+t|wZ!NgmE6k&T#;PUpagBt@kH>C#&)1QC7t?o_ zAGL6{))=~`ebD+i!0lx%G|ZSqFsmA;M>fkEdtL1C89?>1IG+_kb(Cs5{gGC1!-(ON zM}(4=p|PQTfWwU^_usPnyyi7ADZw^bJ=~J+bw8SzTDySd=E@>hxg8&3{L`~}(y3Z% zTbEOv62Z1^`_1$_4C`-6(Z~G7_vh=SAG#x|65B2UCPq!?^i5{&D_Tm_eSWw1uIHig zn@TUk&u!KYG7rm4?ApX8yR0$1&ey!0O9w)5rKNLOWZR)+LC!X^mE!XjZypOQMFo== zmvnO_yf}T-26K4YI!MOfmLivK-8F#=<~6fxyZh< zDenbKj-#aen^9$u0nf~#{nX>NLw5e4-uETs@zK<|UKD6Yl2Ed0Icys!G>* z`dZe_AfCIqLx1P1+N6?X{7YMGtt7VEB{zz~#I=XoGkH}LvBRHap207-`iz$gn{&4{ zh&b+cohV1@otped*^G;Fg|p-3hRt5gX+$C`FV>nOxo6+yY`w>cwW2^NMP27@_Lw}y zeaVVqMbe^?%#osXsOgU-hFW-hvZ9_)GLOA;>wpBC`+#W8jq)h_D@5#SkY(|uF!^Be zvpDxpLH;k;0&3`IV|#nk1OM7EvmXh2`2Dis?iDd54f*uw}jI5THWNIpIqj#NNJ0^2-^Wl*XFz;=xU8n9fv&FLCRIMSj7Q{ZWQ@hZc50(s; z3m6Qr;uqSO66T^?IXs83+G)5t6Sk}PG{2s=Wk-sPcMR5+`7w%`ajV|Oy3(43TSu+C zM~-Zmxa(}^%;=3m237SDD%R~xy8}xO5~CNQrV)Ltrk&z;N6jZt9)3}| z@p0saOnkL#elg?UO_@Ig`wP$CW^}0K&8wf#eIy++_>C90jd2LruH+s%w`}ihw92os zil}cNBDANCIN?G$uC+&?1()6!CWQzL*!D=s5W4p6HKG=QYwh{gCf&{3AST zrcNN5Ph~ju9%GXq_H!sthKqWX%||#6QQ)I!eFR95MgKL%q5H-4IkR`d3zHeeKHiFy z(u>-81|;aIADIjbIk)%244uctVlG#1_LwwztihjJ%A5%KqOMyC2rvu|l#eN|91lN5 z=Nt%}c-$Ej=SrDJCxNO7n}28o!M0qw?(~+_vJ6vZYt6Tye z6T%7!VXP5SO7V$#{fL1jMC{}K@z(d_t)^>op*uwbQ*~aco^uJ0YYm$`n&-3CT0M4^ zFXv+7eDBVP03x6O-dE>vRE;nbk$iI7r0?Z}g>Ni#E!lJJj2W&fiz6x=Nh+D04r|@# zfX;@vAkD%`Z1>BilpnVOI0lkfdtaiv2ozv;#fqmZm`>4^9_7-NWrc7gB~{=VO0r|6 zi%rTpc9bR18A3{*7gMjq+3UOVpKWMM)QH+;&%Km}>K;^!mqB|X7TOYb9#>(mT>XWq4gBjFX0woPN(1n^o!XP zq~rFHG`l8OKHGr&=M^G~PMXO+(xsUFhg$FK8?}<)`m7;V2eyLo#pS zkX&aXT3)!$R%e?x&V7=z5>efncx|Ql+l*CJ5z3#j#p$}#Gqc4tP0QJgNXW1p`S}VFsL_g(d*5kcnN{R|e&8PrW zKTs&SOM>;#Ax#=6M1~6G&d35Z&T2GJkrEZ6pOpa)9IJjGsXzsSkdS{BB;hyeOv! zKFJJDEwaGMyunY48gwI|%#ti{pmXrs)Mit$ZQHhO+qP}J;Tzko*tRRSU9oMal2ljs=<)aX`hJabHP3$5o@<>0 z+y`6!4c0*S13}rfE2|m?1cU(-1cWwa-VZZH@dqxz8+{Dp8!E4*e5J^>D2lW|f-j0x zo<(~QnFNO1pI8`Gd=Dh1B^mL?ab$;(Lh-=8JXtcDpd5?J1y(UPr2%wU(aZOC<-9lL zfcxF*)xE2UIN)87z5VfIhVHN5;|_d+;QhP>h}{S&#GHB~#GGp3!G^1MJbr%lo)4`o zc_%nvPRltX1nccyRLGDVhDq}twP!iOEwD#^U`j(>W|X!^l(A2Bq}thVpjupbJb$tJs_GSbRy=NhT>;2vm1Jp_7P7}k!J11JV$6$a@ojwipW`qx8>vXJJ zJ?zdA<96Wd;j-7&y8wUZb`0vX<7W{%()c?7O2Z!-sp^ecl~$6a?0}R|mAP(@jFxjh zIhxOTBZ1C!Nb1X5dw}fW(aiP!kXA5QDScnJ7E8 zW{-~6^Pn2k&Fjj}2Ckjx{MvEXtEAXY>rYahfIyx>Hw5VZ;Rj7GOVwBeZnpy+Dv>P! zGjqds6s?W0{q=I8gany>eP?xNX%WZKX==PuvH9xy+WvMz8S6wDjx)_Zewge9Gq_0k zEAWR=HIJ|Z#=i8{dR{C6TMglt_Hv?R_Lr}FzoWzvzrxeTP*T{hrUn}X4n&;~;bm)n zhjTJA;7Z3(7NN6M_mgz4;=Ac5MkX47SN*K1*q|LqUH{umM_55_r&15}m{Drjev2>) zSD%5XQJ(QP3Kf{R!Uun#|9FREeI%^-Jz|lJy~g+~DJU z@}jhnz%n*4U3{jH#O4aLo;oZ~;-*?!?e`q^m&_*lUsR@Vuugr{mlw7#;AMPBJq!28 zFJVD=aoQsXXU9xeE7pV7LVn#q{p!VZ3%Y7}jE47Oc_kZjN{$2I_Ih`Hid_gb!z77k zLEPp?R;<|(jHShvV>3q;6{-VZbkCCwhse5}9x5_xyKM(xnjv^V-XBsASA(EHumh^r zu4uRPY+C7=BU8QW{OGSZAfm^B!Ait0-jY>*sG>$R-+;7@n-8id2AU2mHkJf0=Ox7L z3wA>N`?)k>o~;OBOg*l9-c&2Ax>sd#(g1YY--PWe-tT@R^ihOGFOUaF!s{7t|8@Ch z_a_pXzZ3hE9!TK$1W#azp-gEOQ-WuU#0`utpn2;A8trA^l6q$YQF51^@s+gh=n(ox zoxo50I#y^dUD+qqZWwdRChW+6_RmN-hX4{Bk=n^oC1Z8WWcqd|_FqA#1Txzjttspk z$qnVX*9wL95^mN zFaghCQlK}=ONlTTi^uzFqhx1MtD@5q52vJ+NFxQ!u7FgleEERVM{9Q0KxyV+k(#!U zjP{AHSQz$~(Idp)Q>buZc_HZTh*;6r2LVj?1C+I;u46gWXMuJCdyY<=&+h zm4(^0&>UeXB@WOkTUHnuLdRJ}V^~#YwH&^#l%E<;i*sXUO>N1{m4ma@FJx=_#Nw;< z>DuvrnXPe9bTKX@WWBobWN|7oK=)Lm*uH{jQz)jjk}-j>shi7zn|@FwV-hX@U0v25h!EE-T`2>;fbnoybY~s9BLR+`KF%Q zDzbQ>Qv(mtg1L{<#PeylU~f84G=c~OVgw9kph^bB%mbG$j0Gi*<7%^`biLCi$6A3Ua2o<@&WZB%x_Qab`4f8RYu2zo&RGMRxDj1!RG($dfM3s(BZguTy zLQ~Oa_37Ex6x&lHa@^$nGLNS@^H2-MXqXBgn+7g$+NPHtFwcLI4Xtep*>ku19Ga^p zp#I$0_;mELs}quj#0<%t{k44%{7sS|V3?G1-3ZXqJ$R|-W>adjIc-=-Eg~5@2km53 z@Xnl(UkDbZjcc2EDxRKDmzlg3g;+`NXn<32Cs&Gr8M9>iNKNBkYED;3NV$c>%@2(7 zGuZSz;-4HW^C9IKoKie9{tDcJelMU3LgIin!vgno;{>zF^|F}Zn0+;$q2u1o;iwNQ z*ah^oyIql#CiRE(k02Ch-UkgWPBjjbKsFW>pRn$MumX$j zqFLTNU8r{i;*{D$hD+hOUa3_r7*l8 zv!m^zk9RI`jl^J^vt>t_yJad>q#1C=@BvNJ3MPiI931*tyGN(dfE8@a@$)+PFz%6ktHtd^7EFEspL&_D^Xzo&X6_DQ78wf zz1psXF}CZ($`6(2F%C09Pw5W0$pQWGyoi+#B$=AsBzZ;_@JF(*yWu_ba8?#NS)qv3 zq)8|X$tO8<*Cm-6pLzt=@HH~~Whyl@SnX7DTU)W*f~rdggk(W%Z<}b!YT6ltALyJV z&W{eSCYIj#IUky_2kCU`3+UF0CXWJ{R8hft0T~UY^%aGF@Oo1BC3Im`#{kkc7=7sS z8CyJwKM+!`5Ng(Bjw7C=YqBjR4pZ2q^G&dX1t1Bk9B9@gNUD)hE_4oC1LkMMj*Bml z!1|Cs$=oA49A5dB(J*y(pS)A`;qu&G&y}CmAx;G$aS6rh0|Wz#;j$XWiYE!A`t z-nl(heIYdB4%$A?#G8lH%12=MhxWT30nM>+I;h~}7?yr1=LE_C8i57|Wo6{sNQ^>; z76_DvAknlKbXXCYyWKW}OVJIAO$mR9f1kA z`gr)*`~ttfA25CqYm&2*ElP{2i^7qjnqohhLcekYd2ZllD!}7e;-T;lQF}5|iT6py z$l_@r6W(PRz>DAk+cMkZ60X498M-8S!#MJ%S_YjdN(}{_^tcey;R#>;6?L~{leV>u zPbWCJT!zM&*IJeiG+#{cHEvY+ z+Lzy+60#``hEJ4SM{BO+Om>~)RW=p6jE0QoZkC2X1^f$hGAhP8_=LV(#|^Z~1k`J`5Y4{&kph&!7&$xsda&#_|163LJY#sev-!dySjv~soVP|ZwnwS8hqE7eW=?jZIr zi|q0V2R4CbUK!WWlN?7FFNm=IV8vl((EGk<62$xUXcUio))$cnA|RzW;>9U(Bnp6*3SvPm@L)RUplH%j@jDW74248VZ*?j*TrNov+S$c>Dg~fOE1Sik8ABjAeJthLGdbJHnAQl>~+P~ z#8EO}Y7Or4mzgHx>OH=BF}4#ZoI}bJDIC?5J}a%Y(U;mvo%ZW1r2&8f2;ee-6!*6Q zFsae|^`2GCb)p)TzZ{-!^I1Vp@Gyr_M=`Yr)@w?iR~9Kw1~6sAY<}DOF4BFc>oH<+*sWy5S1`mn zF_U-HR381t#PQ`v5doZKTAbNU&Q!FVsUhGIj1!oSU@eSlp5BJPTk$s@L7bUstn`sLU5{#Kyg$T}jmaPaIaQUY)z>ik7Gtj+=Nj;AU=gg&6F~`6+*>>bh zaKRIBVV{_t+a0vt?L;AJae1#NN3)b4T4J^{&oTSdK$>TA&jL2srV0Bw&K~20G=K|j zcmh{_ur7h{M7$gy0P9R^qHnt{2bc55gi`-njR>CF3==d!!^0k-~D{^(9K>;EN-H(QO zcZVNtB+4?UGKW*dGw=#54>WJ8zmpFY%WPBA)rS~ zPf*sTprcOzJg7evUSu! zamXo{%o5}g-xEvC$qkF|h4Yc;6zl5`G@*CeNRuDYY_Il}tj5jasMb`Qx$ZH!@Y3k6 z+vHg^XC|{@Ma$u!yS5RwTtFrB_OZi>IH14e>hHj(Hr+h7{XhjbX zmagNjzDdLH2|so87G^T9=ht^OPok%n@-B7JZd+EBohHA~h|rvTnJWJ-cH5wU9a3e0 zvh1;5>}1vXA)efRhiI*5y=m#|(c|RZ5MCv^G^Vm~bPhcT-P#6llM1*B)Q=|}n#G%- z`-^P3y#>dghcZ-yeS&?^yJeObqdBxnZ6z*>=yfI!cY~2T5*cEWyWcUED2Q2p@DKoz z^OkzZ20>xZGW_|beg{&(M*r^H<#dy|iqOg^qS$Jzp;gQ?*iK&xyqwoSNqVV9;-wY>Bspr8Ti;34;h$o4MC1^b+y{g*55ZzjeWc6f)u8Ng9YEkK>jNC-{Gs}VJgcq(_Z-0ggT3-5t0G)sPE93~qXib;- z5LBi{NKsUJY%s)ymtC2A6uR|VkQQsmlZ8kUrOP}~K7(I=^oSkGxQw1GjA0^MV%;%L z0MBEeSY!ch`*juR$+7!jxlX!YaQFf2)qaVx6X=@~yOIY|;Q7Tu&urcxOemAGWQ(_% z&%;!GQtn8uG%}mcAx~*me%RC!O0xY2>NJ^*f>P#Kp-eBx45d;fTDndGZeXa&yJQ*0 za^P$+D(OSmdXmuwlJN$mZO$v0QWU^gG(CY-0dir%z;;(1zsS?Q1AKQj86wg$o7 ztaYCK?g)FeF_ehxGfp3bBUXIuApba`PhLixgH}sI7BA?5T!650fhsDPJussQVzT~L zP5z4y@!x}?g|=E(0Tcw}790dbGQ|XgAO(pKDn<8@0#K@EpoAuZF5va2QMp}pDk7RR zQo~vV)0?F%tU^IPdpV&b?6r{KV$U;U+A#_+^7mH^Q|6no{|gb${o(8lWT=GQf!OKn z7SHRJpQ4oz;O`yEFG^0h1{E6PX?mV5jwt~=Im%x9VoS4;QCgDzQhy8wG}fsV1JO1V zcM6lDQh@)v|NL%>uhf-KE=_w#{GDgG=1DGP^8y_P>Ioics)A5zUA;TspE3o<7$qF=&{j!*nQi@J1H*qy&fRj5}9W1>v(;&Vb7tAwk0(9 zX1sh-ItRzL-7*><-FadFS0C!q8K!i%5?|hQ67tW-8Q|}R+f@|t;Ic$CbWHI!seIY3 zIe^OgvEl}gt)2MvJ z;gtLYk>PVo4kG_^Iw>~XrqR+p-OR`089eK{vweJqASd7@vpFlX(jNH;^z~{Ws{A6+fmmO=-OL;THV; zus@QT@>O?g;0>5_oN7s6A7PvE~9pb-ae#N05e%sWJJtWYNI&ELSq4mldQ2=9# z`vU(jc>Y(av-6N3Ae1N|AOimb-s~ZM${Za5pr%El7L$$7&vy&yFYxq@%bWY6mo25l0o3OGDC2c!%j@--0`U3x+zz69A0F$wMN$02 zORhsol7=%CP5jV;jLF3iwdX9hOGcD6I_cCYPwEqhIezA^T%Q<77F`*0GiNr`~`L^B*Mo>e6ZO63)@J@Fqo>rU@%4g zBQ>m?f}iZCwpg7>R&Sj{rVPv+iupA-bbx1enWI+;``7|Oa603ZVjH;wL(-z&0Znn~ z5H9}mw0MTe1(!`*@n#Iwq7e=93k5VifES@sNo*bC9=`!3ii(saI8k~MU(3w{W)7{j zUX%$8JUix+_eX&S!K$iFTT_!=GiOa}i2>Qlq6IhOcG@ehjGEgLCyOEfv2W?$yv1pA zIb$!pW<8rs;3lQ>&p@Cd-A&~|d{)*yLI7wXBAv);-Uzk8`9NG(Ky@37L}C>qfUd6e zgMD-F76jWB3f@)Y8FvYnC7_nl=kLP-EIK8{+(i0@Bh^x9*Ey`dUcv1SFbl|8Wbv+X z+>Dkf5qZzB{ae|1+de+rvRmLoGeaFkTUW>|t2w31FZASyo~G8RV~8!DIzpA#uX0+B zXHtKPVE(#Qq>@_9kejW*=R5@qa7|1{-a~8>5rzd3_~-AbzRQ(`p<%kc!Q>RHp{|e4 z>=bO>kc~5O#H+3iU!9SYvvKvKb2bkFx_(qz&lP%RPW6rF=4zWu)Z>aAEaQj;Y>~C* zd`Ky5dZEUEtA5d*WDQDWo^GBzYRzxlwa^Miq`Dkc_xcY5)mpuSg>3PXOZ9jr@1l63yCA+^HtdWt8pJ@|jO!LFGFVy}u}e z`9~i8`sn_Hh=0)wWZv|J88rD}5%(K@m0GQ%LFkt2%%nt~pa*fxR4_oZ&z6)y*p{zV zRUn*J)hw+z%(U9$zKy`?{&d8xow>zdcD6xKtAXOU=+D5)B){w~17M;fWPpO18Wz$F zPpfrhxkK^mad29hK&^B(9#oyT-bQm*N)ngJ+l_Z0NGuDw{ zp-TM`@@k|JAodN{0HDOHmUqiSZjMZv*}sq(&f21cTnsw7^9vEr-tqJd5DV08SVD{1 zDi$GWtahLiXqnw(&tZ%5tDgmLru-2(yb4vjZ(qv5W3bNpeGw|#&y9OFCXZ9)J-kpE zU7p*%^z+d(+ha%34Ov~uopAsIdP(*$g;)#4oa*b1rnr}r77$-V?h9Y~C56Hp(qw%F zJ-9GRmRO`9g&Z|YW&CcEAca>8NAkmzX>yoQJ$j8rsV5k>5eX~uOPh3OcqOcP@HE!W znPD$aTWvp2dkyt=_;I>RMQkU?8!MSxIJ-YV*9F<(K+HWl zfgi3a;9LjJw*hu7#j*MvUvvTj?%W@Y7tDdn`!|@JbUr(@HCM^e?U%fAWYDIa&pXU9bBOn4OH)GDN@ z!C859;_}Q9pQ>Btil0}X`c44zc{qF2d0_zX_hEycusnBiKQCvX`r0HMy7gwSAF$ZS zf4Z#M1i(MwK8bchM%z_W2mBH^kcy2gXpsAiRk?@jO%5D#x#tT+1?*|L3_fb5`ZvWq zwB;P=M;{(_5>Bem&Y=Y(Z8m_}xu_*Vz#+%y9Z{{#P^mEPr}wM4p+l^Ba! z^ZK?EMLCCHGQ9UQ=|*cl&?WM3mGivfZtrv-tEkKkF~T?3@IW)kyU>5Lj(oVUsPtcx z_4F_A`2Q#Cc#iM@d1($xOUmeDf4%UwS21vCBNODsH^7<@l1M6GW+SkvvW=Msw6IpE zvu`k+_=@i1oSv56L{YwJaQt!9grhmvmP9@*uZn_1YHeMI>_XmPyjwHu}yYeQF zQ_0X$d+18Ra;isQFq1C8Dugvb=j^7A;-)T z8Kw>?m8MpJmwyhH10(K;hEnpTs$(9>q=neA*AeB=PclT})o$W0;XjvwlPGlY>qu$5 z%)3zAuD1jy#z8G)yz+!myes)LwIeKJcV+cauP-!z^ibZFRWn$Jj$HJypESxTxMs%E ze>(K3yoRkWh{Z1(r;RdLwaI*MJ@*htv`fr3Y+B?*Tk zPDkcp8W}1Y(Fcpzh&?}(5E+Ov{KJUC0zOyyw!#U|cpQBM6$~RJmDIz_zt>A?e1Af~ z|6Cl#{$l=BDx%hbDN2}Z!EU`yxISBGo=t!u;mK*g=+u*3cL+3ENWIM}%?^ecw&te5 zW_gC7GXcN&qcMoFNQF+E_xAt!FLiJ^!K!~m5C0?j|8;M>92CSQE(aatshs+g6eTnY z+j75!X?mS$FeESvi6JCto$$s|$T=AR!@b<75zp6Sfx(qnco*g)2L$0em0$*S%hbZ z`hR{Vo>@$__3*(XJr3L%zu&`(nXgo;G|8N=TXR&Gd5=~jJiw>ohjP*CYcIY4@=&rE z#Xct5tax4~5wZGoHx3C$T0J&7M{Gm8>ts5@f6=@3W}O+RDSWrtCR6kTzz-?+Jw^AQ zghRGphBr~sclWV>=aNiI7*K9ul%#XN0L_Sy$>YiW`mqe0N2Qjo%HtZJGoAims7@)$ zVV`7E#JR7X+f-JNM5O|kGMDB732L~GrrHBNKs{~ch6)pyDR{TwteT!X`9@2aHM;hy zz)X{d485vt%S>Lv)4<+}VBK;W9_yDArFAvn1fa4uq#NFBz%4(=Va{dR6{#y12G{=r zw|<4N=N`QNPIBsV%3PzXvTM0=e~VduZDwX>o`Fzcv^N#4``PH`*2NCcyi@AwT4&G9 zm|QqlDoM1640-GiR+*aX{SbyyNP-J8gwrG&2ECNMNaZ=;{(?ag;EJ`c^sO_m6WvU& z&KW{JWfJLc6TN_=I|p{1w+xMP3IYFTI>ua1UA^EfWIRHwk9uU_fq;KOET5Y30Cfb1 zk?ipC>Sui%?L`3!WtAX6cY{lOm!ucULQR)dG;3^!tTW=R%&CfK(}|8lW8zmCve^`iz7gS6@&q+I{Bt&^)2la;H9xqXTQ2Fm}r=k9Vqrd)7KLHr%9Fp6vDyI_5UvX;1dCZ4Zv>} z$ryCl=d0hZ1NyKUXwe#Ps)wBY*-M@Z=iYd)UZvQHuDZ1>wM;%h{+pgbM z)wWWm6In6A*7gjrvMBF64|94eJB^eNp6T@<>=JdtS@E8V!;aO+YJd^DfZO#Nj2wE6RN-CJ?_k8a;F8f z02oeQBD8u)&aFG<5~D*;8i7#oOmpg9UV#=Hc*jdM$QC3g*sfMlW@m?O*WxO5{6cd3 zX`ejZ3ysbJ4C^osr=4^_<}DyInJB!z@Tf3ms3<=>a}YcWQyM(IagxaqV5^+3PRm0S zETO@Ck9QOso5yG%6F3H6>UM8A{s|Z|+TQZKdP_YYw=42PI*Tz6EO+ZmT3cr0cyVA^y%#9?eYNQ2o-rbVekn1#E|tto40;x zKcvM&tt1g8<&8v4kVLh!d^QxbXF|0dDGpU)vO-C0#it~lciKZ0=teFhq38x5LHsW3 zmVFmKm-vu)H3_ccBrwtdF@;CkT(u*-lG9TC+)?U`%n}V%SHy4%WbPm557IYD&Mb8X(*P4x^A(SGZECio_ z*s4!Y947&NIu%xz8-5lJC+fEw@NF3@KZF}VwjNyT!HaQhw&u6R177I=cCNcov*|zL z4sKxdF&uJN0--#AC2sH_I?UBZ^j&k(?JP9jNu0gIORjh@^dCeLH$b;*K7N*MJdO03 zWg(1l!uXMI1#Dbp-GNQb85mVg|Kuo&%$_~6i#QO^jCanlgwna0MXz!njj2i_|HJs} z_=PkI8Q(iln)~HJ3Lw0pE`T1Vr8Mlqf1NhU=NF+#M(tAP-M(s9~Q+LW5xZ)iOJ z1(#je@5p6<(pG|a2{2uPbr}1k+3|h7!c&*6_haZcaoBWik=N?>@fi;aP7S7@xAUHE z*hn#x0M}eWpyz53`!jsehk_=6+;mtHtYVJ6*#Bs${WS;Y4k*=@q6a2jE}Ldvd@0RS zxX`!b5Q@(M9e0b9np0*xXq zOmUzs5|0}@2Q>f4|3$1sI>jOXD0tKvk4p3lRY@W&oln6`bg?^p6J>&7izET9lOlGX zab=n`!tbc^C|HpyPT>Uu^0LO)H)a$kVN8djN0gI8?-Sf1KJfI+?yp3OdW5L%Xo^b` zM-xA0ssWRA8Cb_r!LI=Mg}x9d6v2pyq`XmuCbQIADUu&UM+(y3T?u70KO-A&|4XT{ zLZAkCO1+p6VAp9;8U0(41|7~VXmgnd1BDA4Z>1L}mJ(G#e%vx-V`ztQzJc+0b<0!o zFO`x1!Z6fdkiXQ2oeVkK#3I=(r&9fodAGTn-`|gqSV3Sd4(2M&Nn#8MW1JV>rY2*e zp^1L`GEBZQfJHdqpb+Nd(mlJ4WVxXMC9@+r12TU!qw#5sgwj-wc}Q4jdCPPT{ETF?@Uj>Nt8%IAvk(o0faQv<++d z^?{2ephHKDBrzhm2lOkIhqLVJ^fhW2TD{@?xA_z1IGCgR-Mf!ATb5BBTW z<>EuEG9#_MtNM2?NFkdi`!x|invBmdf}BIi01*t0GdJHs_i+SZoI-BAG8E|ROq3vP z)j<=o%JEUO_Grn7S~%HV8Wa8z@6Wh1y7J9Q!l>En-QgU_Xmy8*^8Q#kxl~)->TA(v zef4ykvNXkEO(it9N^k|u9A#!R=ozZMO&PvT-a!#AIvk@yg9>dq<99g@HJO}R_J^FC zBn${l$A3ZpONaA}Hp2G5WVV9>0TKG2WM-Dsf=RQmWE$xFjS!((M_MX8>^?*%zX2k@Xy$a~*t`>n;%zt)IZVEq<~ z$RxOMPxD>j_Q8hmw|rme{S85It?&?zz~@bM$b^9G{?s3TV8Q=tjAaFXEeu^N=8ZyX z40~c_xY(@6`|CihpJU|>Ln1%kpy&^U(F}GKPNAjbhXuMv5@>(yYKiigyZ>OGMJ%P6 zN9rD0KLEWk!=(zRo}03Q@+Ww1$x(hyc9g7A%x$VaKU2#3UIk@}$Fg)IW%)%Wof>;q z)dV}iqeWM|E{}rB?0kv%n5nObtjBU?8ZOOJiT;=?#hpXeQ3kB91nr7!no-pXBb$a> z7i04gJV$ozM6Q2LI&Ob%<%B**Zh2eH^OS$-D*&{gUcDd7rb%0h4Ppuv|5*CM8+@|H z5~qGbwVz(ilVPn-I!lIP%bdt88T^TJug8iaNclGU|UAFJt|9q z96;UBx%57ZCC@F?B!Ie&(}=YOZsx+anhH%RudwPi=BCupCc^yN;saDfMU0y8boIs7 zpk`aQh{3}FhRt$rl*0xyw$*YLcH|(c?8af)PKtR^_J`a|oAvZ`_L{lbdYNPFr*2X%M5x^>k$K`6R_9iuS%>}$6YR!#e*x(9F^Y)fT zFJ8NQ5QCBlJJ?pKkf;nIXHUd&=BF(MGOOXAI9`0fqW_X z;!=^x<^JJaZOxT6?Q(J8R_XS*_D(i!;4!rv3WyX(?eL!^JdCE1GIXA;nG^FHq?vlj zk{WZ5s?kVJd_$`1_cg{ZiIR$V=z!DI12(eSSO-FRfl%V?SoULOtY-@HdHbTJ2|SON zSp-@bvu$}3baxB7TUSy?$P3Kk6b}utoD7@wj_IJYb6LpnoG}AYeTX|~Si6l`^agE? zPUQyM^{XM?;R!Gr(MV@dYC|j>=}a4nQ1H(1dPf-DnNK@BNBHh2obBYi34l?apkiBj zQ3xy+A}Y!pcrGQI2#}4{3KJemmHleLygC|QHAH2zN-TxjXuigz$H+A2C3G?ygw13v>_}Q)=jIGy(J;k;GZ)u$c9OXKm!Zk4L{=it zOtz-}!cADTgcd@Ua}TknHh?>i=Ah>2U!GV}D;)Qje1rwu#P2Z_|vpx0h50+0zWP@{TNcP;s0?A5KD4E$zWB(1)gq8MCVzJTr2npH)Wk9bQYzkJ0{|s zfSgN(g&S=+JF@WcLr9q_Raf|}Xg&C?AUuSv8p+*(Yw?O;hFO?VzK%Fb24G9H&7NO} zk}^N~6=L#03rmRt;CE-Jdj+sveP_3Vq$BS;uyy=h{ocMJ=^Ot%dEH;=h@gb8IW-IB*TzqHV`{AfTZAvjsWQMAAOx zrK8>Xt0X!Oi*?q+V4B^hE@UY}2NQvxD%I{*c_t6IMd3vi=ib29v~BMJnxMlYzrT@y zE!Ic%YM!YIz>0zJLuX|pr;SGF2?a2lx9c+nk@y`MiuEzQTDukma~(qgw+cq`LG8o{ zmG@7w2nz@&B6;zCAiNjq+mDAnAirig5-cQOOWYrrju?**(TNszhb!$iEKz`Z;n+LWu zM3sRu6IuFr$w7e;h6QO->}chMx_INTlVMSY5e5SOMoge~?tSG;Q&%lpRUfPI_0Zap zi`WZ*PJ%Ms-q8R3q;BeBFx79QY`MbqGQCMvEI*Oze3`^7isChyBns#+IESY?9A&sT z6y^2m)n>f92FQbl3RAk1EMViOCwMX^aul=@+Je9^I`v`2ZWlVuCYzn}(n4CvyE+on+*XzbWTn({Mq&|Lh!8xIr6BWqd4Y`+e(;ED! z8}OY%YYdEKpz)y7h4TdWYpcv~rcd%u#YpQ&4aHmW`#!ia=FXQ$k<}R8A9V=i7a-r@I|I}1Cc2k z$Hr64_0FCw9RBM@Yp*q6;_q^1fy4P z(bpznR@&%Kclg7aE87k#9EDJzM=(NYXL?PS6m%!s!P8 zt=)MxPIKMf7}{!W6SJd~s_shuy$C;q9?PW)AF(x#TrcHdIgSkro4 zahz;Q+4qLXxHZRNVdh4*uK=JD{PrYdb?~euzuzcniLv0(g_gGwGYE^SvMQq(|5*~a zM``!z@O|HDALpbIFaZACba;zWvX7U2?e%Vl;>vU2y79w%@?+mY5M-Ba+-LBhC$x5! zFcS>veT<7Aqj-Lc%i2_M#QP&@Z40Tl^UCJviNwemWb{X@_1W0?NfRtjkV@Qf z0QDZ+AlluNNsDoNPn~3VNdI7_u9L;D&6vjSB*~}X_~?M1gFOf zyGLns1g)gx_sIJxX9|0&nusXS)pfO3V_YTlcVb{ylxhIaP@laOTXBOyLN<&V z0}8fXRSSA4TB+swnqR~xi?rXWo)~KvS)?9PCHbg2E8Y(ISA5?Gg7jsK$#r$jeMn0Y zi*hLEt4TBVTVD2-7EFru>rN7p(dASs126pY#;EcVXcrBLbS{FM&(Nk|ZHJ&wKXJ57 z$(D@K%pBMVM==5Xad7u*>(NGsq&;$zuMG$V#Smi)v}DGU-YpX}))}Vm(lors^7a{& zVHRkf(o{u@;f$T2SW^m-6NbabD&K*Se8)Ub<5L~#JHuQ@V)`_IUmOoObtyuJzC1uY zH`mN`+83e`>x<(dBxj+`Zf2Z+YoYi8u_~*%k~8prXrVh``3XKSVW@?^J@^79zF=4l5r1YsRur~&`VroB>cy&XzE=IajU9avpDm28 zj?_Fcl8^d85er3&g)_fVA~K`RE_bu$?gYe=Bb7^&urdPA|y#{y*qP-Bnd!Gf@yZk>oc?|SUZ1E4fJcD>O|q7 za>m?fsDnGse3uJ6-GJS`hbSXZY5s#`Mw*4V53xznIp@qb*zj3J_g=+I`L|{AQdrWAXd}y3 zXs4q$<%((|qq6JC8WPVXH5ta?+pl4KsQVHAN)6gY$o+7}48I;a3O+6xm>PS9{0z4u z8s^ywr(LFNWFp&5?uF9bmsRuz_4(0@bP713{r52%w8v15Dkt5wKP@i(HDzT|ah~Rp z#xKnPWCRYw(Fju;{OQFsQ=QtL`3Mfo?$-ASjPO&R{ITCB`mOWi))ynZxa{?$HgoUn zrIFU1ea@i{sa&Bw8;8;@I0?Jc+&z0y>hOk>9VBK1CRdIG zzr2tP`Yw)=jVb&)7os6i>9}tF$P7SKXg2JsxuNruT+gWTYzo#rmv^2Ha$@;C-NUJA z`c@2=Hm^^`{iAn^&S`6t(}Cj-mO&i*a8)zq2N#G9Y5n#CFdwhw-*qGxZZ zNnM(8zlmYGE%88jxU7}B9R>4}Pb%bmOYjSKHY&Il~N#SFlVf}YJQ zEPU+9AOPD9{rANMT9aCS!066cpoLI24l5oWf6Sy&aJ}G;prH5R4ct54 zv;}C%13Kdhn%DLscVV*2`d8L}HwNH#CotTsmd~xeqwHd>;uu#x?lu{^uA_34rE%FR zynUIf6dY*pz}Pb`BjB_o0*+*i7sCp{#4z!^di6|YLhID}TojNXwggC0aI1~*8j1U= zu+dz3_z{LnOTRAH&r7LMCOm9*eq1SSI_Ia!k!t7D50ntNBN;s)+o2?CR{kp>@Csx1 zQ)vMxbl_TN5GTYkC1@275IK5J_VMHPfHhk%*`_tDi*I<4-lmOEZJ#7L)$B~Os(fJZ ziLf5qYiEontFR1G6a>Up8vXJ^m(XNqBQM8%yT5%yI<>5`tVdMrZ?Ma18!WMXUbM(oKC z;dZB286@@4LBTktO`7{TPx=n60%s?MqGVF3J!YkkRp5-(oFLp-Fef-GIMA1Kz-ZE+ z^2PWfK$zE)*Ad%4*4&@_g>ls{GC{UsH1VBtRsV2w*TUz5a9(c#AUM}VqcOZc{t{}Q z)l))30Q)YS{P-uKsQ!(IC{ylj@l$@CBLKqH_0*Px(ZAC%QDr+I)X|44h>=_GVQDL< z4_ZUmo>_k~$>~g*W-pu59pngseFrfKRv?X^Ros44k2M#HuFPge2y~ym1e`8@zrDZX z1+it${6rbTxf+Q4u{P`iM#ahuniH>J0GIE^&45qp9n{#r-B^*?(iTG^2_GN|*gYBPo&T~Vlmu#} z*|gG|0m(Xlf9)vPgRI#p;iaZG3%9(OdnP7<3dU73W$IDw?eD<2KgJ zgs$dS;DxRo#X3Co78@wp8O1S^s%D;SGmJHnA*{?c`?z&>9W-!U%;UfK;Q&jx83Jb3 zb3lHt80xjzvpFLl&juOp9VuGlG$B>*4XVP8auhtDuO8 zkdxIMcVp72m|D}oJ`=-EkpdQN+6j_vQy9uRIr%4Vuhim#wc9F~vFf6&qsKVtbT8G) zx$(=4bjY4EAeZb!t&n>8lVi<`|G-><8Q?Y)%$A97go3&2ZX%vZ5KUO(ivu{k5hYD8 zz1rs+;`5oLXEx5CwAg1$w>~km1qa@4`lu4rlUw7+t%=~_RqG0~uK-`%;1Ngr!x_&g z@D45*CkRQ4ie@*I(+Iil*Cz_*oXmT_874~CT5Aw@rquZ|{(`3OhTiU%FWrJ(XI|Icw^M z(FAMEe#t9+)LvXHG-_UOG=WC&Y0>+|{%_lO{hyx|`S-&Cq7>rGf7`|yyJ~nE=--Z< zIpG#)s?yZxy26{dpcEQ(ur_vj#JIS!6zJmBvlN{On~dEZ8^V8qf^W+ieP=04SVp{L zq8?=dOIhD!-@Xetc?&L*0q^L4>Q`fa2m6*Z6}RwJ85h* zww-*jZQE93+qTWdR&%;9&c)vUVLi`WbBr0WJ$0(TxqLxS^PB(X3S47h2m_CvjB zB7?Uy=zA>A7`#0RX!R2 z;o7Nr!cluI)=i!ozV4x|SQ56Da&V@1u$d0BagE$bBP#08#J&lWbU)&!rc7e3I~{2p zv>JsLOVU5L%K0_>gq*5Ae$T{uIB)?>`=$!3b6 zTBrT0a5kLQ{}wuon7oC4YIu}NA+T$WH1WB9m@J^_w9R9wH!9dFjqL{|-}QX`l~Cqh zn3l`wDa!&IM_uY*vogsvuKP^?d#mjpm=4Dc@jtCVC0q1*SB`!Yjhs9C?}@n`Bt1Fp zV*T}kFyfM_3%2|Uu2jB~*Q?mAgIp_l{N=_`YnkiB@F>4nE!Io3cK)#Tp1hpwR^E8& zT?YWh!J(*VRBJrQ#MaIz|88r^64~8Sf%j9(dW31rMA=;Cqxnz1x874+v$66THzFs? z!>mmj$Zc>4#u}6J=kL*yd?vE@kl`P%9rj6onBH0hFL0v6AGkHz0fhXAUYw?;=8zjO z^d-4w1n#wK>L)1HeTl&vRN_xr_q^N)2}U5M@`63zK0QO~5NWEMsa;7=N$n)3-j=$*Wn9dn+^T7noK(ucN@W9% z47Md5UMq809N9y}eC0a>Qbri^=ec`jhgpjp1}K*=;i2ZRh78$@XK2@j9-?26bFbfh z@asnq(O!^{o6ec_1i{t-BvJ{?!ebL+_4Fhe>?3E%7gxBrt9P`#0#IO-(?Y&j{5p?zJ- zoyysAuntO>Ym}of{o_W6edLMd73CSc8TRBgfo^1GKkPqlyF2|l6F6ky&M27V3#Ts@2vRIH*{iygOb~`f|oexMToOL4dkot;ZCLlfShXg?hY3*`P zTPqH5L{fWfRTDiz{0lCUolF#xtkXAcM2ktfHj6s;R%@uDQE#%2H2!*o^r=V~dxjJ1 z*vlm3mzr}qwm%(ZJYWoF$kB!uSiyQpxu?wIMjE1nUQT&lbxnl>89fa6JIuk?p70+P z2a>f0k(R0`6gy|9hk8(GZh+=nqjC41XK@MNgbS8@$^1~qzE!+aQSJtzD1j0Bk(-$| zIr8diKlRD6&y3?Zcm&d@o7{?N805=PMbXQz`|ck-X(-7=>iD_LI;WHRBk&Snp1-|3 z*rJ%TI6{JcYq$S+T?WWqsw-Zc81u)EL(2|Qe zE*ENq>O|eRvg$TDIrS~W6eq@WWJy@}de}C{sV=?BxxQjmts0_MjZPrh&%mFq+Db0j z*{`b?#d`s44Rzg7b12!*45f?JVHY3XgBpKIG8)Eh@9}$9YVy|DB1;jQpZ`>%?2%u` zo@dR7o}5LTW!8rFk;w@8hSLEJ#ygD5dMC(k4{A4urO9-M_Op%TXtJ zULnG0+8z1?5+54IVAqFLQOMJ0QAYYi`rYaUf=?M3=rOV;)aXQK=exsgN0BHYB&p}+ z{W(IbecGka*X=1FDGA{f(M{ERjkb^a=EqxXH_MVWM5r;8+Zxzouy3bwqYx(>0;(s* zxJ^-slyA3(pMbR%MJkp+QnW0|Cif+g#}`^&X!ib0=#DqIrx@rj#SBf|%`BpA@P5zH z8g0(csXG5dH4tJRx1cRVzR>=Rks$x(?T1hO*ZpJPMb zKvq;rmqeaa;-vxGL|5#bA5=U$i^A0>m`4xeb!P4Sbk>wj%`(~TYJTzextmh6Az11p z^E%V}*5^6L>#FS}=RViz>bL&aloKP$9L--P>Lp+fa6c6|>)}29Y%%vOpZ#(l6(e*% zb$Clo^_A#I(ZJque1c6pR9G~+y#=BW<@0c__ zx(vWc^}G8i0>8rE{m?V$93Ar1&pEpL+04$(fu&AiRyNp`3Z0YuC7o-M+uDG@mVm^Gfm67L>0tdcME^L5M z9;aNzjLZbb!1&JJd3U$HiOXnkax~9&ScvZWdV6uJvD#~8`Dt6Rt`yfg+v~x{^Os62 z0!PTCF&X>jq{=czY_Tk#sqIpsg*k@VUGtOO>g;w0E!yVx^q>%w5*yRh`sRj{s+|{A zQ)M++1AhOn*_!Ioj*hNsM4mtAaIV1b=ZELZb68hbNRi7lO~U^DBXrrn+fObRk<35Z z3UBue9b$sBZx8Jc?0+IkL=S&T@x}j0h|YFI$)Lee_5jU5^sQ?RWrBlNO2JOS3IWRNUR~Uz;ewb>#+%A(%H) z#f*>}gUf$=h7{&RH=%2%XW87=5vxQGMqNFe+LEr7UdQ0{&)o{~wW}(K53W*hPsKxj zcb%4P_K&!SJgE1n6E@F~N>M+__H-=p7-Cg!0~t6J^4_Sv-V}}@Pk`rFAW`sEbvXNh z(+Tkc7ZdOcU)DHwSx45lTiFwEy=H=(IzB_&OKONKN4y&1rk2|a>R+LS$8yQu@}F6M z=a@Nt*nwy;Ydk=!h3@6O`zq_z)RHP|gGR!OfG3?VIcCGYiLvY}3bEOW3$PX#f^V$v z;V_?w9>nDkEeJ^}JKd|BC6ua)Lmy+XE}E2_OyR4vrzcwXHJFtQlcED^Mz64=(#4re zBnG-HT5O@I4>W&2w5fYf>KjuTj^$+H?#7Pes4$85vIQ523WC{t$(+TdR!d#gX z>-!e<5Cs^`etP%!OIM=fG2glrVR4w*`Rp9I(FixK(tP5TNORc#=_E7$4h-Y=y*W+k zl9@j`^J9(L$xtRBXiR~?`VT4cVnpoEu~W2nmxA3AGe{9FXooD*^SyXgoG8In2vd zwy_A~#_d(@k~Q>d9JC<_3tCBkm?z^obvlV+87<(&>a`2mpnQR;xJgaDAsh<0%7*M@ z15=@nR?4*+%0lEmHjY@@9pMBA8-haZ0@!R1586ZB0%iGLlhM&+$)dosGFzNaE}1O- zP3_>3l$6LZnkot+XMi_+;RSYZ%-$eFSyv@MVzwElzOJ>%z1m-QoR+fGk=2dY1pRZ~ zohG-Hfs2#G78D2!gia-=W$cVA&o}p+SZY3VsW=2t^ANsucAQ1JjnRrbvPJ5|*%H%N ze1VJ>80N5iF!7Wu^g5H$R+9M{nuFud%5>W_%yByfyHjvW+^u>LdvAjS1R(xf(0}H# z{v{(^eo=nN8P3J%nz=D!d&Be5D~}~ z46>pkz{LOCYFPjB5(-TtFD{Z{yJlG|oT*Va6{vwiTo3rR;sK<~^omr5wp?OsMEhAS?(=bMc_|KrgcSOILA8 zal2i)CmrS5n){rG?08?f=u$>bE)8nzRS zR-At7_(`6UW1gH6x&I;!gFBtPfoR=zgHE7E-#}R2iNMPO<^9rraRAwDXbvg1Xq==uFW(SZ8Z|vW8mc9X6 zWX&%j|2~>q!a_GRuh~-5CidJIch{5EuLZaYx!fq2H4^_^XYBC*Vf|F^ zZ4%GMQ&K&a%6$3C_cd^A5G84?@6Gt(W`X?cPZ~B)8#o>Ovgd44&nTU%@a;sN*pdy) zo_wCs9orQ_1f_(FQv{$U_WdhA%(mpdEC$}F-JkccRQnX^tp!C1#wQD7*5)C6^X12I z?j$Y%d!TR|3i-8_@I^2`+mqTI_9T<{hlqpg zmcF+9sQnF9#W4Wy*P*vK^G@h;Amf}EYoyx3=joEhp9c^=sxLrGg`vf44HY(NG)J+| z|F?U2U_kV$f4xSVN0tuQufwaVu{g&Bm6DqFM3r%*Zb*E@1)0OknrZfV29iRO0Y;K6h1VcKwT!0*Za171EDtI+fsc@_|X>g|s zNk=>k9ZiZ0E6-{Lz%bU&j#34iXzzv_W z2D_9C?6=D=)@M#tf14cpSP_CZZ%J}Xf0&xQpY15NS`vU$89J3k;ZakLWw|a+-q1Sf zNppMF#yOe1wDEPAbLJ@w6t{^&-U#_r;o65=9~Hwp-A@0E@GGYUMy)A2`cmpuC`d$*xH`Q(~S z)I#_{A-VTwlQ$upw&Un*STJ3R3SNO8*A%K2k*2wUtpq|}{&)nn0b`9yM^+?Z1=mk+ zO0_MZYB0qslkYW?8q|d4XFKz1B7EPGyaoaeW=>7tV37Vg8P7eR5q*+wfymh&iaDd^ zN^smWa}TmP({jw(bfT=O865K){6a@r$6BUd<&vX>eueAMk(u!?Mavj8$KykMSd*Dq zfD8K~Hh(7ZG~pb<<_I*)x@IPgFAbF0CNnd; z(AwglQw8@c1&g4g+(vo)r^eALl*>f&SI|6l^EuEwmGfJSL19sOkmpcAzGQXi+8D|* z{O+Wc_>+=gvg!>I{!pu(M$`%0DGK?7GHTj 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 VwlMN}`%er%aGR6olj~j${vQ;P=LY}) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f42e62f3..f398c33c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6..65dcd68d 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index 53a6b238..93e3f59f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,91 +1,92 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -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! -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 - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +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! +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 + +:omega diff --git a/instance-build.gradle b/instance-build.gradle deleted file mode 100644 index ca95d6b1..00000000 --- a/instance-build.gradle +++ /dev/null @@ -1,19 +0,0 @@ -/** -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 (Repetitive Content) - */ - -// The app name -ext.APP_NAME = "Tusky" - -// The application id. Must be unique, e.g. based on your domain -ext.APP_ID = "com.keylesspalace.tusky" - -// url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend. -ext.CUSTOM_LOGO_URL = "" - -// e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen -ext.CUSTOM_INSTANCE = "" - -// link to your support account. Will be linked on the about page when not empty. -ext.SUPPORT_ACCOUNT_URL = "https://mastodon.social/@Tusky" \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index e7b4def4..1b76791c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,19 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +enableFeaturePreview("STABLE_CONFIGURATION_CACHE") + include ':app' From a1494ecc68f6a211306cce8501ed6e81960dfeea Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 4 Feb 2023 20:19:01 +0100 Subject: [PATCH 002/418] Perform preference schema upgrades at startup (#3186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Perform preference schema upgrades at startup Over time it can be desirable to change how preferences are interpreted. Preferences might be removed, or renamed. Or the default value for a preference might be changed. When this happens it's important that users upgrading from one version to the next (or jumping from one version to several versions ahead) get a consistent experience. In particular: - Preferences that no longer exist should be deleted - Preferences that have been renamed should have the old preference values copied over - If the user used the default value for the preference, and the default has changed, the previous default value should be explicitly set as their value for the preference To support this, store a SCHEMA_VERSION as a preference. This is not exposed to the user, and corresponds to the app's VERSION_CODE. If the version code does not match the schema version then this is a newer version of the app with older preferences that may need to be changed. Those changes will be implemented in `upgradeSharedPreferences`. * Translated using Weblate (Hungarian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Gera, Zoltan Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/ Translation: Tusky/Tusky * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Eric Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/ Translation: Tusky/Tusky * Translated using Weblate (Ukrainian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Ihor Hordiichuk Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/ Translation: Tusky/Tusky * Translated using Weblate (Vietnamese) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Hồ Nhất Duy Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/ Translation: Tusky/Tusky * Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Mikalai Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/ Translation: Tusky/Tusky * Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Andrej Zabavin Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/ Translation: Tusky/Tusky * Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Mikalai Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/ Translation: Tusky/Tusky * Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: xzFantom Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/ Translation: Tusky/Tusky * Translated using Weblate (Japanese) Currently translated at 91.3% (504 of 552 strings) Co-authored-by: TAKAHASHI Shuuji Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ja/ Translation: Tusky/Tusky * Translated using Weblate (Icelandic) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Sveinn í Felli Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/ Translation: Tusky/Tusky * Translated using Weblate (Belarusian) Currently translated at 100.0% (552 of 552 strings) Co-authored-by: Mikalai Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/be/ Translation: Tusky/Tusky * Lint --------- Co-authored-by: Gera, Zoltan Co-authored-by: Eric Co-authored-by: Ihor Hordiichuk Co-authored-by: Hồ Nhất Duy Co-authored-by: Mikalai Co-authored-by: Andrej Zabavin Co-authored-by: xzFantom Co-authored-by: TAKAHASHI Shuuji Co-authored-by: Sveinn í Felli --- .../keylesspalace/tusky/TuskyApplication.kt | 30 ++++++++++++++++--- .../tusky/settings/SettingsConstants.kt | 1 + 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 4c7aeca9..59978310 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -16,12 +16,13 @@ package com.keylesspalace.tusky import android.app.Application +import android.content.SharedPreferences import android.util.Log -import androidx.preference.PreferenceManager import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode @@ -36,7 +37,6 @@ import java.security.Security import javax.inject.Inject class TuskyApplication : Application(), HasAndroidInjector { - @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -46,6 +46,9 @@ class TuskyApplication : Application(), HasAndroidInjector { @Inject lateinit var localeManager: LocaleManager + @Inject + lateinit var sharedPreferences: SharedPreferences + override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { @@ -65,7 +68,12 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) - val preferences = PreferenceManager.getDefaultSharedPreferences(this) + // Migrate shared preference keys and defaults from version to version. The last + // version that did not have a SCHEMA_VERSION was 97, so that's the default. + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 97) + if (oldVersion != BuildConfig.VERSION_CODE) { + upgradeSharedPreferences(oldVersion, BuildConfig.VERSION_CODE) + } // In this case, we want to have the emoji preferences merged with the other ones // Copied from PreferenceManager.getDefaultSharedPreferenceName @@ -73,7 +81,7 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) setAppNightMode(theme) localeManager.setLocale() @@ -91,4 +99,18 @@ class TuskyApplication : Application(), HasAndroidInjector { } override fun androidInjector() = androidInjector + + private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) { + Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion") + val editor = sharedPreferences.edit() + + // Future upgrade code goes here + + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) + editor.apply() + } + + companion object { + private const val TAG = "TuskyApplication" + } } 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 6df811d6..61900321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -16,6 +16,7 @@ object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give // each preference a key for it to work. + const val SCHEMA_VERSION: String = "schema_version" const val APP_THEME = "appTheme" const val EMOJI = "selected_emoji_font" const val FAB_HIDE = "fabHide" From 16df2bfe87dae3a8cf4b29e89e285e454b3f74e1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 4 Feb 2023 20:19:23 +0100 Subject: [PATCH 003/418] Be consistent about using sentence-case in notification setting titles (#3187) --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2da6de9..b6c70b75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -328,9 +328,9 @@ When multiple accounts logged in Never - New Mentions + New mentions Notifications about new mentions - New Followers + New followers Notifications about new followers Follow requests Notifications about follow requests From 006f0de05c80111745c8231a0191d97960635026 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 4 Feb 2023 20:22:29 +0100 Subject: [PATCH 004/418] Upgrade AndroidX dependencies (#3169) * upgrade AndroidX dependencies * use new @Upsert in InstanceDao * fix crash because of new Room nullchecks * make TimelineStatusEntity.reblogAccount a val as well --- .../timeline/TimelineTypeMappers.kt | 2 +- .../com/keylesspalace/tusky/db/InstanceDao.kt | 33 +++---------------- .../com/keylesspalace/tusky/db/TimelineDao.kt | 2 +- .../tusky/db/TimelineStatusEntity.kt | 10 +++--- .../CachedTimelineRemoteMediatorTest.kt | 8 ++--- .../tusky/components/timeline/StatusMocker.kt | 10 +++--- gradle/libs.versions.toml | 26 +++++++-------- 7 files changed, 34 insertions(+), 57 deletions(-) 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 dc220b3f..24bc544d 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 @@ -153,7 +153,7 @@ fun Status.toEntity( } fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { - if (this.status.isPlaceholder) { + if (this.account == null) { Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) } 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 0bf1dc32..317c577e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -16,41 +16,18 @@ package com.keylesspalace.tusky.db import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns -import androidx.room.Transaction -import androidx.room.Update +import androidx.room.Upsert @Dao interface InstanceDao { - @Insert(onConflict = OnConflictStrategy.IGNORE, entity = InstanceEntity::class) - suspend fun insertOrIgnore(instance: InstanceInfoEntity): Long + @Upsert(entity = InstanceEntity::class) + suspend fun upsert(instance: InstanceInfoEntity) - @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) - } - } + @Upsert(entity = InstanceEntity::class) + suspend fun upsert(emojis: EmojisEntity) @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") 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 ddf0c955..d8423600 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -18,7 +18,7 @@ package com.keylesspalace.tusky.db import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query @Dao 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 3bc4bc7d..ff63faf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -104,11 +104,11 @@ data class TimelineAccountEntity( val bot: Boolean ) -class TimelineStatusWithAccount { +data class TimelineStatusWithAccount( @Embedded - lateinit var status: TimelineStatusEntity + val status: TimelineStatusEntity, @Embedded(prefix = "a_") - lateinit var account: TimelineAccountEntity + val account: TimelineAccountEntity? = null, // null when placeholder @Embedded(prefix = "rb_") - var reblogAccount: TimelineAccountEntity? = null -} + val reblogAccount: TimelineAccountEntity? = null // null when no reblog +) 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 a055eeee..00802cab 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 @@ -193,9 +193,9 @@ class CachedTimelineRemoteMediatorTest { listOf( mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), - TimelineStatusWithAccount().apply { + TimelineStatusWithAccount( status = Placeholder("5", loading = false).toEntity(1) - }, + ), mockStatusEntityWithAccount("3"), mockStatusEntityWithAccount("2"), mockStatusEntityWithAccount("1"), @@ -547,8 +547,8 @@ class CachedTimelineRemoteMediatorTest { private fun AppDatabase.insert(statuses: List) { runBlocking { statuses.forEach { statusWithAccount -> - if (!statusWithAccount.status.isPlaceholder) { - timelineDao().insertAccount(statusWithAccount.account) + statusWithAccount.account?.let { account -> + timelineDao().insertAccount(account) } statusWithAccount.reblogAccount?.let { account -> timelineDao().insertAccount(account) 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 e9c24238..eef02d93 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 @@ -92,26 +92,26 @@ fun mockStatusEntityWithAccount( val mockedStatus = mockStatus(id) val gson = Gson() - return TimelineStatusWithAccount().apply { + return TimelineStatusWithAccount( status = mockedStatus.toEntity( timelineUserId = userId, gson = gson, expanded = expanded, contentShowing = false, contentCollapsed = true - ) + ), account = mockedStatus.account.toEntity( accountId = userId, gson = gson ) - } + ) } fun mockPlaceholderEntityWithAccount( id: String, userId: Long = 1, ): TimelineStatusWithAccount { - return TimelineStatusWithAccount().apply { + return TimelineStatusWithAccount( status = Placeholder(id, false).toEntity(userId) - } + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9f2f083..5921f8e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,31 @@ [versions] -agp = "7.4.0" -androidx-activity = "1.6.0" -androidx-appcompat = "1.6.0-rc01" +agp = "7.4.1" +androidx-activity = "1.6.1" +androidx-appcompat = "1.6.0" 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-exifinterface = "1.3.5" +androidx-fragment = "1.5.5" +androidx-junit = "1.1.5" androidx-paging = "3.1.1" androidx-preference = "1.2.0" -androidx-recyclerview = "1.1.0" +androidx-recyclerview = "1.2.1" 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" +androidx-room = "2.5.0" 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" +espresso = "3.5.1" filemoji-compat = "3.2.7" glide = "4.13.2" glide-animation-plugin = "2.23.0" @@ -41,7 +42,6 @@ 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" @@ -77,10 +77,10 @@ androidx-lifecycle-reactivestreams-ktx = { module = "androidx.lifecycle:lifecycl 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-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-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" } From 15ff6191aeabe2fa9b497e001a553f090f9c3334 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Sat, 4 Feb 2023 20:29:13 +0100 Subject: [PATCH 005/418] Clean up Account adapters (#3202) * make BlocksAdapter use viewbinding * remove LoadingFooterViewHolder * cleanup code * move accountlist to component packes * make FollowRequestsHeaderAdapter use viewbinding * add license to MutesAdapter * move accountlist to component packages * use ConstraintLayout in item_blocked_user.xml * support the bot badge everywhere * cleanup code * cleanup xml files * ktlint * ktlint --- app/src/main/AndroidManifest.xml | 2 +- .../com/keylesspalace/tusky/MainActivity.kt | 1 + .../tusky/adapter/BlocksAdapter.kt | 82 ----------------- .../tusky/adapter/FollowRequestViewHolder.kt | 4 +- .../tusky/adapter/LoadingFooterViewHolder.kt | 21 ----- .../components/account/AccountActivity.kt | 2 +- .../accountlist}/AccountListActivity.kt | 15 ++- .../accountlist}/AccountListFragment.kt | 30 +++--- .../accountlist}/adapter/AccountAdapter.kt | 15 +-- .../accountlist/adapter/BlocksAdapter.kt | 68 ++++++++++++++ .../accountlist}/adapter/FollowAdapter.kt | 19 ++-- .../adapter/FollowRequestsAdapter.kt | 23 +++-- .../adapter/FollowRequestsHeaderAdapter.kt | 23 ++--- .../accountlist}/adapter/MutesAdapter.kt | 34 +++++-- .../preference/AccountPreferencesFragment.kt | 2 +- .../components/timeline/TimelineFragment.kt | 4 +- .../viewthread/ViewThreadFragment.kt | 4 +- .../tusky/di/ActivitiesModule.kt | 2 +- .../tusky/di/FragmentBuildersModule.kt | 2 +- .../main/res/layout/activity_account_list.xml | 2 +- app/src/main/res/layout/item_account.xml | 5 +- app/src/main/res/layout/item_blocked_user.xml | 91 +++++++++++-------- .../main/res/layout/item_follow_request.xml | 10 ++ app/src/main/res/layout/item_muted_user.xml | 21 +++-- .../keylesspalace/tusky/MainActivityTest.kt | 1 + 25 files changed, 254 insertions(+), 229 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/AccountListActivity.kt (90%) rename app/src/main/java/com/keylesspalace/tusky/{fragment => components/accountlist}/AccountListFragment.kt (93%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/adapter/AccountAdapter.kt (89%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/adapter/FollowAdapter.kt (80%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/adapter/FollowRequestsAdapter.kt (69%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/adapter/FollowRequestsHeaderAdapter.kt (54%) rename app/src/main/java/com/keylesspalace/tusky/{ => components/accountlist}/adapter/MutesAdapter.kt (74%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e9546172..420b2352 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,7 +122,7 @@ - + . */ -package com.keylesspalace.tusky.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.loadAvatar - -/** Displays a list of blocked accounts. */ -class BlocksAdapter( - accountActionListener: AccountActionListener, - animateAvatar: Boolean, - animateEmojis: Boolean, - showBotOverlay: Boolean, -) : AccountAdapter( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay -) { - override fun createAccountViewHolder(parent: ViewGroup): BlockedUserViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_blocked_user, parent, false) - return BlockedUserViewHolder(view) - } - - override fun onBindAccountViewHolder(viewHolder: BlockedUserViewHolder, position: Int) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis) - viewHolder.setupActionListener(accountActionListener) - } - - class BlockedUserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val avatar: ImageView = itemView.findViewById(R.id.blocked_user_avatar) - private val username: TextView = itemView.findViewById(R.id.blocked_user_username) - private val displayName: TextView = itemView.findViewById(R.id.blocked_user_display_name) - private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) - private var id: String? = null - - fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) { - id = account.id - val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) - displayName.text = emojifiedName - val format = username.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) - username.text = formattedUsername - val avatarRadius = avatar.context.resources - .getDimensionPixelSize(R.dimen.avatar_radius_48dp) - loadAvatar(account.avatar, avatar, avatarRadius, animateAvatar) - } - - fun setupActionListener(listener: AccountActionListener) { - unblock.setOnClickListener { - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - listener.onBlock(false, id, position) - } - } - itemView.setOnClickListener { listener.onViewAccount(id) } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 60770dda..7e675de3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -50,11 +50,11 @@ class FollowRequestViewHolder( }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) - val format = itemView.context.getString(R.string.post_username_format) - val formattedUsername = String.format(format, account.username) + val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) + binding.avatarBadge.visible(showBotOverlay && account.bot) } fun setupActionListener(listener: AccountActionListener, accountId: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt deleted file mode 100644 index 6d5ddbd8..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LoadingFooterViewHolder.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* Copyright 2018 Conny Duck - * - * 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.View -import androidx.recyclerview.widget.RecyclerView - -class LoadingFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) 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 1320bc91..dd732971 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 @@ -50,13 +50,13 @@ import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt similarity index 90% rename from app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index ca23f791..f379b95a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -13,13 +13,15 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky +package com.keylesspalace.tusky.components.accountlist import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.fragment.app.commit +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding -import com.keylesspalace.tusky.fragment.AccountListFragment import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject @@ -63,10 +65,9 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { setDisplayShowHomeEnabled(true) } - supportFragmentManager - .beginTransaction() - .replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) - .commit() + supportFragmentManager.commit { + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + } } override fun androidInjector() = dispatchingAndroidInjector @@ -76,8 +77,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector { private const val EXTRA_ID = "id" private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - @JvmStatic - @JvmOverloads fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt similarity index 93% rename from app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 9fa321d3..2649cbfe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -13,7 +13,7 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.fragment +package com.keylesspalace.tusky.components.accountlist import android.os.Bundle import android.util.Log @@ -30,16 +30,16 @@ import androidx.recyclerview.widget.SimpleItemAnimator import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity.Type import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.AccountAdapter -import com.keylesspalace.tusky.adapter.BlocksAdapter -import com.keylesspalace.tusky.adapter.FollowAdapter -import com.keylesspalace.tusky.adapter.FollowRequestsAdapter -import com.keylesspalace.tusky.adapter.FollowRequestsHeaderAdapter -import com.keylesspalace.tusky.adapter.MutesAdapter import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type +import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter +import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable @@ -83,7 +83,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) @@ -101,7 +100,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { - val headerAdapter = FollowRequestsHeaderAdapter(accountManager.activeAccount!!.domain, arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true) + val headerAdapter = FollowRequestsHeaderAdapter( + instanceName = accountManager.activeAccount!!.domain, + accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true + ) val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter @@ -350,8 +352,8 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct api.relationships(ids) .observeOn(AndroidSchedulers.mainThread()) .autoDispose(from(this)) - .subscribe(::onFetchRelationshipsSuccess) { - onFetchRelationshipsFailure(ids) + .subscribe(::onFetchRelationshipsSuccess) { throwable -> + Log.e(TAG, "Fetch failure for relationships of accounts: $ids", throwable) } } @@ -362,10 +364,6 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap) } - private fun onFetchRelationshipsFailure(ids: List) { - Log.e(TAG, "Fetch failure for relationships of accounts: $ids") - } - private fun onFetchAccountsFailure(throwable: Throwable) { fetching = false Log.e(TAG, "Fetch failure", throwable) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt similarity index 89% rename from app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index bbd83df3..55a37cfb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -12,24 +12,26 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFooterBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.removeDuplicates /** Generic adapter with bottom loading indicator. */ abstract class AccountAdapter internal constructor( - var accountActionListener: AccountActionListener, + protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean ) : RecyclerView.Adapter() { - var accountList = mutableListOf() + + protected var accountList: MutableList = mutableListOf() private var bottomLoading: Boolean = false override fun getItemCount(): Int { @@ -61,9 +63,8 @@ abstract class AccountAdapter internal constructo private fun createFooterViewHolder( parent: ViewGroup, ): RecyclerView.ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_footer, parent, false) - return LoadingFooterViewHolder(view) + val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt new file mode 100644 index 00000000..f769af34 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -0,0 +1,68 @@ +/* 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.components.accountlist.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible + +/** Displays a list of blocked accounts. */ +class BlocksAdapter( + accountActionListener: AccountActionListener, + animateAvatar: Boolean, + animateEmojis: Boolean, + showBotOverlay: Boolean, +) : AccountAdapter>( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + + override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { + val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindAccountViewHolder(viewHolder: BindingHolder, position: Int) { + val account = accountList[position] + val binding = viewHolder.binding + val context = binding.root.context + + val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis) + binding.blockedUserDisplayName.text = emojifiedName + val formattedUsername = context.getString(R.string.post_username_format, account.username) + binding.blockedUserUsername.text = formattedUsername + + val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar) + + binding.blockedUserBotBadge.visible(showBotOverlay && account.bot) + + binding.blockedUserUnblock.setOnClickListener { + accountActionListener.onBlock(false, account.id, position) + } + binding.root.setOnClickListener { + accountActionListener.onViewAccount(account.id) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt similarity index 80% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt index 5c546305..87b62486 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt @@ -12,10 +12,12 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup +import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.interfaces.AccountActionListener @@ -26,17 +28,14 @@ class FollowAdapter( animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + override fun createAccountViewHolder(parent: ViewGroup): AccountViewHolder { - val binding = ItemAccountBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false) return AccountViewHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 95d944bd..35a59a8e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -12,10 +12,12 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter + +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.interfaces.AccountActionListener @@ -25,16 +27,25 @@ class FollowRequestsAdapter( animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean -) : AccountAdapter(accountActionListener, animateAvatar, animateEmojis, showBotOverlay) { +) : AccountAdapter( + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay +) { + override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { - val binding = ItemFollowRequestBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) + val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) return FollowRequestViewHolder(binding, false) } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { - viewHolder.setupWithAccount(accountList[position], animateAvatar, animateEmojis, showBotOverlay) + viewHolder.setupWithAccount( + account = accountList[position], + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay + ) viewHolder.setupActionListener(accountActionListener, accountList[position].id) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt similarity index 54% rename from app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt index 2480086e..85cf4e20 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestsHeaderAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt @@ -13,27 +13,28 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ -package com.keylesspalace.tusky.adapter +package com.keylesspalace.tusky.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup -import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding +import com.keylesspalace.tusky.util.BindingHolder -class FollowRequestsHeaderAdapter(private val instanceName: String, private val accountLocked: Boolean) : RecyclerView.Adapter() { +class FollowRequestsHeaderAdapter( + private val instanceName: String, + private val accountLocked: Boolean +) : RecyclerView.Adapter>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_follow_requests_header, parent, false) as TextView - return HeaderViewHolder(view) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) } - override fun onBindViewHolder(viewHolder: HeaderViewHolder, position: Int) { - viewHolder.textView.text = viewHolder.textView.context.getString(R.string.follow_requests_info, instanceName) + override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName) } override fun getItemCount() = if (accountLocked) 0 else 1 } - -class HeaderViewHolder(var textView: TextView) : RecyclerView.ViewHolder(textView) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt similarity index 74% rename from app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index 42e19c65..288d1339 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -1,4 +1,19 @@ -package com.keylesspalace.tusky.adapter +/* Copyright 2023 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.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup @@ -9,22 +24,21 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.visible -/** - * Displays a list of muted accounts with mute/unmute account and mute/unmute notifications - * buttons. - * */ +/** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */ class MutesAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter>( - accountActionListener, - animateAvatar, - animateEmojis, - showBotOverlay + accountActionListener = accountActionListener, + animateAvatar = animateAvatar, + animateEmojis = animateEmojis, + showBotOverlay = showBotOverlay ) { + private val mutingNotificationsMap = HashMap() override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { @@ -48,6 +62,8 @@ class MutesAdapter( val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) + binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) + val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) binding.mutedUserUnmute.contentDescription = unmuteString ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) 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 3c919327..a883f73c 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 @@ -24,7 +24,6 @@ import androidx.annotation.DrawableRes import androidx.preference.PreferenceFragmentCompat import com.google.android.material.color.MaterialColors 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.FiltersActivity @@ -32,6 +31,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index f8395c56..6d0ff7ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -34,14 +34,14 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent +import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel 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 index cb361f5d..487caae3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -32,10 +32,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.di.Injectable 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 fbd12d77..2704ee70 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AboutActivity -import com.keylesspalace.tusky.AccountListActivity import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.FiltersActivity @@ -28,6 +27,7 @@ import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.AccountActivity +import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity 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 bc202f14..4a5e9738 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment +import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment @@ -32,7 +33,6 @@ import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragmen import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.NotificationsFragment import dagger.Module import dagger.android.ContributesAndroidInjector diff --git a/app/src/main/res/layout/activity_account_list.xml b/app/src/main/res/layout/activity_account_list.xml index c72ba66a..f61ff830 100644 --- a/app/src/main/res/layout/activity_account_list.xml +++ b/app/src/main/res/layout/activity_account_list.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context="com.keylesspalace.tusky.AccountListActivity"> + tools:context="com.keylesspalace.tusky.components.accountlist.AccountListActivity"> @@ -14,6 +14,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_marginEnd="24dp" + android:contentDescription="@string/action_view_profile" android:foregroundGravity="center_vertical" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -26,8 +27,8 @@ android:layout_height="24dp" android:contentDescription="@null" android:importantForAccessibility="no" - android:visibility="gone" android:src="@drawable/bot_badge" + android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/account_avatar" app:layout_constraintEnd_toEndOf="@id/account_avatar" tools:src="#000" diff --git a/app/src/main/res/layout/item_blocked_user.xml b/app/src/main/res/layout/item_blocked_user.xml index d99513b5..6b8600bb 100644 --- a/app/src/main/res/layout/item_blocked_user.xml +++ b/app/src/main/res/layout/item_blocked_user.xml @@ -1,21 +1,62 @@ - + android:paddingStart="16dp" + android:paddingEnd="16dp"> + android:contentDescription="@string/action_view_profile" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:src="@drawable/avatar_default" /> + + + + + + - - - - - - - - - \ No newline at end of file + \ 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 e31ec6cf..c0c8df97 100644 --- a/app/src/main/res/layout/item_follow_request.xml +++ b/app/src/main/res/layout/item_follow_request.xml @@ -35,6 +35,15 @@ app:layout_constraintTop_toBottomOf="@id/notificationTextView" tools:src="@drawable/avatar_default" /> + + + diff --git a/app/src/main/res/layout/item_muted_user.xml b/app/src/main/res/layout/item_muted_user.xml index ef8c83dc..915c28e3 100644 --- a/app/src/main/res/layout/item_muted_user.xml +++ b/app/src/main/res/layout/item_muted_user.xml @@ -19,11 +19,20 @@ app:layout_constraintTop_toTopOf="parent" tools:src="@drawable/avatar_default" /> + + + app:layout_constraintTop_toBottomOf="@id/muted_user_username" + app:switchPadding="4dp" /> diff --git a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt index 8174aecb..09927d20 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/MainActivityTest.kt @@ -10,6 +10,7 @@ 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.accountlist.AccountListActivity import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.entity.Account From 433ae4065961d083f3dcd1e2e0415f1823069d79 Mon Sep 17 00:00:00 2001 From: Ricard Torres Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 006/418] Translated using Weblate (Catalan) Currently translated at 100.0% (560 of 560 strings) Translated using Weblate (Catalan) Currently translated at 100.0% (560 of 560 strings) Translated using Weblate (Catalan) Currently translated at 90.7% (508 of 560 strings) Translated using Weblate (Catalan) Currently translated at 76.7% (430 of 560 strings) Co-authored-by: Ricard Torres Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/ Translation: Tusky/Tusky --- app/src/main/res/values-ca/strings.xml | 164 ++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 18 deletions(-) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 49cf3277..c35fc262 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -3,17 +3,17 @@ S\'ha produït un error. Això no pot estar buit. El domini que s\'ha introduït no és vàlid - Ha fallat l\'autenticació en aquesta instància. + No s\'ha pogut autenticar amb aquesta instància. Si això continua, proveu d\'iniciar sessió al navegador des del menú. No s\'ha trobat cap navegador web per a utilitzar. - S\'ha produït un error d\'autorització no identificat. - S\'ha denegat l\'autorització. - Ha fallat l\'obtenció del token d\'inici de sessió. - L\'estat és massa llarg! + S\'ha produït un error d\'autorització no identificat. Si això continua, proveu d\'iniciar sessió al navegador des del menú. + S\'ha denegat l\'autorització. Si esteu segur que heu proporcionat les credencials correctes, proveu d\'iniciar sessió al navegador des del menú. + No s\'ha pogut obtenir el token d\'inici de sessió. Si això continua, proveu d\'iniciar sessió al navegador des del menú. + La publicació és massa llarga! 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. Cal permís d\'escriptura a l\'emmagatzematge. - No es poden adjuntar imatges i vídeos en el mateix estat. + Les imatges i els vídeos no es poden adjuntar a la mateixa publicació. Ha fallat la pujada. Inici Notificacions @@ -46,7 +46,7 @@ Preferit Més Escriure - Inicia sessió amb Mastodon + Inicia sessió amb Tusky Tanca sessió Segueix Deixa de seguir @@ -140,19 +140,20 @@ Sense llistar Només seguidors Mida de text de l\'estat - Mencions noves + Noves mencions Notificacions sobre mencions noves - Seguidors nous + Nous seguidors Notificacions sobre nous seguidors Impulsos - Notificacions si retootejents els teus toots + Notificacions quan s\'impulsen les teves publicacions Preferits - Notificacions si marquen com a preferits els teus toots + Notificacions quan les teves publicacions es marquen com a preferides %s et mencionen %1$s, %2$s, %3$s i %4$d més %1$s, %2$s i %3$s %1$s i %2$s + %d interacció nova %d interaccions noves Compte blocat @@ -283,11 +284,13 @@ Cercar persones que segueixes Afegir un compte a la llista Suprimir un compte de la llista - Publicar amb el compte %1$s + "Publicar com a %1$s" Error al afegir la llegenda - Descriure per a invidentes -\n(%d character limit) + Descriu per a persones amb discapacitat visual +\n(%d límit de caràcters) + Descriu per a persones amb discapacitat visual +\n(%d límit de caràcters) Afegir una llegenda Eliminar @@ -343,7 +346,8 @@ %1$s i %2$s %1$s, %2$s i %3$d més - màxim de %1$d pestanyes aconseguides + S\'ha arribat al màxim de %1$d pestanya + S\'han arribat al màxim de %1$d pestanyes Mèdia : %s Sense descripció @@ -480,10 +484,10 @@ Adjuncions Àudio - Notificacions quan algú a qui esteu subscrit publica un tut nou + Notificacions quan algú a qui estàs subscrit publica una publicació nova Publicacions noves emojis personalitzats animats - algú a qui estic subscrit acaba de publicar un tut nou + algú a qui estic subscrit ha publicat una nova publicació %s acaba de fer una publicació Avisos S\'ha eliminat la publicació a la qual vau fer un esborrany de resposta @@ -492,9 +496,133 @@ No s\'ha pogut publicar! Segur que voleu esborrar la llista %s\? - No podeu pujar més de %1$d adjunts multimèdia. + No podeu penjar més de %1$d fitxers adjunts multimèdia. + No podeu penjar més de %1$d fitxers adjunts multimèdia. Amaga les estadístiques quantitatives dels perfils Amaga les estadístiques quantitatives de les publicacions Limita les notificacions de la cronologia + No s\'ha pogut carregar la pàgina d\'inici de sessió. + No s\'han pogut carregar els detalls del compte + Afegeix o elimina de la llista + %s (%s) + Vols suprimir aquesta conversa\? + No s\'ha pogut afegir el compte a la llista + Els fitxers de vídeo i àudio no poden superar la mida de %s MB. + La imatge no s\'ha pogut editar. + Descartar + El port hauria d\'estar entre %d i %d + +1 + Edicions + hi ha un nou informe + Inhabilitat + <no definit> + <no vàlid> + Aquesta instància no admet seguir hashtags. + %s s\'ha registrat + Sempre + Quan s\'inicien la sessió amb diversos comptes + Mai + S\'ha produït un error en activar #%s + S\'ha produït un error en silenciar #%s + S\'ha editat %s + Edicions de publicacions + La càrrega ha fallat + La teva publicació no s\'ha pogut penjar i s\'ha desat als esborranys. +\n +\nNo s\'ha pogut contactar amb el servidor o bé ha rebutjat la publicació. + Les teves publicacions no s\'han pogut penjar i s\'han desat als esborranys. +\n +\nNo s\'ha pogut contactar amb el servidor o ha rebutjat les publicacions. + Mostra esborranys + Descartar + Comparteix l\'URL del compte a… + ALT + %s ha editat la seva publicació + Nou informe sobre %s + %s ha informat %s + %s · %d publicacions adjuntes + Elimina el marcador + Suprimeix la conversa + Inscripcions + Continua editant + Descartar els canvis + L\'arxiu multimèdia han de tenir una descripció. + Idioma de publicació per defecte + Les notificacions quan s\'editen les publicacions amb les quals has interaccionat + Notificacions sobre informes de moderació + ara + Inicieu sessió amb el navegador + afegir reacció + Comparteix l\'enllaç al compte + Comparteix el nom d\'usuari del compte + Detalls + Comparteix el nom d\'usuari del compte a… + S\'ha copiat el nom d\'usuari + #%s deixat de seguir + algú s\'ha donat d\'alta + una publicació amb la qual he interactuat s\'ha editat + Informes + Error seguint #%s + Error en deixar de seguir #%s + No s\'ha pogut carregar la font d\'estat des del servidor. + Hashtags seguits + Notificacions sobre nous usuaris + Iniciar Sessió + Torneu a iniciar sessió per rebre notificacions push + S\'està carregant el fil + Guardar l\'esborrany\? (Els fitxers adjunts es tornaran a penjar quan recupereu l\'esborrany.) + Heu tornat a iniciar sessió al vostre compte actual per concedir permís de subscripció push a Tusky. Tanmateix, encara teniu altres comptes que no s\'han migrat d\'aquesta manera. Canvieu-los i torneu a iniciar sessió un per un per activar el suport de notificacions UnifiedPush. + En iniciar sessió, accepteu les regles de %s. + Edita la imatge + No s\'ha pogut fixar + No s\'ha pogut desfixar + Idioma de la publicació + 14 dies + Tot i que el vostre compte no està bloquejat, el personal de %1$s va pensar que potser voldreu revisar les sol·licituds de seguiment d\'aquests comptes manualment. + Altres + Ordre de lectura + El més vell primer + El més nou primer + 30 dies + No s\'ha pogut eliminar el compte de la llista + 60 dies + 90 dies + (Sense canvis) + Redacta la publicació + Mostra el diàleg de confirmació abans de marcar com a preferit + Deixar de seguir #%s\? + Tens canvis no desats. + Toqueu o arrossegueu el cercle per triar el punt focal que sempre serà visible a les miniatures. + %s (🔗 %s) + No s\'ha pogut establir el punt d\'enfocament + Mostra el nom d\'usuari a les barres d\'eines + Estableix el punt d\'enfocament + Funciona en la majoria dels casos. No es filtra cap dada a altres aplicacions. + Pot ser compatible amb mètodes d\'autenticació addicionals, però requereix un navegador compatible. + No tens cap llista. + Vols suprimir aquesta publicació programada\? + %s (%s) + Incompliment de la regla + Spam + 180 dies + 365 dies + S\'amagarà algunes dades que poden afectar el vostre benestar mental. Això inclou: +\n +\n - Notificacions de favorits/impulsos/seguiments +\n - Preferits/augmenta el nombre de publicacions +\n - Estadístiques de seguidors/publicació als perfils +\n +\n Les notificacions push no es veuran afectades, però podeu revisar les vostres preferències de notificació manualment. + S\'ha unit el %1$s + Desant l\'esborrany… + Per utilitzar les notificacions push mitjançant UnifiedPush, Tusky necessita permís per subscriure\'s a les notificacions al vostre servidor Mastodon. Això requereix un nou inici de sessió per canviar els àmbits d\'OAuth concedits a Tusky. Si feu servir l\'opció de tornar a iniciar sessió aquí o a les preferències del compte, es conservaran tots els esborranys locals i la memòria cau. + Torneu a iniciar sessió a tots els comptes per activar el suport de notificacions push. + Editat + %1$s ha editat %2$s + Subscriu-te + Cancel·la la subscripció + %s regles + Silencia les notificacions + %1$s ha creat %2$s \ No newline at end of file From da21741e9808ead4d0881e441ade5cae0207e5c7 Mon Sep 17 00:00:00 2001 From: Manuel Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 007/418] Translated using Weblate (Italian) Currently translated at 100.0% (560 of 560 strings) Translated using Weblate (Italian) Currently translated at 95.8% (537 of 560 strings) Co-authored-by: Manuel Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/ Translation: Tusky/Tusky --- app/src/main/res/values-it/strings.xml | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c4cd7961..a4055010 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -4,11 +4,11 @@ Si è verificato un errore di rete! Per favore controlla la tua connessione e riprova! Questo non può essere vuoto. Inserito un dominio non valido - Autenticazione con quell\'istanza fallita. + Autenticazione con quell\'istanza fallita. Se il problema persiste, prova a collegarti dal browser nel menù. Nessun browser web utilizzabile trovato. - Si è verificato un errore di autenticazione non identificato. - Autorizzazione negata. - Acquisizione token di accesso fallita. + Sì è verificato un errore di autenticazione non identificato. Se il problema persiste, prova a collegarti dal browser nel menù. + Autorizzazione negata. Se sei sicuro di aver usato le credenziali corrette, prova a collegarti con il browser nel menu. + Acquisizione token di accesso fallita. Se il problema persiste, prova a collegarti dal browser nel menu. Il messaggio è troppo lungo! Quel tipo di file non può essere caricato. Non è stato possibile aprire quel file. @@ -60,7 +60,7 @@ Rimuovi preferito Di più Componi - Accedi con Mastodon + Accedi con Tusky Disconnettiti Sei sicuro di volerti disconnettere dall\'account %1$s? Segui @@ -282,7 +282,7 @@ Impostazione del sottotitolo non riuscita Descrivi per ipovedenti -\n(limite di %d caratteri) +\n(limite di %d carattere) Descrivi per ipovedenti \n(limite di %d caratteri) Descrivi per ipovedenti @@ -616,4 +616,28 @@ %1$s ha creato %2$s Altro Smettere di seguire #%s\? + Caricamento fallito + Accedi dal browser + Carico il thread + Disattivato + <non impostato> + <non valido> + Più nuovi prima + Non è stato possibile caricare il tuo post ed è stato salvato nelle bozze. +\n +\nNon è stato possibile contattare il server oppure il server ha rifiutato il post. + Non è stato possibile caricare i tuoi post e sono stati salvati nelle bozze. +\n +\nNon è stato possibile contattare il server oppure il server ha rifiutato i post. + Mostra bozze + Chiudi + Condividi nome utente dell\'account a… + Ordine di lettura + Funziona nella maggior parte dei casi. Nessun dato è trasferito ad altre app. + Potrebbe supportare differenti metodi di autenticazione, ma richiede un browser supportato. + Più vecchi prima + Condividi link dell\'account + Condividi il nome utente dell\'account + Condividi URL account a… + Nome utente copiato \ No newline at end of file From 17893f55641aa5ef146bddea9c30cda00331c8f0 Mon Sep 17 00:00:00 2001 From: Lin <012akt97@ouo.4wrd.cc> Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 008/418] Translated using Weblate (Chinese (Traditional)) Currently translated at 87.1% (488 of 560 strings) Co-authored-by: Lin <012akt97@ouo.4wrd.cc> Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hant/ Translation: Tusky/Tusky --- app/src/main/res/values-zh-rTW/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 28d3ae22..3369372d 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -4,9 +4,9 @@ 網絡請求出錯,請檢查互聯網連接並重試! 內容不能為空。 該域名無效 - 無法連接此伺服器。 + 無法通過該伺服器的身份驗證。如果此問題持續發生,請嘗試選單中的“在瀏覽器中登錄”。 沒有可用的瀏覽器。 - 認證過程出現未知錯誤。 + 認證過程出現未知錯誤。如果此問題持續發生,請嘗試選單中的“在瀏覽器中登錄”。 授權被拒絕。 無法獲取登入資訊。 嘟文太長了! From aa6de31a08084f62eadaa10e2c1ee74ab61cbe02 Mon Sep 17 00:00:00 2001 From: GunChleoc Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 009/418] Translated using Weblate (Gaelic) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: GunChleoc Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gd/ Translation: Tusky/Tusky --- app/src/main/res/values-gd/strings.xml | 71 +++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index dee5b4e0..97ab8a08 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -25,7 +25,7 @@ Sgeul-beatha Freagair… Lorg… - Clàraich a-steach le Mastodon + Clàraich a-steach le Tusky POSTAICH! Meur-chlàr Emoji Na lean tuilleadh @@ -519,11 +519,11 @@ Cha b’ urrainn dhuinn am faidhle sin fhosgladh. Cha ghabh an seòrsa de dh’fhaidhle seo a luchdadh suas. Tha am post ro fhada! - Cha deach leinn tòcan clàraidh a-steach fhaighinn. - Chaidh an t-ùghdarrachadh a dhiùltadh. - Thachair mearachd leis an ùghdarrachadh nach do dh’aithnich sinn. + Cha deach leinn tòcan clàraidh a-steach fhaighinn. Ma mhaireas an duilgheadas seo, feuch “Clàraich a-steach le brabhsair” on chlàr-taice. + Chaidh an t-ùghdarrachadh a dhiùltadh. Ma tha thu cinnteach gun do chuir thu a-steach an teisteas ceart, feuch “Clàraich a-steach le brabhsair” on chlàr-taice. + Thachair mearachd leis an ùghdarrachadh nach do dh’aithnich sinn. Ma mhaireas an duilgheadas seo, feuch “Clàraich a-steach le brabhsair” on chlàr-taice. Cha do lorg sinn brabhsair-lìn a chleachdadh sinn. - Dh’fhàillig leis an dearbhadh leis an ionstans ud. + Dh’fhàillig leis an dearbhadh leis an ionstans ud. Ma mhaireas an duilgheadas seo, feuch “Clàraich a-steach le brabhsair” on chlàr-taice. Chuir thu a-steach àrainn-lìn mì-dhligheach Chan fhaod seo a bhith falamh. Thachair mearachd leis an lìonra! Thoir sùil air a’ cheangal agad is feuch ris a-rithist! @@ -563,7 +563,7 @@ 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. + 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 @@ -583,4 +583,63 @@ 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.) + A’ luchdadh an t-snàithlein + Òrdugh an leughaidh + As sine an toiseach + As ùire an toiseach + A bheil thu airson sgur de #%s a leantainn\? + Mùch na brathan + Bu chòir do thuairisgeul a bhith aig a’ mheadhan. + Briseadh riaghailte + Spama + Eile + À comas + <cha deach a shuidheachadh> + <mì-dhligheach> + Cha deach leinn an cunntas a thoirt air falbh on liosta + Deasachaidhean + an-dràsta + Cànan bunaiteach nam post + Chruthaich %1$s %2$s + Dheasaich %1$s %2$s + Chan eil liosta sam bith agad. + %s (%s) + Bu chòir dhan phort a bhith eadar %d is %d + Cha chuir an t-ionstans seo taic ri leantainn thagaichean hais. + Mearachd a’ mùchadh #%s + Mearachd a’ dì-mhùchadh #%s + Tagaichean hais ’gan leantainn + Gearanan + Brathan mu ghearanan na maorsainneachd + ALT + Dh’fhàillig leis an luchdadh suas + Dh’fhàillig luchdadh suas nam postaichean agad is chaidh an sàbhaladh ’nan dreachdan. +\n +\nCha d’ fhuair sinn grèim air an fhrithealaiche no dhiùlt e na postaichean. + Seall na dreachdan + Leig seachad + Chan eil #%s a’ leantainn tuilleadh + Chaidh a dheasachadh + Clàraich a-steach le brabhsair + bhios gearan ùr ann + Cuir ris no thoir air falbh on liosta + Cha deach leinn an cunntas a chur ris an liosta + Obraichidh seo mar as trice. Cha dèid dàta fhoillseachadh do dh’aplacaidean eile. + Dh’fhaoidte gun cuir seo taic ri dòighean dearbhaidh a bharrachd ach bi feum air brabhsair a chuireas taic ris. + Tilg air falbh na h-atharraichean + Lean air an deasachadh + Air a dheasachadh %s + Gearan ùr air %s + Rinn %s gearan mu %s + %s · Tha postaichean ris, %d dhiubh + Co-roinn ceangal dhan chunntas + Chaidh lethbhreac a dhèanamh dhen ainm-chleachdaiche + Tha atharraichean gun sàbhaladh agad. + Dh’fhàillig luchdadh bun-tùs a’ phuist on fhrithealaiche. + Dh’fhàillig luchdadh suas a’ phuist agad is chaidh a shàbhaladh ’na dhreachd. +\n +\nCha d’ fhuair sinn grèim air an fhrithealaiche no dhiùlt e am post. + Co-roinn ainm-cleachdaiche a’ chunntais + Co-roinn URL a’ chunntais le… + Co-roinn ainm-cleachdaiche a’ chunntais le… \ No newline at end of file From b1d44ce192dc830df1329cae1eb612768f79b96c Mon Sep 17 00:00:00 2001 From: puf Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 010/418] Translated using Weblate (English (United Kingdom)) Currently translated at 7.8% (44 of 560 strings) Translated using Weblate (Welsh) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: puf Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/ Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/en_GB/ Translation: Tusky/Tusky --- app/src/main/res/values-cy/strings.xml | 6 +- app/src/main/res/values-en-rGB/strings.xml | 124 ++++++++++++++++++++- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 9998e640..465eca24 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -174,9 +174,9 @@ Canolig Mawr Mwyaf - Crybwylliadau Newydd + Crybwylliadau newydd Hysbysiadau am grybwylliadau newydd - Dilynwyr Newydd + Dilynwyr newydd Hysbysiadau am ddilynwyr newydd Hybiadau Hysbysiadau pan gaiff eich negeseuon eu hybu @@ -471,7 +471,7 @@ %1$s Ffefryn Uniongyrchol - Ychwanegwch neu dynnu oddi ar y rhestr + Ychwanegu at neu dynnu oddi ar restr Wedi methu ag ychwanegu\'r cyfrif at y rhestr Wedi methu tynnu\'r cyfrif o\'r rhestr Drwy fewngofnodi rydych yn cytuno i reolau %s. diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 975a2adb..106300b9 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -16,17 +16,17 @@ Favourite %s favourited your post Favourites - Authorisation was denied. - An unidentified authorisation error occurred. - Log out + + + Show colourful gradients for hidden media Edit your profile Permission to read media is required. Images and videos cannot both be attached to the same post. A network error occurred! Please check your connection and try again! - Failed authenticating with that instance. + Couldn\'t find a web browser to use. - Failed getting a login token. + Notifications Posts Follows @@ -43,7 +43,7 @@ Local Blocked users Tabs - Follow Requests + Home Muted users Thread @@ -53,4 +53,116 @@ Invalid domain entered With replies Pinned + + + + + + + + + + + + + + + + + + + + + + + + + + + + Licences + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 3092c9b773142757e5838087ff473465c09c114d Mon Sep 17 00:00:00 2001 From: Deleted User Date: Thu, 9 Feb 2023 12:35:54 +0000 Subject: [PATCH 011/418] Translated using Weblate (German) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 214 +++++++++++++------------ 1 file changed, 110 insertions(+), 104 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d63897ea..1f83f61d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,53 +1,53 @@ Ein Fehler ist aufgetreten. - Ein Netzwerkfehler ist aufgetreten! Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut! + Ein Netzwerkfehler ist aufgetreten! Bitte überprüfe deine Internetverbindung und versuche es erneut! Das darf nicht leer sein. Ungültige Domain angegeben - Authentifizieren mit dieser Instanz fehlgeschlagen. + Authentifizieren mit dieser Instanz fehlgeschlagen. Falls das Problem weiter besteht, versuche dich über den Browser anzumelden. Kein Webbrowser gefunden. - Ein unbekannter Fehler ist bei der Autorisierung aufgetreten. - Autorisierung wurde abgelehnt. - Es konnte kein Login-Token abgerufen werden. + Ein unbekannter Fehler ist bei der Autorisierung aufgetreten. Falls das Problem weiter besteht, versuche dich über den Browser anzumelden. + Autorisierung wurde abgelehnt. Wenn du sicher bist, dass du die korrekten Anmeldedaten eingegeben hast, versuche dich über den Browser anzumelden. + Es konnte kein Anmelde-Token abgerufen werden. Falls das Problem weiter besteht, versuche dich über den Browser anzumelden. Der Beitrag ist zu lang! Dieser Dateityp kann nicht hochgeladen werden. Die Datei konnte nicht geöffnet werden. Berechtigung für Zugriff auf Mediendateien benötigt. - Eine Berechtigung wird zum Speichern des Mediums benötigt. + Berechtigung fürs Speichern von Mediendateien benötigt. Bilder und Videos können nicht an den gleichen Beitrag angehängt werden. - Das Hochladen ist gescheitert. + Das Hochladen ist fehlgeschlagen. Fehler beim Senden des Beitrags. - Start + Startseite Benachrichtigungen Lokal Föderiert Direktnachrichten Tabs - Konversation + Thread Beiträge Mit Antworten Angeheftet Folgt - Folgende + Follower Favoriten Stummgeschaltete Profile Blockierte Profile - Folgeanfragen + Follower-Anfragen Dein Profil bearbeiten Entwürfe Lizenzen \@%s %s teilte - Heikle Inhalte - Medien versteckt + Inhaltswarnung + Mediendateien ausgeblendet ALT Zum Anzeigen tippen - Zeige mehr - Zeige weniger + Mehr anzeigen + Weniger anzeigen Ausklappen Einklappen Hier ist nichts. - Noch keine Beiträge hier! Ziehe nach unten um zu aktualisieren! + Noch keine Beiträge. Ziehe nach unten, um zu aktualisieren! %s teilte deinen Beitrag %s favorisierte deinen Beitrag %s folgt dir @@ -62,14 +62,14 @@ Mehr Beitrag erstellen Mit Tusky anmelden - Ausloggen + Abmelden Bist du sicher, dass du dich vom Konto %1$s abmelden möchtest\? Folgen Entfolgen Blockieren - Entblockieren + Blockierung aufheben Geteilte Beiträge verbergen - Zeige geteilte Beiträge + Geteilte Beiträge anzeigen Melden Löschen TRÖT @@ -82,16 +82,16 @@ Favoriten Stummgeschaltete Profile Blockierte Profile - Folgeanfragen + Follower-Anfragen Medien Im Browser öffnen - Füge Medien hinzu + Mediendateien hinzufügen Foto machen Teilen Stummschalten - Lautschalten + Stummschaltung aufheben Erwähnen - Medien verstecken + Mediendateien ausblenden Drawer öffnen Speichern Profil bearbeiten @@ -103,27 +103,27 @@ Entwürfe Beitragssichtbarkeit Inhaltswarnung - Emoji + Emoji-Tastatur Tab hinzufügen - Verlinkungen + Links Erwähnungen Hashtags - Boosts anzeigen + Geteilte Beiträge anzeigen Favoriten anzeigen Hashtags Erwähnungen - Verlinkungen - Medium #%d öffnen + Links + Datei #%d öffnen %1$s heruntergeladen Link kopieren - Öffne als %s + Als %s öffnen Teilen als … - Beitragslink teilen … - Beitragsinhalt teilen … - Mediendatei teilen … + Beitragslink teilen an … + Beitrag teilen an … + Mediendateien teilen an … Gesendet! - Benutzer entblockt - Stummschaltung aufgehoben + Blockierung des Profils aufgehoben + Stummschaltung des Profils aufgehoben Gesendet! Antwort erfolgreich gesendet. Welche Instanz? @@ -139,38 +139,36 @@ Titelbild 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! - \n\nWenn du bis jetzt kein Konto hast, kannst du hier den Namen einer Instanz eingeben - und dort ein Konto einrichten.\n\nEine Instanz ist ein einziger Ort, wo dein Konto - gehostet ist, aber du kannst dennoch mit anderen Leuten reden und mit ihnen interagieren, als - wärt ihr alle auf einer Webseite. - \n\nWeitere Informationen gibt es auf joinmastodon.org. - - Stelle Medienupload fertig - Lade hoch … + Die Adresse oder Domain einer Instanz kann hier eingegeben werden, wie z. B. mastodon.social, icosahedron.website, social.tchncs.de, and more! +\n +\nWenn du bis jetzt kein Konto hast, kannst du hier den Namen einer Instanz eingeben und dort ein Konto einrichten. +\n +\nEine Instanz ist ein einzelner Ort, wo dein Konto gehostet ist, aber du kannst dennoch mit anderen Leuten interagieren, als wärt ihr alle auf derselben Webseite. +\n +\nWeitere Informationen gibt es auf joinmastodon.org. + Stelle Dateiupload fertig + Wird hochgeladen … Herunterladen Folgeanfrage zurückziehen? - Willst du diesem Profil wirklich nicht mehr folgen? + Dieses Profil entfolgen\? Diesen Beitrag löschen? Öffentlich: Für alle sichtbar Ungelistet: Nicht in der öffentlichen Timeline sichtbar - Nur Folgende: Nur für Folgende sichtbar + Nur Follower: Nur für Follower sichtbar Direkt: Nur für Erwähnte sichtbar Benachrichtigungen Benachrichtigungen Benachrichtigungen - Benachrichtige mit Sound + Mit einem Ton benachrichtigen Benachrichtige mit Vibration Benachrichtige mit Licht Benachrichtigen wenn Ich erwähnt werde Mir jemand folgt Jemand meine Beiträge teilt - Jemandem meine Beiträge gefallen - Aussehen - App-Thema + meine Beiträge favorisiert werden + Erscheinungsbild + App-Design Zeitleisten Filter Dunkel @@ -180,25 +178,25 @@ Systemthema verwenden Browser Links in der App (Browser Custom Tabs) öffnen - Verstecke Button bei Bildlauf + »Verfassen«-Schaltfläche beim Scrollen ausblenden Sprache Timeline-Filter Tabs Geteilte Beiträge anzeigen Zeige Antworten - Medienvorschauen herunterladen + Dateivorschauen herunterladen Proxy HTTP-Proxy HTTP-Proxy aktivieren HTTP-Proxy-Server HTTP-Proxy-Port Beitragssichtbarkeit - Medien immer als heikel markieren + Mediendateien immer mit einer Inhaltswarnung versehen Beiträge Fehler beim Synchronisieren Öffentlich Nicht gelistet - Nur Folgende + Nur Follower Schriftgröße Kleiner Klein @@ -207,8 +205,8 @@ Größer Neue Erwähnungen Benachrichtigungen über neue Erwähnungen - Neue Folgende - Benachrichtigungen über neue Folgende + Neue Follower + Benachrichtigungen über neue Follower Geteilte Beiträge Benachrichtigungen, wenn deine Beiträge geteilt werden Favorisierte Beiträge @@ -243,7 +241,7 @@ Folgeanfrage gesendet Folgt dir - Heikle Inhalte immer anzeigen + Mediendateien mit Inhaltswarnung immer anzeigen Medien Antworten an @%s mehr laden @@ -288,7 +286,7 @@ Alle Beiträge aus-/einklappen Beitrag öffnen App-Neustart erforderlich - Du musst Tusky neustarten um die Änderungen anzuwenden + Du musst Tusky neustarten, um die Änderungen anzuwenden Später Neustarten Die Standard-Emojis deines Geräts @@ -309,7 +307,7 @@ Bezeichnung Inhalt Absolute Zeitstempel verwenden - Das Profil wird möglicherweise unvollständig wiedergegeben. Klick um vollständiges Profil anzuzeigen. + Das Profil wird möglicherweise unvollständig wiedergegeben. Klicke, um das vollständige Profil im Browser zu öffnen. Vom Profil lösen Im Profil anheften Favorisiert von @@ -323,11 +321,11 @@ Keine Beschreibung Favorisiert Öffentlich - Folgende + Follower Direkt Listenname - Medien herunterladen - Medien werden heruntergeladen + Dateien herunterladen + Dateien werden heruntergeladen zu filternde Phrase Liste konnte nicht erstellt werden Liste konnte nicht umbenannt werden @@ -348,7 +346,7 @@ Ungelistet Löschen und neu erstellen Bist du dir sicher, dass du diesen Beitrag löschen und neu erstellen möchtest\? - Umfragen beendet sind + Umfragen beendet wurden Umfragen Benachrichtigungen über beendete Umfragen Löschen @@ -384,11 +382,11 @@ %d Sekunde verbleibend %d Sekunden verbleibend - Versteckte Domains - Versteckte Domains - %s verstecken - %s nicht mehr versteckt - Bist du dir sicher, dass du die Domain %s blockieren willst\? Nach der Blockierung wirst du nichts mehr von dieser Domain in öffentlichen Zeitleisten oder Benachrichtigungen sehen. Deine Follower von dieser Domain werden entfernt. + Ausgeblendete Domains + Ausgeblendete Domains + %s stummschalten + %s nicht mehr ausgeblendet + Bist du dir sicher, dass du alles von %s blockieren möchtest\? Du wirst keine Inhalte dieser Domain in irgendwelchen öffentlichen Timelines oder in deinen Benachrichtigungen sehen. Deine Follower von dieser Domain werden entfernt. Ganze Domain verbergen GIF-Avatare animieren Ganzes Wort @@ -399,8 +397,8 @@ \@%s wurde erfolgreich gemeldet Zusätzliche Kommentare An %s weiterleiten - Beim Melden ist ein Fehler aufgetreten - Der Bericht wird an die Moderatoren des Servers geschickt. Du kannst hier eine Erklärung angeben, warum du dieses Konto meldest: + Melden fehlgeschlagen + Die Meldung wird an die Moderator*innen deines Servers geschickt. Du kannst hier eine Erklärung angeben, warum du dieses Konto meldest: Dieses Konto ist von einem anderen Server. Soll eine anonymisierte Kopie des Berichts auch dorthin geschickt werden\? Benachrichtigungsfilter anzeigen Umfrage @@ -411,7 +409,7 @@ 1 Tag 3 Tage 7 Tage - Editieren + Bearbeiten test %s Umfrage hinzufügen Beiträge mit Inhaltswarnungen immer ausklappen @@ -439,34 +437,34 @@ Du hast keine Entwürfe. Du hast keine geplanten Beiträge. Das Datum des geplanten Toots muss mindestens 5 Minuten in der Zukunft liegen. - Benachrichtigungen über neue Folgeanfragen - Neue Folgeanfragen - Folgeanfragen + Benachrichtigungen über neue Follower-Anfragen + Anfrage zum Folgen gesendet + Follower-Anfragen Bist du dir sicher, dass du @%s stummschalten möchtest\? Bist du dir sicher, dass du @%s blockieren möchtest\? - Stummschaltung der Konversation aufheben - Konversation stummschalten + Stummschaltung der Unterhaltung aufheben + Unterhaltung stummschalten %s möchte dir folgen Hashtags Hashtag hinzufügen Bestätigungsdialog vor dem Teilen eines Beitrags - Linkvorschauen in Timelines anzeigen - Wischgeste zum Wechseln zwischen Tabs + Linkvorschau in Timelines anzeigen + Wischgeste zum Wechseln zwischen Tabs aktivieren %s Person %s Personen - Farbverlauf für versteckte Medien anzeigen + Farbverlauf für verborgene Medien anzeigen Unten Oben - %s nicht mehr verstecken + %s nicht mehr stummschalten <b>%s</b> Boost <b>%s</b> Boosts Hauptnavigations-Position Benachrichtigungen ausblenden - %s nicht mehr verstecken + %s nicht mehr stummschalten %d Sek. %d St. in %d T. @@ -476,7 +474,7 @@ Titel der Hauptnavigation verstecken Im Moment gibt es keine Ankündigungen. Ankündigungen - Der Beitrag auf den du antworten willst wurde gelöscht + Der Beitrag, auf den du antworten wolltest, wurde gelöscht Entwurf gelöscht Dieser Beitrag konnte nicht gesendet werden! Willst du die Liste %s wirklich löschen\? @@ -492,25 +490,25 @@ Benachrichtigungen, wenn jemand, den ich abonniert habe, eine neue Nachricht veröffentlicht Neue Beiträge GIF-Emojis animieren - Jemand, den ich abonniert habe, hat etwas Neues veröffentlicht + jemand, den ich abonniert habe, etwas Neues veröffentlicht %s hat gerade etwas veröffentlicht %d Min. Benachrichtigungen überprüfen - Informationen, die dein Wohlbefinden beeinflussen könnten, werden versteckt. Das beinhaltet + Informationen, die dein geistiges Wohlbefinden beeinflussen könnten, werden versteckt. Dies beinhaltet \n -\n- Benachrichtigungen über favorisierte/geteilte Beiträge, sowie \"Jemand folgt dir\" Benachrichtigungen -\n- Anzahl der Favoriten/Teilungen von Beiträgen -\n- Statistiken zu Followern auf Profilen +\n• Benachrichtigungen über favorisierte/geteilte Beiträge, sowie »X folgt dir«-Benachrichtigungen +\n• Anzahl der Favoriten von Beiträgen und wie oft diese geteilt wurden +\n• Statistiken zu Followern/Beiträgen auf Profilen \n -\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. +\nPush-Benachrichtigungen sind nicht betroffen, aber du kannst deine Benachrichtigungseinstellungen manuell überprüfen. + Auch wenn dein Konto öffentlich bzw. nicht geschützt ist, haben die Admins von %1$s gedacht, dass du diesen Follower lieber manuell bestätigen solltest. Keine Statistiken auf Profilen zeigen Keine Statistiken in Beiträgen zeigen Timeline-Benachrichtigungen einschränken Abonnieren - nicht mehr abonnieren + Deabonnieren in %d M. - in %d St. + in %d Std. Antwortinformationen konnten nicht geladen werden %d T. in %d J. @@ -539,7 +537,7 @@ Neuanmeldung für Push-Benachrichtigungen Ablehnen 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. + Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf dem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Anmelden 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+ @@ -549,16 +547,16 @@ 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 + Video- und Audiodateien dürfen nicht größer als %s MB sein. + Fehler beim Folgen von #%s + Fehler beim Entfolgen von #%s 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 + Profilname in Hauptnavigation anzeigen Pinnen fehlgeschlagen Lösen fehlgeschlagen Immer @@ -586,18 +584,18 @@ Gefolgte Hashtags #%s entfolgen\? Bearbeitet - Lade Thread + Thread wird geladen Benachrichtigungen stummschalten - Neueste Beiträge zuerst laden - Älteste Beiträge zuerst laden - Leserichtung + Neueste zuerst + Älteste zuerst + Lesereihenfolge Bearbeitungen Deaktiviert <nicht gesetzt> <ungültig> Du hast nicht gespeicherte Änderungen. Port sollte zwischen %d und %d liegen - Stummschalten von #%s fehlgeschlagen + Fehler beim Stummschalten von #%s Hochladen fehlgeschlagen Dein Beitrag konnte nicht gepostet werden. \n @@ -610,12 +608,20 @@ Bearbeitet %s Änderungen verwerfen Bearbeitung fortsetzen - Login mit Browser + Mit Browser anmelden Name des Profils teilen Link zum Profil teilen Teile Profil-Link mit … - Teile Profilnamen mit … + Profilname teilen an … Proilname kopiert %1$s bearbeitete %2$s %1$s erstellte %2$s + Neue Meldung über %s + Benachrichtigungen über Moderationsmeldungen + es eine neue Meldung gibt + Fehler bei Aufhebung der Stummschaltung von #%s + Funktioniert in den meisten Fällen. Keine Daten werden mit anderen Apps geteilt. + Kann zusätzliche Authentifizierungsmethoden unterstützen, erfordert aber einen unterstützten Browser. + Meldungen + Die Statusquelle konnte nicht vom Server geladen werden. \ No newline at end of file From 174c503642573d01c765a8abbb0c29816af2c5f7 Mon Sep 17 00:00:00 2001 From: Deleted User Date: Thu, 9 Feb 2023 12:35:55 +0000 Subject: [PATCH 012/418] Translated using Weblate (German) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1f83f61d..92715afa 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -86,7 +86,7 @@ Medien Im Browser öffnen Mediendateien hinzufügen - Foto machen + Foto aufnehmen Teilen Stummschalten Stummschaltung aufheben @@ -169,13 +169,13 @@ meine Beiträge favorisiert werden Erscheinungsbild App-Design - Zeitleisten + Timelines Filter Dunkel Hell Schwarz Automatisch bei Sonnenuntergang - Systemthema verwenden + Systemdesign verwenden Browser Links in der App (Browser Custom Tabs) öffnen »Verfassen«-Schaltfläche beim Scrollen ausblenden @@ -219,7 +219,7 @@ %d neue Interaktion %d neue Interaktionen - Gesperrtes Profil + Privates Profil Über Tusky ist freie und quelloffene Software. Es ist lizenziert unter der GNU General Public License Version 3. Du kannst dir die Lizenz hier anschauen: https://www.gnu.org/licenses/gpl-3.0.de.html diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8ec84acf..b6f88d09 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -59,4 +59,6 @@ 16dp 4dp + + 1dp diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index f1b6839e..3126b942 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -13,6 +13,7 @@ :%s: %s * " • " + %1$s — %2$s public diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml index c4de3448..e85c561c 100644 --- a/app/src/main/res/values/integers.xml +++ b/app/src/main/res/values/integers.xml @@ -1,4 +1,6 @@ 3 + + 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6c70b75..b6be0ec5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ Home Notifications Local + Trending hashtags Federated Direct messages Tabs @@ -727,7 +728,7 @@ Unfollow #%s? Mute notifications - + Reading order Oldest first @@ -739,4 +740,9 @@ %1$s created %2$s Loading thread + + + %1$d people are talking about hashtag %2$s + Total usage + Total accounts From 4dc7919ec0ccf78df525dc5cee7a90482d900856 Mon Sep 17 00:00:00 2001 From: mcclure Date: Tue, 14 Feb 2023 14:15:42 -0500 Subject: [PATCH 024/418] Fix sporadic failure in non-git build (using patch by @nikclayton) (#3307) --- app/build.gradle | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b8098a9f..283a8374 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,12 +7,13 @@ plugins { // For constructing gitSha only def getGitSha = { - try { + try { // Try-catch is necessary for build to work on non-git distributions providers.exec { commandLine 'git', 'rev-parse', 'HEAD' + executionResult.rethrowFailure() // Without this, sometimes it just stops immediately instead of throwing }.standardOutput.asText.get().trim() } catch (Exception e) { - "unknown" // Try-catch is necessary for build to work on non-git distributions + "unknown" } } From 969e4e9f1ad00733e05f24fa16f60dc0620610ba Mon Sep 17 00:00:00 2001 From: Deleted User Date: Tue, 14 Feb 2023 20:02:48 +0000 Subject: [PATCH 025/418] Translated using Weblate (German) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2f180c1b..6d4226ca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -221,7 +221,7 @@ Privates Profil Über - Tusky ist freie und quelloffene Software. Es ist lizenziert unter der GNU General Public License Version 3. Du kannst dir die Lizenz hier anschauen: https://www.gnu.org/licenses/gpl-3.0.de.html + Tusky ist eine freie & quelloffene Software und ist lizenziert unter der GNU General Public License Version 3. Du kannst die Lizenz hier einsehen: https://www.gnu.org/licenses/gpl-3.0.de.html %1$s • %2$s %s voto + %s votos %s votos termina em %s @@ -385,18 +387,22 @@ Sua enquete 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 Reproduzir GIFs @@ -463,6 +469,7 @@ Notificações sobre seguidores pendentes %s pessoa + %s pessoas %s pessoas Ativar deslizar para alternar entre abas @@ -602,4 +609,5 @@ alguém se inscreveu Salvando rascunho… Entrar + %s regras \ No newline at end of file From aa685abe310e7f651f77f9f3dec4d82ab7886a7a Mon Sep 17 00:00:00 2001 From: Aleksandr Yakovlev Date: Tue, 14 Feb 2023 20:02:48 +0000 Subject: [PATCH 028/418] Translated using Weblate (Russian) Currently translated at 77.3% (433 of 560 strings) Co-authored-by: Aleksandr Yakovlev Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ru/ Translation: Tusky/Tusky --- app/src/main/res/values-ru/strings.xml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 749c3aab..225e2f0a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -4,9 +4,9 @@ Произошла ошибка сети! Пожалуйста, проверьте интернет-соединение и попробуйте снова! Не может быть пустым. Некорректный домен - Ошибка аутентификации на этом узле. + Ошибка аутентификации на этом узле. Если это повторится, попробуйте Вход через Браузер в меню. Не удается найти веб-браузер. - Произошла ошибка неопознанной авторизации. + Произошла ошибка неопознанной авторизации. Если это повторится, попробуйте Вход через Браузер в меню. Авторизация была отклонена. Не удалось получить токен авторизации. Статус слишком длинный! @@ -23,7 +23,7 @@ Объединенная лента Личные сообщения Вкладки - Кутеж + Ветка Посты Посты и ответы Закреплённые @@ -538,4 +538,11 @@ Удалить разговор Запрашивать подтверждение перед добавлением в избранное Убрать из закладок + Загрузка ветки + Сначала новые + Правки + %1$s отредактировали %2$s + %1$s создали %2$s + Войти + Вход через Браузер \ No newline at end of file From 10c230332ba248a2ef2984452320a6b4c2448caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= Date: Tue, 14 Feb 2023 20:02:48 +0000 Subject: [PATCH 029/418] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (560 of 560 strings) Co-authored-by: Hồ Nhất Duy Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/ Translation: Tusky/Tusky --- app/src/main/res/values-vi/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 677b6902..f0e14490 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -204,7 +204,7 @@ Hiện lượt đăng lại Tabs Lọc bảng tin - Che mờ nội dung nhạy cảm + Phủ màu media nhạy cảm Ảnh đại diện GIF Icon cho tài khoản Bot Ngôn ngữ From 7c1a0ff5b3369d6de0527d786828078fb4c6381f Mon Sep 17 00:00:00 2001 From: XoseM Date: Tue, 14 Feb 2023 20:02:48 +0000 Subject: [PATCH 030/418] Translated using Weblate (Galician) Currently translated at 100.0% (560 of 560 strings) Co-authored-by: XoseM Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/ Translation: Tusky/Tusky --- app/src/main/res/values-gl/strings.xml | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index d34f4250..2feaf61a 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -52,7 +52,7 @@ Seguir Tes a certeza de que queres pechar sesión da conta %1$s\? Pechar sesión - Accede con Mastodon + Acceder con Tusky Redactar Máis Eliminar favorito @@ -111,11 +111,11 @@ Non se puido abrir o ficheiro. Non pode subirse ese tipo de ficheiro. A publicación é demasiado longa! - Fallou a obtención do token de acceso. - A autorización foi rexeitada. - Aconteceu un erro non identificado de autorización. + Fallou a obtención do token de acceso. Se persiste, inténtao desde Acceder no Navegador. + A autorización foi rexeitada. Se tes a certeza de que as credenciais son correctas, inténtao desde Acceder no Navegador no menú. + Aconteceu un erro non identificado de autorización. Se persiste, inténtao desde Acceder no Navedor. Non se atopou un navegador para utilizar. - Fallou a autenticación nesta instancia. + Fallou a autenticación nesta instancia. Se persiste, inténtao desde Acceder no Navegador no menú. O dominio escrito non é válido Esto non pode estar baleiro. Houbo un fallo na rede! Comproba a túa conexión e inténtao outra vez! @@ -598,4 +598,22 @@ Compartir URL da conta en… Compartir identificador da conta en… Identificador copiado + Orde de lectura + Antigo primeiro + Novidades primeiro + <non establecido> + Desactivado + <non válido> + Fallou a subida + Fallou a subida da publicación e gardouse non borradores. +\n +\nPode que o servidor non estive accesible ou que rexeitase a publicación. + Fallou a subida das túas publicacións e gardáronse nos borradores. +\n +\nPode que o servidor non estivese accesible ou que rexeitase as publicacións. + Mostrar borradores + Desbotar + Acceder no Navegador + Pode ter soporte para métodos adicionais de autenticación, pero require un navegador soportado. + Funciona case sempre. Non se filtran datos a outras apps. \ No newline at end of file From 80dbf6a491ad4275cccc1533f46aad06f59d9091 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 14 Feb 2023 21:09:06 +0100 Subject: [PATCH 031/418] Update Contributing.md (#3303) --- CONTRIBUTING.md | 68 +++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb8cee01..0960c91e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,57 +1,53 @@ # Contributing -## Getting Started -1. Fork the repository on the GitHub page by clicking the Fork button. This makes a fork of the project under your GitHub account. -2. Clone your fork to your machine. ```git clone https://github.com//Tusky``` -3. Create a new branch named after your change. ```git checkout -b your-change-name``` (```checkout``` switches to a branch, ```-b``` specifies that the branch is a new one) +Thanks for your interest in contributing to Tusky! Here are some informations to help you get started. -## Building +If you have any questions, don't hesitate to open an issue or join our [development chat on Matrix](https://riot.im/app/#/room/#Tusky:matrix.org). -Building Tusky requires Gradle 7.5 ([AGP](https://developer.android.com/studio/releases/gradle-plugin) 7.4.1) or newer. The easiest way to get this is to install [Android Studio](https://developer.android.com/studio/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) Electric Eel (2022.1.1) or newer. +## Contributing translations -Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Official release builds are "blue", whereas official nightly builds are "green". Build variant "greenDebug" is recommended for local development builds. - -## Making Changes - -### Text -All English text that will be visible to users should be put in ```app/src/main/res/values/strings.xml```. Any text that is missing in a translation will fall back to the version in this file. Be aware that anything added to this file will need to be translated, so be very concise with wording and try to add as few things as possible. Look for existing strings to use first. If there is untranslatable text that you don't want to keep as a string constant in a Java class, you can use the string resource file ```app/src/main/res/values/donottranslate.xml```. - -### Translation -Translations are done through our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). +Translations are managed on our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). You can create an account and translate texts through the interface, no coding knowledge required. To add a new language, click on the 'Start a new translation' button on at the bottom of the page. +- Use gender-neutral language +- Address users informally (e.g. in German "du" and never "Sie") + +## Contributing code + +### Prerequisites +You should have a general understanding of Android development and Git. + +### Architecture +We try to follow the [Guide to app architecture](https://developer.android.com/topic/architecture). + ### Kotlin -This project is in the process of migrating to Kotlin, all new code must be written in Kotlin. +Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin. We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). You can check the codestyle by running `./gradlew ktlintCheck`. -### Java -Existing code in Java should follow the [Android Style Guide](https://source.android.com/source/code-style), which is what Android uses for their own source code. ```@Nullable``` and ```@NotNull``` annotations are really helpful for Kotlin interoperability. Please don't submit new features written in Java. +### Text +All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages. +Try to keep texts friendly and concise. +If there is untranslatable text that you don't want to keep as a string constant in Kotlin code, you can use the string resource file `app/src/main/res/values/donottranslate.xml`. ### Viewbinding We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted. There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier. ### Visuals -There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like ```?attr/colorPrimary``` and ```?attr/textColorSecondary```. +There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like `?attr/colorPrimary` and `?attr/textColorSecondary`. +All icons are from the Material iconset, find new icons [here](https://fonts.google.com/icons) (Google fonts) or [here](https://fonts.google.com/icons) (community contributions). -### Saving -Any time you get a good chunk of work done it's good to make a commit. You can either uses Android Studio's built-in UI for doing this or running the commands: -``` -git add . -git commit -m "Describe the changes in this commit here." -``` +### Accessibility +We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information. -## Submitting Your Changes -1. Make sure your branch is up-to-date with the ```develop``` branch. Run: -``` -git fetch -git rebase origin/develop -``` -It may refuse to start the rebase if there's changes that haven't been committed, so make sure you've added and committed everything. If there were changes on develop to any of the parts of files you worked on, a conflict will arise when you rebase. [Resolving a merge conflict](https://help.github.com/articles/resolving-a-merge-conflict-using-the-command-line) is a good guide to help with this. After committing the resolution, you can run ```git rebase --continue``` to finish the rebase. If you want to cancel, like if you make some mistake in resolving the conflict, you can always do ```git rebase --abort```. +### Supported servers +Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features. -2. Push your local branch to your fork on GitHub by running ```git push origin your-change-name```. -3. Then, go to the original project page and make a pull request. Select your fork/branch and use ```develop``` as the base branch. -4. Wait for feedback on your pull request and be ready to make some changes +## Troubleshooting / FAQ -If you have any questions, don't hesitate to open an issue or contact [Tusky@mastodon.social](https://mastodon.social/@Tusky). Please also ask before you start implementing a new big feature. +- Tusky should be built with the newest version of Android Studio +- Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases. + +## Resources +- [Mastodon Api documentation](https://docs.joinmastodon.org/api/) From d34586b7c8e42180d0b469955fa8a0ce8a1bafa1 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 14 Feb 2023 21:09:16 +0100 Subject: [PATCH 032/418] Update Readme (#3304) * Update Readme * master -> main --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ad67003..201c2f38 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,15 @@ Tusky is a beautiful Android client for [Mastodon](https://github.com/mastodon/m ### Testing -The nightly build from master is [available on Google Play](https://play.google.com/store/apps/details?id=com.keylesspalace.tusky.test). +The nightly build containing the newest development code is [available on Google Play](https://play.google.com/store/apps/details?id=com.keylesspalace.tusky.test). ### Support -Check out our [FAQs](https://github.com/tuskyapp/faq), your question may already be answered. +Check out our [FAQs](https://github.com/tuskyapp/faq/blob/main/README.md), your question may already be answered. If you have any bug reports, feature requests or questions please open an issue or send us a message at [Tusky@mastodon.social](https://mastodon.social/@Tusky)! -For translating Tusky into your language, visit https://weblate.tusky.app/ +### Contributing +We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started. ### Head of development @@ -38,4 +39,3 @@ The current maintainer is [ConnyDuck@chaos.social](https://chaos.social/@ConnyDu ### Development chatroom https://riot.im/app/#/room/#Tusky:matrix.org -### From 395e21c956f4794a14cacdcb248e6976923cb5eb Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Tue, 14 Feb 2023 21:13:38 +0100 Subject: [PATCH 033/418] Add support for updating media description and focus point when editing statuses (#3215) * Add support for updating media description and focus point when editing statuses * Don't publish description/focus point updates via the standard api when editing a published post --- .../components/compose/ComposeViewModel.kt | 25 +++++++++++-------- .../components/compose/MediaPreviewAdapter.kt | 11 ++++---- .../keylesspalace/tusky/entity/Attachment.kt | 4 ++- .../keylesspalace/tusky/entity/NewStatus.kt | 11 ++++++++ .../tusky/service/SendStatusService.kt | 9 +++++++ 5 files changed, 43 insertions(+), 17 deletions(-) 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 fea92b5f..81304c41 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 @@ -335,17 +335,20 @@ class ComposeViewModel @Inject constructor( } } - val updatedItem = newMediaList.find { it.localId == localId } - if (updatedItem?.id != null) { - 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 -> - Log.w(TAG, "failed to update media", throwable) - false - }) + if (!editing) { + // Updates to media for already-published statuses need to go through the status edit api + val updatedItem = newMediaList.find { it.localId == localId } + if (updatedItem?.id != null) { + 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 -> + Log.w(TAG, "failed to update media", throwable) + false + }) + } } return true } 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 cababaf0..16daa6dc 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 @@ -48,11 +48,12 @@ class MediaPreviewAdapter( val addFocusId = 2 val editImageId = 3 val removeId = 4 - if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { - // Already-published items can't have their metadata edited - popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) - if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { - popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + + popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) + if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) { + popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) + if (item.state != ComposeActivity.QueuedMedia.State.PUBLISHED) { + // Already-published items can't be edited popup.menu.add(0, editImageId, 0, R.string.action_edit_image) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index c1368325..fa0b978f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -83,7 +83,9 @@ data class Attachment( data class Focus( val x: Float, val y: Float - ) : Parcelable + ) : Parcelable { + fun toMastodonApiString(): String = "$x,$y" + } /** * The size of an image, used to specify the width/height. 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 d11ad5f7..9577caef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -26,6 +26,7 @@ data class NewStatus( val visibility: String, val sensitive: Boolean, @SerializedName("media_ids") val mediaIds: List?, + @SerializedName("media_attributes") val mediaAttributes: List?, @SerializedName("scheduled_at") val scheduledAt: String?, val poll: NewPoll?, val language: String?, @@ -37,3 +38,13 @@ data class NewPoll( @SerializedName("expires_in") val expiresIn: Int, val multiple: Boolean ) : Parcelable + +// It would be nice if we could reuse MediaToSend, +// but the server requires a different format for focus +@Parcelize +data class MediaAttribute( + val id: String, + val description: String?, + val focus: String?, + val thumbnail: String?, +) : Parcelable 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 42365aff..90babc03 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -29,6 +29,7 @@ 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.MediaAttribute import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status @@ -190,6 +191,14 @@ class SendStatusService : Service(), Injectable { scheduledAt = statusToSend.scheduledAt, poll = statusToSend.poll, language = statusToSend.language, + mediaAttributes = media.map { media -> + MediaAttribute( + id = media.id!!, + description = media.description, + focus = media.focus?.toMastodonApiString(), + thumbnail = null, + ) + }, ) val sendResult = if (statusToSend.statusId == null) { From bd19e81d89415d2ec5ac368ffbf7f685651622dd Mon Sep 17 00:00:00 2001 From: Ricard Torres Date: Sun, 12 Feb 2023 17:35:54 +0000 Subject: [PATCH 034/418] Translated using Weblate (Spanish) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/es/ Translated using Weblate (Catalan) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/ca/ --- fastlane/metadata/android/ca/changelogs/100.txt | 7 +++++++ fastlane/metadata/android/ca/changelogs/58.txt | 15 ++++++++++----- fastlane/metadata/android/ca/changelogs/74.txt | 8 ++++++++ fastlane/metadata/android/ca/changelogs/77.txt | 10 ++++++++++ fastlane/metadata/android/ca/changelogs/80.txt | 7 +++++++ fastlane/metadata/android/ca/changelogs/82.txt | 5 +++++ fastlane/metadata/android/ca/changelogs/83.txt | 3 +++ fastlane/metadata/android/ca/changelogs/87.txt | 8 ++++++++ fastlane/metadata/android/ca/changelogs/89.txt | 7 +++++++ fastlane/metadata/android/ca/changelogs/91.txt | 6 ++++++ fastlane/metadata/android/ca/changelogs/94.txt | 9 +++++++++ fastlane/metadata/android/ca/changelogs/97.txt | 9 +++++++++ fastlane/metadata/android/ca/full_description.txt | 2 +- fastlane/metadata/android/es/changelogs/100.txt | 7 +++++++ 14 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 fastlane/metadata/android/ca/changelogs/100.txt create mode 100644 fastlane/metadata/android/ca/changelogs/74.txt create mode 100644 fastlane/metadata/android/ca/changelogs/77.txt create mode 100644 fastlane/metadata/android/ca/changelogs/80.txt create mode 100644 fastlane/metadata/android/ca/changelogs/82.txt create mode 100644 fastlane/metadata/android/ca/changelogs/83.txt create mode 100644 fastlane/metadata/android/ca/changelogs/87.txt create mode 100644 fastlane/metadata/android/ca/changelogs/89.txt create mode 100644 fastlane/metadata/android/ca/changelogs/91.txt create mode 100644 fastlane/metadata/android/ca/changelogs/94.txt create mode 100644 fastlane/metadata/android/ca/changelogs/97.txt create mode 100644 fastlane/metadata/android/es/changelogs/100.txt diff --git a/fastlane/metadata/android/ca/changelogs/100.txt b/fastlane/metadata/android/ca/changelogs/100.txt new file mode 100644 index 00000000..bd41f893 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/100.txt @@ -0,0 +1,7 @@ +Tusky 21.0 + +- Suport per a l'edició de publicacions +- Nova configuració per controlar la direcció de lectura preferida +- Visualitzacions prèvies d'imatges més grans i una nova superposició per indicar les imatges amb descripció +- Ara és possible afegir comptes a llistes des del seu perfil +i molt més diff --git a/fastlane/metadata/android/ca/changelogs/58.txt b/fastlane/metadata/android/ca/changelogs/58.txt index 0aacff93..fa7eb581 100644 --- a/fastlane/metadata/android/ca/changelogs/58.txt +++ b/fastlane/metadata/android/ca/changelogs/58.txt @@ -1,8 +1,13 @@ Tusky v6.0 -- Els filtres de línia de temps s'han canviat a Preferències del compte i es sincronitzaran amb el servidor +- Els filtres de cronologia s'han mogut a Preferències del compte i se sincronitzaran amb el servidor - Ara podeu tenir un hashtag personalitzat com a pestanya a la interfície principal -- Ara es poden editar llistes -- Seguretat: es va suprimir el suport per a TLS 1.0 i TLS 1.1, i es va afegir suport per a TLS 1.3 a Android 6+ -- La vista de redacció ara suggerirà emojis personalitzats en començar a escriure -- més informació al changelog +- Ara es poden editar les llistes +- Seguretat: s'ha eliminat el suport per a TLS 1.0 i TLS 1.1 i s'ha afegit suport per a TLS 1.3 a Android 6+ +- La vista de redacció ara suggerirà emojis personalitzats quan comenci a escriure +- Nova configuració del tema "seguir el tema del sistema" +- Millora de l'accessibilitat de la cronologia +- Ara Tusky ignorarà les notificacions desconegudes i ja no es bloquejarà +- Configuració nova: ara podeu anul·lar l'idioma del sistema i definir un idioma diferent a Tusky +- Noves traduccions: txec i esperanto +- Moltes altres millores i correccions diff --git a/fastlane/metadata/android/ca/changelogs/74.txt b/fastlane/metadata/android/ca/changelogs/74.txt new file mode 100644 index 00000000..d9c9f9a0 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/74.txt @@ -0,0 +1,8 @@ +Tusky v12.0 + +- Interfície principal millorada: ara podeu moure les pestanyes a la part inferior +- Quan silencieu un usuari, ara també podeu decidir si silencieu les seves notificacions +- Ara podeu seguir tants hashtags com vulgueu en una sola pestanya d'hashtag +- S'ha millorat la manera com es mostren les descripcions dels mitjans perquè funcioni fins i tot per a descripcions molt llargues + +Registre de canvis complet: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/ca/changelogs/77.txt b/fastlane/metadata/android/ca/changelogs/77.txt new file mode 100644 index 00000000..f209d230 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/77.txt @@ -0,0 +1,10 @@ +Tusky v13.0 + +- Suport per a notes de perfil (funció Mastodon 3.2.0) +- Suport per a anuncis d'administrador (funció Mastodon 3.1.0) + +- Ara es mostrarà l'avatar del vostre compte seleccionat a la barra d'eines principal +- Si feu clic al nom de visualització en una línia de temps, ara s'obrirà la pàgina de perfil d'aquest usuari + +- Moltes correccions d'errors i petites millores +- Traduccions millorades diff --git a/fastlane/metadata/android/ca/changelogs/80.txt b/fastlane/metadata/android/ca/changelogs/80.txt new file mode 100644 index 00000000..4db5efbb --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/80.txt @@ -0,0 +1,7 @@ +Tusky v14.0 + +- Rebeu una notificació quan un usuari seguit publica: feu clic a la icona de campana del seu perfil! (funció Mastodon 3.3.0) +- La funció d'esborrany de Tusky s'ha redissenyat completament per ser més ràpida, més fàcil d'utilitzar i amb menys errors. +- S'ha afegit un nou mode de benestar que us permet limitar determinades funcions de Tusky. +- Ara Tusky pot animar emojis personalitzats. +Registre de canvis complet: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/ca/changelogs/82.txt b/fastlane/metadata/android/ca/changelogs/82.txt new file mode 100644 index 00000000..ec73bca3 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/82.txt @@ -0,0 +1,5 @@ +Tusky v15.0 + +- Les sol·licituds de seguiment ara es mostren sempre al menú principal. +- El selector de temps per programar una publicació té ara un disseny coherent amb la resta de l'aplicació +Registre de canvis complet: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/ca/changelogs/83.txt b/fastlane/metadata/android/ca/changelogs/83.txt new file mode 100644 index 00000000..5ae0965d --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/83.txt @@ -0,0 +1,3 @@ +Tusky v15.1 + +Aquesta versió corregeix un error en subtitular imatges diff --git a/fastlane/metadata/android/ca/changelogs/87.txt b/fastlane/metadata/android/ca/changelogs/87.txt new file mode 100644 index 00000000..8a0c97c9 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/87.txt @@ -0,0 +1,8 @@ +Tusky v16.0 + +- La lògica de càrrega de la línia de temps s'ha reescrit completament per tal de ser més ràpida, amb menys errors i més fàcil de mantenir. +- Tusky ara pot animar emojis personalitzats en format APNG i WebP animat. +- Moltes correccions d'errors +- Suport per a Android 11 +- Noves traduccions: gaèlic escocès, gallec, ucraïnès +- Traduccions millorades diff --git a/fastlane/metadata/android/ca/changelogs/89.txt b/fastlane/metadata/android/ca/changelogs/89.txt new file mode 100644 index 00000000..f7b6ea37 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/89.txt @@ -0,0 +1,7 @@ +Tusky v17.0 + +- "Obre com..." ara també està disponible al menú dels perfils del compte quan s'utilitzen diversos comptes +- Ara l'inici de sessió es gestiona en una WebView dins de l'aplicació +- Suport per a Android 12 +- Suport per a la nova API de configuració de la instància Mastodon +- i moltes altres petites correccions i millores diff --git a/fastlane/metadata/android/ca/changelogs/91.txt b/fastlane/metadata/android/ca/changelogs/91.txt new file mode 100644 index 00000000..a67254fb --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/91.txt @@ -0,0 +1,6 @@ +Tusky v18.0 + +- Suport per als nous tipus de notificacions Mastodon 3.5 +- La insígnia del bot ara es veu millor i s'ajusta al tema seleccionat +- Ara es pot seleccionar el text a la vista detallada de la publicació +- S'han corregit molts errors, inclòs un que impedia els inicis de sessió a Android 6 i anteriors diff --git a/fastlane/metadata/android/ca/changelogs/94.txt b/fastlane/metadata/android/ca/changelogs/94.txt new file mode 100644 index 00000000..cbe187c0 --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/94.txt @@ -0,0 +1,9 @@ +Tusky 19.0 + +- Suport per a Unified Push. Per activar l'assistència, haureu de tornar a iniciar sessió als vostres comptes. +- El nombre de respostes a una publicació s'indica ara a les línies de temps. +- Les imatges ara es poden retallar mentre es redacta una publicació. +- Ara els perfils mostren la data en què es van crear. +- Quan es visualitza una llista, ara el títol es mostra a la barra d'eines. +- Moltes correccions d'errors +- Millores en la traducció diff --git a/fastlane/metadata/android/ca/changelogs/97.txt b/fastlane/metadata/android/ca/changelogs/97.txt new file mode 100644 index 00000000..16c922fe --- /dev/null +++ b/fastlane/metadata/android/ca/changelogs/97.txt @@ -0,0 +1,9 @@ +Tusky 20.0 + +- Nova icona de l'aplicació de Dzuk https://dzuk.zone/ +- Ara podeu seguir els hashtags. Feu clic a un hashtag i després a la icona de la barra d'eines. +- Suport per a Android 13 +- nou menú desplegable a la vista de redacció per definir l'idioma d'una publicació +- La pestanya multimèdia dels perfils ara respecta les imatges sensibles i es carrega més suaument. +- Ara és possible establir el punt d'enfocament d'una imatge abans de publicar-la +- Nova opció per mostrar el vostre nom d'usuari complet a la barra d'eines diff --git a/fastlane/metadata/android/ca/full_description.txt b/fastlane/metadata/android/ca/full_description.txt index 16b53e5c..7aacc1cf 100644 --- a/fastlane/metadata/android/ca/full_description.txt +++ b/fastlane/metadata/android/ca/full_description.txt @@ -1,6 +1,6 @@ Tusky és un client lleuger per a Mastodon, un servidor de xarxa social gratuït i de codi obert. -• Disseny de materials +• Estils de "Material Design" • S'han implementat la majoria de les API de Mastodon • Assistència multi-compte • Tema fosc i clar amb la possibilitat de canviar automàticament en funció de l’hora del dia diff --git a/fastlane/metadata/android/es/changelogs/100.txt b/fastlane/metadata/android/es/changelogs/100.txt new file mode 100644 index 00000000..795614ba --- /dev/null +++ b/fastlane/metadata/android/es/changelogs/100.txt @@ -0,0 +1,7 @@ +Tusky 21.0 + +- Soporte para edición de publicaciones +- Nueva configuración para controlar la dirección de lectura preferida +- Vistas previas de medios más grandes y una nueva superposición para indicar medios con descripción +- Ahora es posible agregar cuentas a listas desde su perfil +y mucho más From 5102f1cee222d07e167fe97839dbcf9712ce13e1 Mon Sep 17 00:00:00 2001 From: Deleted User Date: Sun, 12 Feb 2023 17:35:54 +0000 Subject: [PATCH 035/418] Translated using Weblate (German) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/de/ --- fastlane/metadata/android/de/changelogs/100.txt | 4 ++-- fastlane/metadata/android/de/changelogs/58.txt | 4 ++-- fastlane/metadata/android/de/changelogs/61.txt | 8 ++++---- fastlane/metadata/android/de/changelogs/67.txt | 2 +- fastlane/metadata/android/de/changelogs/68.txt | 2 +- fastlane/metadata/android/de/changelogs/70.txt | 2 +- fastlane/metadata/android/de/changelogs/72.txt | 6 +++--- fastlane/metadata/android/de/changelogs/74.txt | 6 +++--- fastlane/metadata/android/de/changelogs/77.txt | 6 +++--- fastlane/metadata/android/de/changelogs/80.txt | 6 +++--- fastlane/metadata/android/de/changelogs/82.txt | 2 +- fastlane/metadata/android/de/changelogs/83.txt | 2 +- fastlane/metadata/android/de/changelogs/87.txt | 4 ++-- fastlane/metadata/android/de/changelogs/89.txt | 6 +++--- fastlane/metadata/android/de/changelogs/91.txt | 2 +- fastlane/metadata/android/de/changelogs/94.txt | 2 +- fastlane/metadata/android/de/changelogs/97.txt | 12 ++++++------ fastlane/metadata/android/de/full_description.txt | 14 +++++++------- fastlane/metadata/android/de/short_description.txt | 2 +- 19 files changed, 46 insertions(+), 46 deletions(-) diff --git a/fastlane/metadata/android/de/changelogs/100.txt b/fastlane/metadata/android/de/changelogs/100.txt index 433d653a..677b061d 100644 --- a/fastlane/metadata/android/de/changelogs/100.txt +++ b/fastlane/metadata/android/de/changelogs/100.txt @@ -2,6 +2,6 @@ Tusky 21.0 - Beiträge können nun bearbeitet werden - Neue Einstellungen für die bevorzugte Leserichtung -- Größere Medienvorschauen und ein neuer Hinweis dass Medien eine Beschreibung haben -- Accounts können nun direkt von der Profilansicht zu Listen hinzugefügt werden +- Größere Medienvorschauen und ein neuer Hinweis, dass Medien eine Beschreibung haben +- Konten können nun direkt von der Profilansicht zu Listen hinzugefügt werden und viele weitere Verbesserungen diff --git a/fastlane/metadata/android/de/changelogs/58.txt b/fastlane/metadata/android/de/changelogs/58.txt index 9db5b778..fe86e159 100644 --- a/fastlane/metadata/android/de/changelogs/58.txt +++ b/fastlane/metadata/android/de/changelogs/58.txt @@ -1,11 +1,11 @@ Tusky v6.0 - Timeline-Filter in Kontoeinstellungen verschoben; werden mit dem Server synchronisiert -- Eigenes Tab für Hashtags +- Eigener 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 +- „Systemdesign verwenden“ hinzugefügt - Verbesserte Barrierefreiheit - Tusky ignoriert nun unbekannte Benachrichtigungen - Neue Einstellung: Individuelle App-Sprache diff --git a/fastlane/metadata/android/de/changelogs/61.txt b/fastlane/metadata/android/de/changelogs/61.txt index 89093f91..e6934aec 100644 --- a/fastlane/metadata/android/de/changelogs/61.txt +++ b/fastlane/metadata/android/de/changelogs/61.txt @@ -1,7 +1,7 @@ Tusky v7.0 -- Unterstützung für das Anzeigen von Umfragen, Abstimmen und Umfragebenachrichtigungen -- Neue Knöpfe um den Benachrichtigungstab zu filtern und alle Benachrichtigungen zu löschen -- Lösche & erstelle deine Toots neu -- es wird auf dem Profilbild angezeigt, ob es sich bei einem Account um einen Bot handelt (kann in den Einstellungen ausgeschaltet werden) +- Unterstützung für das Anzeigen von Umfragen, Abstimmungen und Umfragebenachrichtigungen +- Neue Schaltflächen, um den Benachrichtigungstab zu filtern und alle Benachrichtigungen zu löschen +- Lösche & erstelle deine Beiträge neu +- Es wird auf dem Profilbild angezeigt, ob es sich bei einem Konto um einen Bot handelt (kann in den Einstellungen ausgeschaltet werden) - Neue Übersetzungen: Norwegisch Bokmål und Slovenisch. diff --git a/fastlane/metadata/android/de/changelogs/67.txt b/fastlane/metadata/android/de/changelogs/67.txt index c0ee1d24..51226e28 100644 --- a/fastlane/metadata/android/de/changelogs/67.txt +++ b/fastlane/metadata/android/de/changelogs/67.txt @@ -3,7 +3,7 @@ Tusky v9.0 - Du kannst jetzt Umfragen erstellen - Die Suche wurde verbessert - Neue Option in den Profileinstellungen, um Inhaltswarnungen immer auszuklappen -- Avatare im Hauptmenü haben jetzt abgerundete Ecken +- Profilbilder im Hauptmenü haben jetzt abgerundete Ecken - Es ist jetzt möglich, Profile ohne Beiträge zu melden - Tusky wird auf Android 6+ nur noch sichere Netzwerkverbindungen nutzen - Viele andere kleine Verbesserungen und Fehlerkorrekturen diff --git a/fastlane/metadata/android/de/changelogs/68.txt b/fastlane/metadata/android/de/changelogs/68.txt index dee85857..0921e0c9 100644 --- a/fastlane/metadata/android/de/changelogs/68.txt +++ b/fastlane/metadata/android/de/changelogs/68.txt @@ -1,3 +1,3 @@ Tusky v9.1 -Dieses Release stellt die Kompatibilität mit Mastodon 3 sicher und verbessert Geschwindigkeit und Stabilität der App. +Diese Veröffentlichung stellt die Kompatibilität mit Mastodon 3 sicher und verbessert die Geschwindigkeit und Stabilität der App. diff --git a/fastlane/metadata/android/de/changelogs/70.txt b/fastlane/metadata/android/de/changelogs/70.txt index 749d0a9e..4c5f1fed 100644 --- a/fastlane/metadata/android/de/changelogs/70.txt +++ b/fastlane/metadata/android/de/changelogs/70.txt @@ -1,7 +1,7 @@ Tusky v10.0 - Du kannst jetzt Lesezeichen hinzufügen und ansehen -- Du kannst jetzt Posts vorausplanen. Achtung, der geplante Zeitpunkt muss mindestens 5 Minuten in der Zukunft liegen! +- Du kannst jetzt Beiträge vorausplanen. Achtung, der geplante Zeitpunkt muss mindestens 5 Minuten in der Zukunft liegen! - Du kannst jetzt Listen auf dem Hauptbildschirm anzeigen. - Du kannst jetzt Audio-Anhänge versenden. diff --git a/fastlane/metadata/android/de/changelogs/72.txt b/fastlane/metadata/android/de/changelogs/72.txt index 58705863..1152cfcb 100644 --- a/fastlane/metadata/android/de/changelogs/72.txt +++ b/fastlane/metadata/android/de/changelogs/72.txt @@ -1,11 +1,11 @@ Tusky v11.0 -- Benachrichtigungen über neue Folgeanfragen wenn das Konto gesperrt ist -- Neue Funktionen die in den Einstellungen aktiviert werden können: +- Benachrichtigungen über neue Folgeanfragen, wenn das Konto privat ist +- Neue Funktionen, die aktiviert werden können: - Wischgeste zum Wechseln zwischen Tabs - Bestätigung vor dem Teilen eines Beitrags - Linkvorschauen in Timelines - Konversationen können jetzt stummgeschaltet werden - Umfrageergebnisse für Umfragen mit Mehrfachauswahl sind jetzt einfacher zu verstehen -- Viele Fehlerkorrekturen, primär beim Postverfassen +- Viele Fehlerkorrekturen, primär beim Verfassen von Beiträgen - Verbesserte Übersetzungen diff --git a/fastlane/metadata/android/de/changelogs/74.txt b/fastlane/metadata/android/de/changelogs/74.txt index f9e5dcc4..709dbe56 100644 --- a/fastlane/metadata/android/de/changelogs/74.txt +++ b/fastlane/metadata/android/de/changelogs/74.txt @@ -1,8 +1,8 @@ Tusky v12.0 -- Verbessertes Interface - die Hauptnavigation kann jetzt auch unten angezeigt werden -- Entscheide beim stummschalten eines Benutzers jetzt auch, ob du Benachrichtigungen stummschalten willst +- Verbessertes Interface — die Hauptnavigation kann jetzt auch unten angezeigt werden +- Entscheide beim Stummschalten eines Profils, ob du Benachrichtigungen stummschalten willst - Es ist jetzt möglich, beliebig vielen Hashtags in einem Hashtag-Tab zu folgen - Verbesserte Bildbeschreibungen, vorallem bei langen Texten -Ganzer Changelog: https://github.com/tuskyapp/Tusky/releases +Alle Änderungen: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/de/changelogs/77.txt b/fastlane/metadata/android/de/changelogs/77.txt index f88fc4d6..2b762087 100644 --- a/fastlane/metadata/android/de/changelogs/77.txt +++ b/fastlane/metadata/android/de/changelogs/77.txt @@ -1,10 +1,10 @@ Tusky v13.0 - Unterstützung für Profilnotizen (Mastodon 3.2.0 Funktion) -- Unterstützung für Ankündigungen von Administratoren (Mastodon 3.1.0 Funktion) +- Unterstützung für Ankündigungen von Admins (Mastodon 3.1.0 Funktion) -- Der Avatar des ausgewählten Kontos wird nun in der Hauptnavigation angezeigt -- Klicken auf einen Anzeigenamen in einer Timeline öffnet jetzt das Profil dieses Nutzers +- Das Profilbild des ausgewählten Kontos wird nun in der Hauptnavigation angezeigt +- Ein Klick auf den Anzeigenamen in einer Timeline öffnet nun die Profilseite des jeweiligen Profils - Viele Fehlerkorrekturen und kleine Verbesserungen - Verbesserte Übersetzungen diff --git a/fastlane/metadata/android/de/changelogs/80.txt b/fastlane/metadata/android/de/changelogs/80.txt index c9ba80b0..18ec441e 100644 --- a/fastlane/metadata/android/de/changelogs/80.txt +++ b/fastlane/metadata/android/de/changelogs/80.txt @@ -1,7 +1,7 @@ Tusky v14.0 -- Werde über neue Beiträge benachrichtigt – Klicke auf das Glockensymbol in Profilen! (Funktion von Mastodon 3.3.0) +- 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. +- Ein neuer Wohlbefinden-Modus, der dir erlaubt, bestimmte Funktionen von Tusky zu beschränken, wurde hinzugefügt. +- Tusky kann jetzt animierte Emojis darstellen. Alle Änderungen: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/de/changelogs/82.txt b/fastlane/metadata/android/de/changelogs/82.txt index 9240dea5..18fd936d 100644 --- a/fastlane/metadata/android/de/changelogs/82.txt +++ b/fastlane/metadata/android/de/changelogs/82.txt @@ -1,5 +1,5 @@ Tusky v15.0 - Folgeanfragen werden jetzt immer im Menü angezeigt -- Der Zeitauswahldialog beim planen eines Beitrags hat jetzt ein besseres Design +- Der Zeitauswahldialog beim Planen eines Beitrags hat jetzt ein besseres Design Alle Änderungen: https://github.com/tuskyapp/Tusky/releases diff --git a/fastlane/metadata/android/de/changelogs/83.txt b/fastlane/metadata/android/de/changelogs/83.txt index bd764a05..75ee4d42 100644 --- a/fastlane/metadata/android/de/changelogs/83.txt +++ b/fastlane/metadata/android/de/changelogs/83.txt @@ -1,3 +1,3 @@ Tusky v15.1 -Dieses Release behebt einen Absturz bei der Eingabe einer Bildbeschreibung +Diese Veröffentlichung behebt einen Absturz bei der Eingabe einer Bildbeschreibung diff --git a/fastlane/metadata/android/de/changelogs/87.txt b/fastlane/metadata/android/de/changelogs/87.txt index 4e2b18a5..b67110ba 100644 --- a/fastlane/metadata/android/de/changelogs/87.txt +++ b/fastlane/metadata/android/de/changelogs/87.txt @@ -1,7 +1,7 @@ Tusky v16.0 -- Die Logik des Ladens der Timeline wurde komplett neu geschrieben um schneller, weniger fehlerhaft und wartungsfreundlicher zu sein. -- Tusky kann nun benutzerdefinierte Emojis im APNG- & Animated-WebP-Format animieren. +- Die Logik des Ladens der Timeline wurde komplett neu geschrieben, um schneller, weniger fehlerhaft und wartungsfreundlicher zu sein. +- Tusky kann nun benutzerdefinierte Emojis im APNG- & Animated-WebP-Format darstellen. - Viele Fehlerbehebungen - Unterstützung von Android 11 - Neue Übersetzungen: Schottisches Gälisch, Galicisch, Ukrainisch diff --git a/fastlane/metadata/android/de/changelogs/89.txt b/fastlane/metadata/android/de/changelogs/89.txt index cb92453b..3c9065db 100644 --- a/fastlane/metadata/android/de/changelogs/89.txt +++ b/fastlane/metadata/android/de/changelogs/89.txt @@ -1,7 +1,7 @@ Tusky v17.0 -- "Öffnen als..." ist jetzt im Menü in Konto Profilen auch verfügbar, wenn mehrere Konten genutzt werden -- Die Anmeldung wird jetzt über die WebView innerhalb der App abgewickelt +- „Als %s öffnen“ ist jetzt auch im Menü der Kontoprofile verfügbar, wenn mehrere Konten verwendet werden +- Die Anmeldung erfolgt nun über WebView innerhalb der App - Unterstützung für Android 12 -- Unterstützung für die neue Mastodon instance configuration API +- Unterstützung für die neue „Mastodon instance configuration API“ - und einige andere kleine Fehlerbehebungen und Verbesserungen diff --git a/fastlane/metadata/android/de/changelogs/91.txt b/fastlane/metadata/android/de/changelogs/91.txt index 1fd95cb2..327f5b53 100644 --- a/fastlane/metadata/android/de/changelogs/91.txt +++ b/fastlane/metadata/android/de/changelogs/91.txt @@ -1,6 +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 +- Das Bot-Symbol sieht jetzt besser aus und passt sich dem gewählten App-Design 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/94.txt b/fastlane/metadata/android/de/changelogs/94.txt index 711131ca..8db0c9ac 100644 --- a/fastlane/metadata/android/de/changelogs/94.txt +++ b/fastlane/metadata/android/de/changelogs/94.txt @@ -1,6 +1,6 @@ Tusky 19.0 -- Push-Benachrichtigungen via Unified Push. Um Unified Push zu verwenden musst du dich neu einloggen. +- Push-Benachrichtigungen via Unified Push. Um Unified Push zu verwenden, musst du dich erneut anmelden. - Die Anzahl an Antworten unter einem Beitrag wird jetzt in der Timeline angezeigt. - Bilder können jetzt vor dem Veröffentlichen zugeschnitten werden. - Das Erstellungsdatum eines Profils wird jetzt angezeigt. diff --git a/fastlane/metadata/android/de/changelogs/97.txt b/fastlane/metadata/android/de/changelogs/97.txt index 90a94829..ea7f8c95 100644 --- a/fastlane/metadata/android/de/changelogs/97.txt +++ b/fastlane/metadata/android/de/changelogs/97.txt @@ -1,9 +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. +- Neues Appsymbol von Dzuk https://dzuk.zone +- Du kannst Hashtags folgen. Tippe auf einen Hashtag und dann auf das Symbol in der Hauptnavigation - 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 +- Neues Auswahlmenü zum Festlegen der Beitragssprache +- Der Medien-Tab in Profilen respektiert nun Mediendateien mit Inhaltswarnung und lädt schneller +- Es ist nun möglich, den Fokuspunkt eines Bildes vor der Veröffentlichung festzulegen +- Der vollständige Profilname kann nun in der Hauptleiste angezeigt werden diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 295a9787..74c386bf 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,12 +1,12 @@ -Tusky ist ein leichtgewichtiger Client für Mastodon, ein freier und quelloffener Server für soziales Netzwerken. +Tusky ist ein kompakter Client für Mastodon, ein freier und quelloffener Server für soziales Netzwerken. • Material Design -• Meiste Mastodon-APIs implementiert -• Multi-Account-Support -• Dunkles und helles Theme mit der Möglichkeit es automatisch je nach Tageszeit zu wechseln -• Entwürfe - Schreibe Beiträge und speichere sie für später +• Fast alle Mastodon-APIs implementiert +• Unterstützung mehrerer Konten +• Dunkles und helles Design mit der Möglichkeit, es automatisch je nach Tageszeit wechseln zu lassen +• Entwürfe — schreibe Beiträge und speichere sie für später • Auswahl zwischen verschiedenen Emoji-Stilen • Optimiert für alle Bildschirmgrößen -• Komplett quelloffenen - Keine unfreien Abhängigkeiten wie Google-Dienste +• Komplett quelloffenen - keine unfreien Abhängigkeiten z. B. von Google-Diensten -Um mehr über Mastodon zu erfahren besuche https://joinmastodon.org/ +Um mehr über Mastodon zu erfahren, besuche https://joinmastodon.org/ diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt index bdc1af15..e21665c8 100644 --- a/fastlane/metadata/android/de/short_description.txt +++ b/fastlane/metadata/android/de/short_description.txt @@ -1 +1 @@ -Ein Multi-Account-Client für das soziale Netzwerk Mastodon +Ein Client für das soziale Netzwerk Mastodon, der mehrere Konten unterstützt From 2253878f8b1deff1d604e65257a30fd6641900db Mon Sep 17 00:00:00 2001 From: "Gera, Zoltan" Date: Sun, 12 Feb 2023 17:35:54 +0000 Subject: [PATCH 036/418] Translated using Weblate (Hungarian) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/hu/ --- fastlane/metadata/android/hu/changelogs/100.txt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 fastlane/metadata/android/hu/changelogs/100.txt diff --git a/fastlane/metadata/android/hu/changelogs/100.txt b/fastlane/metadata/android/hu/changelogs/100.txt new file mode 100644 index 00000000..68ae3942 --- /dev/null +++ b/fastlane/metadata/android/hu/changelogs/100.txt @@ -0,0 +1,7 @@ +Tusky 21.0 + +- Támogatás bejegyzések szerkesztéséhez +- Új beállítás az előnyben részesített olvasási irány állításához +- Nagyobb médiaelőnézetek és új áfedés a leírásokkal rendelkező média jelzéséhez +- Már lehetséges fiókokat hozzáadni listákhoz a profilnézetből is +és sok más From 5ba42792af4af904a69cc222beea181e63d9e820 Mon Sep 17 00:00:00 2001 From: Danial Behzadi Date: Sun, 12 Feb 2023 17:35:54 +0000 Subject: [PATCH 037/418] Translated using Weblate (Persian) Currently translated at 90.0% (18 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/ --- fastlane/metadata/android/fa/changelogs/100.txt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 fastlane/metadata/android/fa/changelogs/100.txt diff --git a/fastlane/metadata/android/fa/changelogs/100.txt b/fastlane/metadata/android/fa/changelogs/100.txt new file mode 100644 index 00000000..03156c4b --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/100.txt @@ -0,0 +1,7 @@ +تاسکی ۲۱٫۰ + +- پشتیبانی از ویرایش فرسته +- تنظیمات جدید برای واپایش جهت خوانش ترجیحی +- پیش‌نمایش‌های رسانهٔ بزرگ‌تر و نشانگر روکار جدید برای رسانه‌های دارای شرح +- اکنون افزودن حساب‌ها به فهرست‌ها از نمایه‌شان ممکن است +و بسیاری چیزهای بیش‌تر From d85f5078a57eb34b8600879665d3d0f535644d8a Mon Sep 17 00:00:00 2001 From: XoseM Date: Sun, 12 Feb 2023 17:35:55 +0000 Subject: [PATCH 038/418] Translated using Weblate (Galician) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/gl/ --- fastlane/metadata/android/gl/changelogs/100.txt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 fastlane/metadata/android/gl/changelogs/100.txt diff --git a/fastlane/metadata/android/gl/changelogs/100.txt b/fastlane/metadata/android/gl/changelogs/100.txt new file mode 100644 index 00000000..4d04458d --- /dev/null +++ b/fastlane/metadata/android/gl/changelogs/100.txt @@ -0,0 +1,7 @@ +Tusky 21.0 + +- Soporte para edición de publicacións +- Novo axuste para controlar a dirección de lectura +- Vistas previas de maior tamaño e indicador de descrición da imaxe +- Agora podes engadir contas ás listas desde o seu perfil +e moito máis From 196ebdb386fd17482e3be517006da01657a494a1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 15 Feb 2023 19:17:59 +0100 Subject: [PATCH 039/418] Keep the tabs adapter over the life of the viewpager (#3255) Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 --- .../com/keylesspalace/tusky/MainActivity.kt | 69 ++++++++++++------- .../tusky/pager/MainPagerAdapter.kt | 2 +- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index e8595746..fdf7dfc4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -34,6 +34,7 @@ import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -170,6 +171,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null + /** Mediate between binding.viewPager and the chosen tab layout */ + private var tabLayoutMediator: TabLayoutMediator? = null + + /** Adapter for the different timeline tabs */ + private lateinit var tabAdapter: MainPagerAdapter + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -275,6 +282,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje fetchAnnouncements() + // Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the + // adapter changes over the life of the viewPager (the adapter, not its contents), so set + // the initial list of tabs to empty, and set the full list later in setupTabs(). See + // https://github.com/tuskyapp/Tusky/issues/3251 for details. + tabAdapter = MainPagerAdapter(emptyList(), this) + binding.viewPager.adapter = tabAdapter + setupTabs(showNotificationTab) eventHub.events @@ -655,7 +669,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun setupTabs(selectNotificationTab: Boolean) { - val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") { + val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize) val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) (binding.composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin @@ -668,29 +682,36 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.tabLayout } + // Save the previous tab so it can be restored later + val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) + val tabs = accountManager.activeAccount!!.tabPreferences - val adapter = MainPagerAdapter(tabs, this) - binding.viewPager.adapter = adapter - TabLayoutMediator(activeTabLayout, binding.viewPager) { _: TabLayout.Tab?, _: Int -> }.attach() - activeTabLayout.removeAllTabs() - for (i in tabs.indices) { - val tab = activeTabLayout.newTab() - .setIcon(tabs[i].icon) - if (tabs[i].id == LIST) { - tab.contentDescription = tabs[i].arguments[1] - } else { - tab.setContentDescription(tabs[i].text) - } - activeTabLayout.addTab(tab) + // Detach any existing mediator before changing tab contents and attaching a new mediator + tabLayoutMediator?.detach() - if (tabs[i].id == NOTIFICATIONS) { - notificationTabPosition = i - if (selectNotificationTab) { - tab.select() - } + tabAdapter.tabs = tabs + tabAdapter.notifyItemRangeChanged(0, tabs.size) + + tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { + tab: TabLayout.Tab, position: Int -> + tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) + tab.contentDescription = when (tabs[position].id) { + LIST -> tabs[position].arguments[position] + else -> getString(tabs[position].text) } - } + }.also { it.attach() } + + // Selected tab is either + // - Notification tab (if appropriate) + // - The previously selected tab (if it hasn't been removed) + // - Left-most tab + val position = if (selectNotificationTab) { + tabs.indexOfFirst { it.id == NOTIFICATIONS } + } else { + previousTab?.let { tabs.indexOfFirst { it == previousTab } } + }.takeIf { it != -1 } ?: 0 + binding.viewPager.setCurrentItem(position, false) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) @@ -710,18 +731,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity) - refreshComposeButtonState(adapter, tab.position) + refreshComposeButtonState(tabAdapter, tab.position) } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) { - val fragment = adapter.getFragment(tab.position) + val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { (fragment as ReselectableFragment).onReselect() } - refreshComposeButtonState(adapter, tab.position) + refreshComposeButtonState(tabAdapter, tab.position) } }.also { activeTabLayout.addOnTabSelectedListener(it) @@ -730,7 +751,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity) binding.mainToolbar.setOnClickListener { - (adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() + (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } updateProfiles() diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt index 4fe92660..c635f929 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -20,7 +20,7 @@ import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.util.CustomFragmentStateAdapter -class MainPagerAdapter(val tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { +class MainPagerAdapter(var tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { override fun createFragment(position: Int): Fragment { val tab = tabs[position] From b760ebe004b7bf09d3b18919f0a7c0770bd74fc1 Mon Sep 17 00:00:00 2001 From: Conny Duck Date: Wed, 15 Feb 2023 20:05:45 +0100 Subject: [PATCH 040/418] update tusky_banner.xcf --- assets/tusky_banner.xcf | Bin 1730949 -> 1730949 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/tusky_banner.xcf b/assets/tusky_banner.xcf index 5ab43ef3a60ba22f57500dd61eb2897dc7e46e2c..55551d1fb984fd36adbea7c10f9459216afeba13 100644 GIT binary patch delta 1803 zcmeIzy^GUu7{Kx5mwFmK_4K^cSP;R*PAUgNrxS0gFLb!qEVl?s1}C|pAa*$GQBY76 zLg5Nh5v;gKk+Xw>95)mfv5Q$0@dctdogi_EPxE|N|APyB^GTizX+nOq)(>j^;O<#F z|9ooEv7P0Xv(j?xwU%SX&xaGXb0@r5Hf6gRHp?bojjX}uSbhA2Kk-jyw#&)h{+%6~ z=@=7r#zhYcSi}a2?%7O;pV^fAb+n%N42IySJ4UF>0m zF(&GqiyjuRh$Zwf7+AH;T!ld$8`#D!_AtU26LsE24+~ht68ad3Rm(E-6$W)|U>m#G z!w6$c)CCtkEMO5!=wp!G-!cmo26b#;8@t%U2xClC-&Gek)XPuRt2fl8RrUG{wRUds z{PG5FU(msWo9gNh_3@DUOjm>HWT%$tjOe^`6i?zA_2nt`^+R>zxcc^+`u?-};Z=5j z%Y5XCzdL&2*J*Y0tNOR8ruqv^y3V;STa$j_d^6LL1wGgtYZQ;+1Ww`PGzXXP8ZP5~ ze1sib&$PlfsmX`$QZu}@+j`w5KaGx5XhnwamY{y6++OogBc7J zGU$?EKv0J>LxiG`p;K@;>mUfiEsAJ|vzrd*n|q&whu?kg9e3PuSKnE!@2oz#Xwv?r zl55frJMKouHT{ljrx#jt#{JxSl(&P*TRZ8je!y3SYpfqNK_PM@hp$hnKpcS3!w-@fgQ>$*2PdVYBWz0(ZN;bryRdG*6fb^EM3+*LpQRKIMi-+1EhXLrllEa NRyxs-&B^s!{{dOBy{7;G From ab364712fe92d90243d3d47114c8dd488ba96ab1 Mon Sep 17 00:00:00 2001 From: mcclure Date: Thu, 16 Feb 2023 14:20:52 -0500 Subject: [PATCH 041/418] Previous attempt to fix git sha on non-git build broke git build. (#3322) This uses unusual code provided by Mikhail Lopatkin of Gradle Inc: https://github.com/gradle/gradle/issues/23914#issuecomment-1431909019 It wraps the git sha function in a "value source". This allows it to interact correctly with configuration caching. Because the code is longer than before, it is now broken out into its own file getGitSha.gradle. --- app/build.gradle | 14 ++------------ app/getGitSha.gradle | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 app/getGitSha.gradle diff --git a/app/build.gradle b/app/build.gradle index 283a8374..ab77fe0a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,19 +5,9 @@ plugins { alias(libs.plugins.kotlin.parcelize) } -// For constructing gitSha only -def getGitSha = { - try { // Try-catch is necessary for build to work on non-git distributions - providers.exec { - commandLine 'git', 'rev-parse', 'HEAD' - executionResult.rethrowFailure() // Without this, sometimes it just stops immediately instead of throwing - }.standardOutput.asText.get().trim() - } catch (Exception e) { - "unknown" - } -} +apply from: 'getGitSha.gradle' -final def gitSha = getGitSha() +final def gitSha = ext.getGitSha() // The app name final def APP_NAME = "Tusky" diff --git a/app/getGitSha.gradle b/app/getGitSha.gradle new file mode 100644 index 00000000..53315b2b --- /dev/null +++ b/app/getGitSha.gradle @@ -0,0 +1,27 @@ +import org.gradle.api.provider.ValueSourceParameters +import javax.inject.Inject + +// Must wrap this in a ValueSource in order to get well-defined fail behavior without confusing Gradle on repeat builds. +abstract class GitShaValueSource implements ValueSource { + @Inject abstract ExecOperations getExecOperations() + + @Override String obtain() { + try { + def output = new ByteArrayOutputStream() + + execOperations.exec { + it.commandLine 'git', 'rev-parse', '--short=8', 'HEAD' + it.standardOutput = output + } + return output.toString().trim() + } catch (GradleException ignore) { + // Git executable unavailable, or we are not building in a git repo. Fall through: + } + return "unknown" + } +} + +// Export closure +ext.getGitSha = { + providers.of(GitShaValueSource) {}.get() +} From 52c98749e6a0dff52c4c06ec82baa7e95ce49b42 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 16 Feb 2023 20:43:44 +0100 Subject: [PATCH 042/418] Fetch list title from second values in arguments (#3327) Previous code was: ``` for (i in tabs.indices) { // ... if (tabs[i].id == LIST) { tab.contentDescription = tabs[i].arguments[1] } else { tab.setContentDescription(tabs[i].text) } // ... ``` When I converted it over, `i` was replaced with `position`, but I misread `tab.contentDescription = tabs[i].arguments[1]` as `tab.contentDescription = tabs[i].arguments[i]`. Put the `1` back. --- app/src/main/java/com/keylesspalace/tusky/MainActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index fdf7dfc4..85518b23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -697,7 +697,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje tab: TabLayout.Tab, position: Int -> tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) tab.contentDescription = when (tabs[position].id) { - LIST -> tabs[position].arguments[position] + LIST -> tabs[position].arguments[1] else -> getString(tabs[position].text) } }.also { it.attach() } From 0baf3fe4672c18722ac7ef5239c26ecc7395b0bd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Feb 2023 19:55:09 +0100 Subject: [PATCH 043/418] Add renovate.json (#3263) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..f9c2c327 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} From cfea5700b0ba652de580b9d78e848fc280079c7b Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Feb 2023 02:58:37 +0800 Subject: [PATCH 044/418] Code cleanups (#3264) * Kotlin 1.8.10 https://github.com/JetBrains/kotlin/releases/tag/v1.8.10 * Migrate onActivityCreated to onViewCreated * More final modifiers * Java Cleanups * Kotlin cleanups * More final modifiers * Const value TOOLBAR_HIDE_DELAY_MS * Revert --- .../com/keylesspalace/tusky/BaseActivity.java | 2 - .../com/keylesspalace/tusky/ListsActivity.kt | 1 - .../tusky/adapter/NotificationsAdapter.java | 36 ++++----- .../tusky/adapter/StatusBaseViewHolder.java | 74 +++++++++--------- .../tusky/adapter/StatusViewHolder.java | 4 +- .../components/account/AccountActivity.kt | 6 +- .../components/compose/ComposeActivity.kt | 2 +- .../compose/view/FocusIndicatorView.kt | 4 +- .../tusky/components/drafts/DraftHelper.kt | 4 +- .../components/drafts/DraftsViewModel.kt | 2 +- .../preference/ProxyPreferencesFragment.kt | 4 +- .../timeline/viewmodel/TimelineViewModel.kt | 4 +- .../viewthread/ViewThreadFragment.kt | 2 +- .../com/keylesspalace/tusky/db/DraftsAlert.kt | 8 +- .../tusky/di/ViewModelFactory.kt | 2 +- .../com/keylesspalace/tusky/entity/Status.kt | 2 +- .../tusky/fragment/NotificationsFragment.java | 75 +++++++++---------- .../tusky/fragment/ViewVideoFragment.kt | 9 ++- .../util/ListStatusAccessibilityDelegate.kt | 2 +- .../tusky/util/LocaleExtensions.kt | 4 +- .../tusky/util/RxAwareViewModel.kt | 2 +- .../com/keylesspalace/tusky/util/SpanUtils.kt | 4 +- .../tusky/BottomSheetActivityTest.kt | 1 - gradle/libs.versions.toml | 2 +- 24 files changed, 123 insertions(+), 133 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 8f665fed..f821dc6a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -239,8 +239,6 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } if (permissionsToRequest.isEmpty()) { int[] permissionsAlreadyGranted = new int[permissions.length]; - for (int i = 0; i < permissionsAlreadyGranted.length; ++i) - permissionsAlreadyGranted[i] = PackageManager.PERMISSION_GRANTED; requester.onRequestPermissionsResult(permissions, permissionsAlreadyGranted); return; } diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 436bef7a..b02728cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -113,7 +113,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { lifecycleScope.launch { viewModel.events.collect { event -> - @Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA") when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) 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 98975543..8f45c0af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -67,7 +67,7 @@ import java.util.List; import at.connyduck.sparkbutton.helpers.Utils; -public class NotificationsAdapter extends RecyclerView.Adapter { +public class NotificationsAdapter extends RecyclerView.Adapter { public interface AdapterDataSource { int getItemCount(); @@ -87,12 +87,12 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - private String accountId; + private final String accountId; private StatusDisplayOptions statusDisplayOptions; - private StatusActionListener statusListener; - private NotificationActionListener notificationActionListener; - private AccountActionListener accountActionListener; - private AdapterDataSource dataSource; + private final StatusActionListener statusListener; + private final NotificationActionListener notificationActionListener; + private final AccountActionListener accountActionListener; + private final AdapterDataSource dataSource; private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); public NotificationsAdapter(String accountId, @@ -164,11 +164,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { bindViewHolder(viewHolder, position, payloads); } - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { + private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; if (position < this.dataSource.getItemCount()) { NotificationViewData notification = dataSource.getItemAt(position); @@ -234,7 +234,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { concreteNotification.getId()); } else { if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { + for (Object item : (List) payloadForHolder) { if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); } @@ -353,11 +353,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } private static class FollowViewHolder extends RecyclerView.ViewHolder { - private TextView message; - private TextView usernameView; - private TextView displayNameView; - private ImageView avatar; - private StatusDisplayOptions statusDisplayOptions; + private final TextView message; + private final TextView usernameView; + private final TextView displayNameView; + private final ImageView avatar; + private final StatusDisplayOptions statusDisplayOptions; FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { super(itemView); @@ -414,7 +414,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final TextView contentWarningDescriptionTextView; private final Button contentWarningButton; private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder - private StatusDisplayOptions statusDisplayOptions; + private final StatusDisplayOptions statusDisplayOptions; private final AbsoluteTimeFormatter absoluteTimeFormatter; private String accountId; @@ -422,9 +422,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; - private int avatarRadius48dp; - private int avatarRadius36dp; - private int avatarRadius24dp; + private final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; StatusNotificationViewHolder( View itemView, 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 e568a5a0..f061523f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -25,7 +25,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; -import androidx.core.view.ViewKt; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -76,46 +75,46 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static final String KEY_CREATED = "created"; } - private TextView displayName; - private TextView username; - private ImageButton replyButton; - private TextView replyCountLabel; - private SparkButton reblogButton; - private SparkButton favouriteButton; - private SparkButton bookmarkButton; - private ImageButton moreButton; - private ConstraintLayout mediaContainer; - protected MediaPreviewLayout mediaPreview; - private TextView sensitiveMediaWarning; - private View sensitiveMediaShow; - protected TextView[] mediaLabels; - protected CharSequence[] mediaDescriptions; - private MaterialButton contentWarningButton; - private ImageView avatarInset; + private final TextView displayName; + private final TextView username; + private final ImageButton replyButton; + private final TextView replyCountLabel; + private final SparkButton reblogButton; + private final SparkButton favouriteButton; + private final SparkButton bookmarkButton; + private final ImageButton moreButton; + private final ConstraintLayout mediaContainer; + protected final MediaPreviewLayout mediaPreview; + private final TextView sensitiveMediaWarning; + private final View sensitiveMediaShow; + protected final TextView[] mediaLabels; + protected final CharSequence[] mediaDescriptions; + private final MaterialButton contentWarningButton; + private final ImageView avatarInset; - public ImageView avatar; - public TextView metaInfo; - public TextView content; - public TextView contentWarningDescription; + public final ImageView avatar; + public final TextView metaInfo; + public final TextView content; + public final TextView contentWarningDescription; - private RecyclerView pollOptions; - private TextView pollDescription; - private Button pollButton; + private final RecyclerView pollOptions; + private final TextView pollDescription; + private final Button pollButton; - private LinearLayout cardView; - private LinearLayout cardInfo; - private ShapeableImageView cardImage; - private TextView cardTitle; - private TextView cardDescription; - private TextView cardUrl; - private PollAdapter pollAdapter; + private final LinearLayout cardView; + private final LinearLayout cardInfo; + private final ShapeableImageView cardImage; + private final TextView cardTitle; + private final TextView cardDescription; + private final TextView cardUrl; + private final PollAdapter pollAdapter; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - protected int avatarRadius48dp; - private int avatarRadius36dp; - private int avatarRadius24dp; + protected final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; private final Drawable mediaPreviewUnloaded; @@ -325,8 +324,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); - String readout = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); - timestampText = readout; + timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); } } @@ -598,9 +596,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { final String accountId, final String statusContent, StatusDisplayOptions statusDisplayOptions) { - View.OnClickListener profileButtonClickListener = button -> { - listener.onViewAccount(accountId); - }; + View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId); avatar.setOnClickListener(profileButtonClickListener); displayName.setOnClickListener(profileButtonClickListener); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 76d12917..13d175a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -44,8 +44,8 @@ public class StatusViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - private TextView statusInfo; - private Button contentCollapseButton; + private final TextView statusInfo; + private final Button contentCollapseButton; public StatusViewHolder(View itemView) { super(itemView); 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 dd732971..bf97d85b 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 @@ -941,13 +941,13 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } private fun getFullUsername(account: Account): String { - if (account.isRemote()) { - return "@" + account.username + return if (account.isRemote()) { + "@" + account.username } else { val localUsername = account.localUsername // Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible. val domain = accountManager.activeAccount!!.domain - return "@$localUsername@$domain" + "@$localUsername@$domain" } } 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 79a043c0..da074c69 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 @@ -697,7 +697,7 @@ class ComposeActivity : var oneMediaWithoutDescription = false for (media in viewModel.media.value) { - if (media.description == null || media.description.isEmpty()) { + if (media.description.isNullOrEmpty()) { oneMediaWithoutDescription = true break } 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 index 9a3e4b00..477c015c 100644 --- 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 @@ -70,9 +70,7 @@ class FocusIndicatorView if (event.actionMasked == MotionEvent.ACTION_CANCEL) return false - val imageSize = this.imageSize - if (imageSize == null) - return false + val imageSize = this.imageSize ?: 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)) 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 5d2d852a..b9757a80 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 @@ -44,7 +44,7 @@ import javax.inject.Inject class DraftHelper @Inject constructor( val context: Context, - val okHttpClient: OkHttpClient, + private val okHttpClient: OkHttpClient, db: AppDatabase ) { @@ -140,7 +140,7 @@ class DraftHelper @Inject constructor( } } - suspend fun deleteDraftAndAttachments(draft: DraftEntity) { + private suspend fun deleteDraftAndAttachments(draft: DraftEntity) { deleteAttachments(draft) draftDao.delete(draft.id) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index 69439803..e748aebb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -33,7 +33,7 @@ class DraftsViewModel @Inject constructor( val database: AppDatabase, val accountManager: AccountManager, val api: MastodonApi, - val draftHelper: DraftHelper + private val draftHelper: DraftHelper ) : ViewModel() { val drafts = Pager( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index 22318440..da63db12 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -55,8 +55,8 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { val portErrorMessage = getString( R.string.pref_title_http_proxy_port_message, - ProxyConfiguration.MIN_PROXY_PORT, - ProxyConfiguration.MAX_PROXY_PORT + MIN_PROXY_PORT, + MAX_PROXY_PORT ) validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 0b970481..968b2743 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -55,7 +55,7 @@ abstract class TimelineViewModel( private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, - protected val sharedPreferences: SharedPreferences, + private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { @@ -69,7 +69,7 @@ abstract class TimelineViewModel( private set protected var alwaysShowSensitiveMedia = false - protected var alwaysOpenSpoilers = false + private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST 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 index 487caae3..84379e07 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -256,7 +256,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, * When started the job will wait `delayMs` then show `view`. If the job is cancelled at * any time `view` is hidden. */ - @CheckResult() + @CheckResult private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch( start = CoroutineStart.LAZY ) { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 917305d1..9d11f47d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -44,7 +44,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { @Inject lateinit var accountManager: AccountManager - public fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { + fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope @@ -63,7 +63,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { AlertDialog.Builder(context) .setTitle(R.string.action_post_failed) .setMessage( - context.getResources().getQuantityString(R.plurals.action_post_failed_detail, count) + context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) ) .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts @@ -78,7 +78,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { } } } else { - draftsNeedUserAlert.observe(context) { _ -> + draftsNeedUserAlert.observe(context) { Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") clearDraftsAlert(coroutineScope, activeAccountId) } @@ -91,7 +91,7 @@ class DraftsAlert @Inject constructor(db: AppDatabase) { /** * Clear drafts alert for specified user */ - fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { + private fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { coroutineScope.launch { draftDao.draftsClearNeedUserAlert(id) } 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 60265845..f852d97b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -40,7 +40,7 @@ class ViewModelFactory @Inject constructor(private val viewModels: MutableMap) 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 b7d74c8b..61901761 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -137,7 +137,7 @@ data class Status( ) } - fun getEditableText(): String { + private fun getEditableText(): String { val contentSpanned = content.parseAsMastodonHtml() val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 7d381c4b..5023b85e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -172,20 +172,20 @@ public class NotificationsFragment extends SFragment implements // Each element is either a Notification for loading data or a Placeholder private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function, NotificationViewData>() { + = new PairedList<>(new Function<>() { @Override public NotificationViewData apply(Either input) { if (input.isRight()) { Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); + .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true + notification, + alwaysShowSensitiveMedia || !sensitiveStatus, + alwaysOpenSpoiler, + true ); } else { return new NotificationViewData.Placeholder(input.asLeft().id, false); @@ -311,8 +311,8 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); Activity activity = getActivity(); if (activity == null) throw new AssertionError("Activity is null"); @@ -344,7 +344,7 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onLoadMore(int totalItemsCount, RecyclerView view) { + public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { NotificationsFragment.this.onLoadMore(); } }; @@ -352,23 +352,23 @@ public class NotificationsFragment extends SFragment implements binding.recyclerView.addOnScrollListener(scrollListener); eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); - } else if (event instanceof BookmarkEvent) { - setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); - } else if (event instanceof ReblogEvent) { - setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); - } else if (event instanceof PinEvent) { - setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); + .observeOn(AndroidSchedulers.mainThread()) + .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) + .subscribe(event -> { + if (event instanceof FavoriteEvent) { + setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); + } else if (event instanceof BookmarkEvent) { + setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); + } else if (event instanceof ReblogEvent) { + setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); + } else if (event instanceof PinEvent) { + setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); + } else if (event instanceof BlockEvent) { + removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof PreferenceChangedEvent) { + onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } + }); } @Override @@ -483,7 +483,6 @@ public class NotificationsFragment extends SFragment implements Notification notification = notifications.get(position).asRight(); Status status = notification.getStatus(); if (status == null) return; - ; super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); } @@ -1171,20 +1170,20 @@ public class NotificationsFragment extends SFragment implements new AsyncDifferConfig.Builder<>(diffCallback).build()); private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } + new NotificationsAdapter.AdapterDataSource<>() { + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; + @Override + public NotificationViewData getItemAt(int pos) { + return differ.getCurrentList().get(pos); + } + }; private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback() { + = new DiffUtil.ItemCallback<>() { @Override public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { 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 b3c0246d..9576825e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -57,10 +57,13 @@ class ViewVideoFragment : ViewMediaFragment() { mediaController.hide() } private lateinit var mediaActivity: ViewMediaActivity - private val TOOLBAR_HIDE_DELAY_MS = 3000L private lateinit var mediaController: MediaController private var isAudio = false + companion object { + private const val TOOLBAR_HIDE_DELAY_MS = 3000L + } + override fun onAttach(context: Context) { super.onAttach(context) videoActionsListener = context as VideoActionsListener @@ -188,10 +191,8 @@ class ViewVideoFragment : ViewMediaFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val attachment = arguments?.getParcelable(ARG_ATTACHMENT) + ?: throw IllegalArgumentException("attachment has to be set") - if (attachment == null) { - throw IllegalArgumentException("attachment has to be set") - } val url = attachment.url isAudio = attachment.type == Attachment.Type.AUDIO 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 ed10bd4e..80deb344 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -38,7 +38,7 @@ class ListStatusAccessibilityDelegate( private val context: Context get() = recyclerView.context - private val itemDelegate = object : RecyclerViewAccessibilityDelegate.ItemDelegate(this) { + private val itemDelegate = object : ItemDelegate(this) { override fun onInitializeAccessibilityNodeInfo( host: View, info: AccessibilityNodeInfoCompat diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt index caab2192..800e7a4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleExtensions.kt @@ -30,7 +30,7 @@ val Locale.modernLanguageCode: String fun Locale.getTuskyDisplayName(context: Context): String { return context.getString( R.string.language_display_name_format, - this?.displayLanguage, - this?.getDisplayLanguage(this) + displayLanguage, + getDisplayLanguage(this) ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt index 0f326743..16a93486 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/RxAwareViewModel.kt @@ -6,7 +6,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable open class RxAwareViewModel : ViewModel() { - val disposables = CompositeDisposable() + private val disposables = CompositeDisposable() fun Disposable.autoDispose() = disposables.add(this) 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 7087a165..670b02ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -147,12 +147,12 @@ fun highlightSpans(text: Spannable, colour: Int) { val length = text.length var start = 0 var end = 0 - while (end >= 0 && end < length && start >= 0) { + while (end in 0 until length && start >= 0) { // Search for url first because it can contain the other characters val found = findPattern(string, end) start = found.start end = found.end - if (start >= 0 && end > start) { + if (start in 0 until end) { text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) start += finders[found.matchType]!!.searchPrefixWidth } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 66b922bd..bc4e617b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -237,7 +237,6 @@ class BottomSheetActivityTest { init { mastodonApi = api - @Suppress("UNCHECKED_CAST") bottomSheet = mock() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5921f8e6..0f81fcdf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ filemoji-compat = "3.2.7" glide = "4.13.2" glide-animation-plugin = "2.23.0" gson = "2.9.0" -kotlin = "1.7.10" +kotlin = "1.8.10" image-cropper = "4.3.1" lifecycle = "2.5.1" material = "1.6.1" From 41d493e72a1dfc339f5360fe9bd77f6ddf7a026e Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Mon, 20 Feb 2023 20:06:50 +0100 Subject: [PATCH 045/418] Allow viewing of the account header image. (#3274) Fixes #3254 --- .../components/account/AccountActivity.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 bf97d85b..4f1627ae 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 @@ -488,18 +488,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .centerCrop() .into(binding.accountHeaderImageView) - binding.accountAvatarImageView.setOnClickListener { avatarView -> - val intent = - ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar) - - avatarView.transitionName = account.avatar - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar) - - startActivity(intent, options.toBundle()) + binding.accountAvatarImageView.setOnClickListener { view -> + viewImage(view, account.avatar) + } + binding.accountHeaderImageView.setOnClickListener { view -> + viewImage(view, account.header) } } } + private fun viewImage(view: View, uri: String) { + view.transitionName = uri + startActivity( + ViewMediaActivity.newSingleImageIntent(view.context, uri), + ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle() + ) + } + /** * Update toolbar views for loaded account */ From 27f6976295a0c83d19393fc08e52b7c78a86d33d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 20 Feb 2023 20:14:16 +0100 Subject: [PATCH 046/418] Add FAB to follow new hashtags from FollowedTagsActivity (#3275) - Add a FAB for user interaction (hide on scroll if appropriate) - Show a dialog to collect the new hashtag - Autocomplete hashtags the same as when composing a status --- .../followedtags/FollowedTagsActivity.kt | 77 ++++++++++++++++++- .../followedtags/FollowedTagsViewModel.kt | 22 +++++- .../res/layout/activity_followed_tags.xml | 11 ++- .../main/res/layout/dialog_follow_hashtag.xml | 16 ++++ app/src/main/res/values/strings.xml | 3 + 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/layout/dialog_follow_hashtag.xml diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt index 0b8e7a5c..94a0b47e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -1,21 +1,30 @@ package com.keylesspalace.tusky.components.followedtags +import android.app.Dialog +import android.content.DialogInterface +import android.content.SharedPreferences import android.os.Bundle import android.util.Log +import android.widget.AutoCompleteTextView import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.HashtagActionListener import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding @@ -25,13 +34,19 @@ import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject -class FollowedTagsActivity : BaseActivity(), HashtagActionListener { +class FollowedTagsActivity : + BaseActivity(), + HashtagActionListener, + ComposeAutoCompleteAdapter.AutocompletionProvider { @Inject lateinit var api: MastodonApi @Inject lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var sharedPreferences: SharedPreferences + private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory } @@ -47,6 +62,11 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { setDisplayShowHomeEnabled(true) } + binding.fab.setOnClickListener { + val dialog: DialogFragment = FollowTagDialog.newInstance() + dialog.show(supportFragmentManager, "dialog") + } + setupAdapter().let { adapter -> setupRecyclerView(adapter) @@ -64,6 +84,19 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { binding.followedTagsView.layoutManager = LinearLayoutManager(this) binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) + if (hideFab) { + binding.followedTagsView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0 && binding.fab.isShown) { + binding.fab.hide() + } else if (dy < 0 && !binding.fab.isShown) { + binding.fab.show() + } + } + }) + } } private fun setupAdapter(): FollowedTagsAdapter { @@ -89,11 +122,15 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } - private fun follow(tagName: String, position: Int) { + private fun follow(tagName: String, position: Int = -1) { lifecycleScope.launch { api.followTag(tagName).fold( { - viewModel.tags.add(position, it) + if (position == -1) { + viewModel.tags.add(it) + } else { + viewModel.tags.add(position, it) + } viewModel.currentSource?.invalidate() }, { @@ -142,7 +179,41 @@ class FollowedTagsActivity : BaseActivity(), HashtagActionListener { } } + override fun search(token: String): List { + return viewModel.searchAutocompleteSuggestions(token) + } + companion object { const val TAG = "FollowedTagsActivity" } + + class FollowTagDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val layout = layoutInflater.inflate(R.layout.dialog_follow_hashtag, null) + val autoCompleteTextView = layout.findViewById(R.id.hashtag)!! + autoCompleteTextView.setAdapter( + ComposeAutoCompleteAdapter( + requireActivity() as FollowedTagsActivity, + animateAvatar = false, + animateEmojis = false, + showBotBadge = false + ) + ) + + return AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_follow_hashtag_title) + .setView(layout) + .setPositiveButton(android.R.string.ok) { _, _ -> + (requireActivity() as FollowedTagsActivity).follow( + autoCompleteTextView.text.toString().removePrefix("#") + ) + } + .setNegativeButton(android.R.string.cancel) { _: DialogInterface, _: Int -> } + .create() + } + + companion object { + fun newInstance(): FollowTagDialog = FollowTagDialog() + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index bcb23a26..1a1b794b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -1,18 +1,22 @@ package com.keylesspalace.tusky.components.followedtags +import android.util.Log 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 at.connyduck.calladapter.networkresult.fold +import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter +import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject class FollowedTagsViewModel @Inject constructor( - api: MastodonApi + private val api: MastodonApi ) : ViewModel(), Injectable { val tags: MutableList = mutableListOf() var nextKey: String? = null @@ -28,6 +32,20 @@ class FollowedTagsViewModel @Inject constructor( ).also { source -> currentSource = source } - }, + } ).flow.cachedIn(viewModelScope) + + fun searchAutocompleteSuggestions(token: String): List { + return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .fold({ searchResult -> + searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) + } + + companion object { + private const val TAG = "FollowedTagsViewModel" + } } diff --git a/app/src/main/res/layout/activity_followed_tags.xml b/app/src/main/res/layout/activity_followed_tags.xml index f2602757..412b4310 100644 --- a/app/src/main/res/layout/activity_followed_tags.xml +++ b/app/src/main/res/layout/activity_followed_tags.xml @@ -35,4 +35,13 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/dialog_follow_hashtag.xml b/app/src/main/res/layout/dialog_follow_hashtag.xml new file mode 100644 index 00000000..cdcf3024 --- /dev/null +++ b/app/src/main/res/layout/dialog_follow_hashtag.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6be0ec5..7a08a1ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,6 +58,9 @@ Followed hashtags Edits + Follow hashtag + #hashtag + \@%s %s boosted Sensitive content From 6cc79c8d752925eb57d5ae693b0e4396ad794271 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 21 Feb 2023 03:14:54 +0800 Subject: [PATCH 047/418] Use unsafeLazy to simplify thread unsafe lazy initializations (#3276) --- .../com/keylesspalace/tusky/AccountsInListFragment.kt | 9 +++++---- .../main/java/com/keylesspalace/tusky/MainActivity.kt | 3 ++- .../com/keylesspalace/tusky/TabPreferenceActivity.kt | 5 +++-- .../tusky/components/account/AccountActivity.kt | 3 ++- .../components/announcements/AnnouncementsActivity.kt | 5 +++-- .../tusky/components/compose/ComposeActivity.kt | 3 ++- .../components/preference/AccountPreferencesFragment.kt | 3 ++- .../tusky/components/preference/PreferencesFragment.kt | 3 ++- .../tusky/components/search/SearchActivity.kt | 3 ++- .../tusky/components/timeline/TimelineFragment.kt | 3 ++- .../com/keylesspalace/tusky/service/SendStatusService.kt | 3 ++- app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt | 5 +++++ 12 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 38d72fc3..6be4224a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -41,6 +41,7 @@ import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State @@ -63,10 +64,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { private val adapter = Adapter() private val searchAdapter = SearchAdapter() - private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } - private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } - private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } - private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } + private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } + private val pm by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) } + private val animateAvatar by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } + private val animateEmojis by unsafeLazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 85518b23..1e147325 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -96,6 +96,7 @@ import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -162,7 +163,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private var unreadAnnouncementsCount = 0 - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } private lateinit var glide: RequestManager diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index d476ea72..cb492424 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -46,6 +46,7 @@ import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.onTextChanged +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import io.reactivex.rxjava3.core.Single @@ -70,9 +71,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private var tabsChanged = false - private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } + private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } - private val hashtagRegex by lazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } + private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { 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 4f1627ae..7f17bfe5 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 @@ -82,6 +82,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog @@ -109,7 +110,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI private lateinit var accountFieldAdapter: AccountFieldAdapter - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index b353fe86..14fbcf8c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -39,6 +39,7 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker import javax.inject.Inject @@ -54,8 +55,8 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, private lateinit var adapter: AnnouncementAdapter - private val picker by lazy { EmojiPicker(this) } - private val pickerDialog by lazy { + private val picker by unsafeLazy { EmojiPicker(this) } + private val pickerDialog by unsafeLazy { PopupWindow(this) .apply { contentView = picker 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 da074c69..b49b5e7f 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 @@ -100,6 +100,7 @@ import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.onTextChanged import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable @@ -139,7 +140,7 @@ class ComposeActivity : private var photoUploadUri: Uri? = null - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT 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 a883f73c..25024d00 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 @@ -53,6 +53,7 @@ import com.keylesspalace.tusky.util.getInitialLanguage import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.makeIcon +import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -72,7 +73,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var eventHub: EventHub - private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() 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 5d6c1ea2..615c9cd5 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 @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.deserialize import com.keylesspalace.tusky.util.makeIcon import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference @@ -47,7 +48,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var localeManager: LocaleManager - private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } enum class ReadingOrder { /** User scrolls up, reading statuses oldest to newest */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 209548e8..beb31611 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.reduceSwipeSensitivity +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector @@ -47,7 +48,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { private val binding by viewBinding(ActivitySearchBinding::inflate) - private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 6d0ff7ae..fe7b5d14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -61,6 +61,7 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -86,7 +87,7 @@ class TimelineFragment : @Inject lateinit var eventHub: EventHub - private val viewModel: TimelineViewModel by lazy { + private val viewModel: TimelineViewModel by unsafeLazy { if (kind == TimelineViewModel.Kind.HOME) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] } else { 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 90babc03..39dcefa0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -34,6 +34,7 @@ import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.unsafeLazy import dagger.android.AndroidInjection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,7 +67,7 @@ class SendStatusService : Service(), Injectable { private val statusesToSend = ConcurrentHashMap() private val sendJobs = ConcurrentHashMap() - private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + private val notificationManager by unsafeLazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } override fun onCreate() { AndroidInjection.inject(this) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt b/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt new file mode 100644 index 00000000..87445566 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Lazy.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.util + +@Suppress("NOTHING_TO_INLINE") +inline fun unsafeLazy(noinline initializer: () -> T): Lazy = + lazy(LazyThreadSafetyMode.NONE, initializer) From 15fb8ad43fd4abb5e44f36245da85c2b674f3dc1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 20 Feb 2023 20:30:32 +0100 Subject: [PATCH 048/418] Update avatar size/placement for follow requests (#3280) Use the same icon size/placement as other notifications. Fixes https://github.com/tuskyapp/Tusky/issues/3279 --- app/src/main/res/layout/item_follow.xml | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/layout/item_follow.xml b/app/src/main/res/layout/item_follow.xml index eff1fb2a..204c7dd4 100644 --- a/app/src/main/res/layout/item_follow.xml +++ b/app/src/main/res/layout/item_follow.xml @@ -28,17 +28,15 @@ - \ No newline at end of file + From ac87482e7ab1430d08f16566c88ba65247441aba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Feb 2023 20:36:11 +0100 Subject: [PATCH 049/418] Update dependency androidx.appcompat:appcompat to v1.6.1 (#3343) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f81fcdf..643682cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "7.4.1" androidx-activity = "1.6.1" -androidx-appcompat = "1.6.0" +androidx-appcompat = "1.6.1" androidx-browser = "1.4.0" androidx-cardview = "1.0.0" androidx-constraintlayout = "2.1.4" From 8f3869d42ecd847091d12ff79b380ccee083a506 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Feb 2023 20:36:22 +0100 Subject: [PATCH 050/418] Update dependency androidx.exifinterface:exifinterface to v1.3.6 (#3344) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 643682cf..c2d3f588 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-browser = "1.4.0" androidx-cardview = "1.0.0" androidx-constraintlayout = "2.1.4" androidx-core = "1.9.0" -androidx-exifinterface = "1.3.5" +androidx-exifinterface = "1.3.6" androidx-fragment = "1.5.5" androidx-junit = "1.1.5" androidx-paging = "3.1.1" From 2974265c4ad3eb437bf0f71a9bad415f1034f01c Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 20 Feb 2023 20:36:37 +0100 Subject: [PATCH 051/418] Use the adapter position when responding to clicks on followed tags (#3334) This ensures that the position is valid w.r.t. to the backing array. Fixes https://github.com/tuskyapp/Tusky/issues/3333 --- .../tusky/components/followedtags/FollowedTagsAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt index 36590088..682d39a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -22,7 +22,7 @@ class FollowedTagsAdapter( viewModel.tags[position].let { tag -> holder.itemView.findViewById(R.id.followed_tag).text = tag.name holder.itemView.findViewById(R.id.followed_tag_unfollow).setOnClickListener { - actionListener.unfollow(tag.name, position) + actionListener.unfollow(tag.name, holder.bindingAdapterPosition) } } } From 640a6faaaebb1a5a4f0b3cb7d61182ad8d80617d Mon Sep 17 00:00:00 2001 From: ButterflyOfFire Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 052/418] Translated using Weblate (French) Currently translated at 84.3% (476 of 564 strings) Translated using Weblate (Arabic) Currently translated at 97.6% (551 of 564 strings) Co-authored-by: ButterflyOfFire Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ar/ Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fr/ Translation: Tusky/Tusky --- app/src/main/res/values-ar/strings.xml | 42 +++++++++++++------------- app/src/main/res/values-fr/strings.xml | 12 ++++++-- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c67fe06a..7457335e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -25,7 +25,7 @@ الألسنة خيط المنشورات - التبويقات والردود + بالردود المدبّسة المتابَعون المتابِعون @@ -37,7 +37,7 @@ المسودات الرّخص \@%s - تم مشاركة %s + شاركه %s محتوى حساس وسائط مخفية اضغط للعرض @@ -280,19 +280,18 @@ النشر بإسم %1$s تعذرت عملية إضافة الشرح - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) - وصف لضعاف البصر -\n(%d أحرف على أقصى تقدير) + وصف لضعاف البصر \n(%d أحرف على أقصى تقدير) + وصف لضعاف البصر +\n(حرف واحد على أقصى تقدير) + وصف لضعاف البصر +\n(حرفان على أقصى تقدير) + وصف لضعاف البصر +\n(%d حروف على أقصى تقدير) + وصف لضعاف البصر +\n(%d حرفًا على أقصى تقدير) + وصف لضعاف البصر +\n(%d حرف على أقصى تقدير) إضافة شرح حذف @@ -301,7 +300,7 @@ هل تود الإحتفاظ بالمسودة ؟ جارٍ إرسال التبويق… خطأ أثناء عملية إرسال التبويق - إرسال التبويقات + إرسال المنشورات أُلغيَ الإرسال تم الاحتفاظ بنسخة مِن التبويق في مسوداتك حرر @@ -477,9 +476,9 @@ تعديل عندما تكون الكلمة أو العبارة أبجدية رقمية فقط ، فلن يتم تطبيقها إلا إذا كانت مطابقة للكلمة بأكملها %1$s • %2$s - التبويقات المبَرمَجة + المنشورات المُبَرمَجة تعديل - التبويقات المبَرمَجة + المنشورات المُبَرمَجة برمجة تبويق صفّر خطأ أثناء البحث عن منشور %s @@ -566,7 +565,7 @@ يجب أن تضع وصف للوسائط. لا يمكن أن يتجاوز حجم ملفات الفيديو والصوت %s ميغا بايت. لا يمكن تحرير الصورة. - عمليات التحرير + التعديلات هناك شكوى جديدة أبداً تشغيل الاشعارات عندما يقوم شخص انت مشترك معه بنشر منشور جديد @@ -635,7 +634,7 @@ فشل في تعيين نقطة التركيز إضافة رد فعل مشاركة رابط الحساب - مشراكة اسم المستخدم + مشاركة اسم مستخدم الحساب شارك رابط الحساب الى… شارك اسم المستجدم الخاص بالحساب الى… تم نسج اسم المستخدم @@ -652,11 +651,11 @@ من أجل استخدام الإشعارات عبر UnifiedPush ، يحتاج Tusky إلى إذن للإشتراك في الإشعارات على خادم Mastodon الخاص بك. يتطلب هذا إعادة تسجيل الدخول لتغيير نطاقات OAuth الممنوحة لـ Tusky. سيؤدي استخدام خيار إعادة تسجيل الدخول هنا أو في اعدادات الحساب إلى الاحتفاظ بجميع المسودات المحلية وذاكرة التخزين المؤقت. الرفاهية انضم في %1$s - اعد تسجيل الدخول لوصل الاشعارات. + أعد تسجيل الدخول لاستلام الاشعارات تجاهل تفاصيل المنشور الذي تفاعلت معه تم تعديله - فشل الوصل الى بيانات الحساب. + فشلت عملية تحميل تفاصيل الحساب فشل الوصول الى صفحة تسجيل الدخول. فشل التحميل إظهار المسودات @@ -666,4 +665,5 @@ الأقدم أولاً الأحدث أولاً الولوج باستخدام متصفح + الوسوم المتداولة \ 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 223509d6..79fc13e2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -220,6 +220,7 @@ %1$s et %2$s %d nouvelle interaction + %d nouvelles interactions %d nouvelles interactions Compte verrouillé @@ -348,6 +349,7 @@ %1$s, %2$s et %3$d autres maximum de %1$d onglet atteint + maximum de %1$d onglets atteint maximum de %1$d onglets atteint Média : %s @@ -361,8 +363,7 @@ Non listé Abonné·e·s - Direct - + Direct Nom de la liste Hashtag sans # Nettoyer @@ -383,18 +384,22 @@ Un sondage que vous avez créé est terminé %d jour restant + %d jours restants %d jours restants %d heure restante + %d heures restantes %d heures restantes %d minute restante + %d minutes restantes %d minutes restantes %d seconde restante + %d secondes restantes %d secondes restantes Activer l’animation des avatars @@ -476,11 +481,13 @@ Ne plus masquer %s %s personne + %s personnes %s personnes Cacher le titre de la barre d’outils supérieure %s voix + %s voix %s voix Sauvegardé ! @@ -500,6 +507,7 @@ Nouveaux messages Vous ne pouvez pas téléverser plus d’ %1$d pièce jointe. + Vous ne pouvez pas téléverser plus de %1$d pièces jointes. Vous ne pouvez pas téléverser plus de %1$d pièces jointes. Bien-être From b5e33e5730d62122df91247b48753693261dbdf3 Mon Sep 17 00:00:00 2001 From: Ricard Torres Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 053/418] Translated using Weblate (Catalan) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Ricard Torres Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/ Translation: Tusky/Tusky --- app/src/main/res/values-ca/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index c35fc262..a9ba8756 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -625,4 +625,8 @@ %s regles Silencia les notificacions %1$s ha creat %2$s + Hashtags populars + %1$d persones parlen del hashtag %2$s + Ús total + Total de comptes \ No newline at end of file From dd89fe53c43ed9b1afc4a27616a371627b12c606 Mon Sep 17 00:00:00 2001 From: Newidyn Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 054/418] Translated using Weblate (Welsh) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Newidyn Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/ Translation: Tusky/Tusky --- app/src/main/res/values-cy/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 465eca24..6afb0b99 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -14,7 +14,7 @@ Rhaid cael caniatâd i ddarllen y cyfrwng hwn. Rhaid cael caniatâd i gadw\'r cyfrwng hwn. Ni allwch atodi delweddau a fideos i\'r un neges. - Methodd yr lanlwytho. + Methodd yr uwchlwytho. Bu gwall wrth anfon y neges. Hafan Hysbysiadau From fe2cc4a8e5f90f0288346767acf1f5064703d598 Mon Sep 17 00:00:00 2001 From: puf Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 055/418] Translated using Weblate (Welsh) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: puf Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/cy/ Translation: Tusky/Tusky --- app/src/main/res/values-cy/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 6afb0b99..263d917e 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -52,7 +52,7 @@ Ffefryn Mwy Creu - Mewngofnodi â Thusky + Mewngofnodi â Tusky Allgofnodi Ydych chi\'n siŵr eich bod am allgofnodi o\'r cyfrif %1$s? Dilyn @@ -675,4 +675,8 @@ Methodd eich negeseuon â lanlwytho ac mae wedi\'u cadw i ddrafftiau. \n \nNaill ai nid oedd modd cysylltu â\'r gweinydd, neu fe wrthododd y negeseuon. + Hashnodau tueddiadol + Siarada %1$d o bobl am hashnod %2$s + Defnydd cyfan + Cyfrifon cyfan \ No newline at end of file From 37440b333e1379c4b81b7848875a0a6d74d4f618 Mon Sep 17 00:00:00 2001 From: Deleted User Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 056/418] Translated using Weblate (German) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6d4226ca..820c0f05 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -624,4 +624,8 @@ Kann zusätzliche Authentifizierungsmethoden unterstützen, erfordert aber einen unterstützten Browser. Meldungen Die Statusquelle konnte nicht vom Server geladen werden. + Angesagte Hashtags + %1$d Leute schreiben über den Hashtag %2$s + Insgesamt verwendet + Konten insgesamt \ No newline at end of file From 3a151b76608c0ff02a2a9b45ba94c0b1992a6529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enzo=20Mart=C3=ADn=20Segovia?= Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 057/418] Translated using Weblate (Spanish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Enzo Martín Segovia Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/ Translation: Tusky/Tusky --- app/src/main/res/values-es/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa4b1602..0f3758a9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -642,4 +642,8 @@ Compartir el nombre de usuario de la cuenta Compartir URL de la cuenta con… Compartir enlace a la cuenta + Hashtags en tendencia + %1$d personas están hablando de #%2$s + Uso total + Cuentas totales \ No newline at end of file From 6a3cc8cbd27207ac4a9fb77dcae8aa348db86c92 Mon Sep 17 00:00:00 2001 From: Paul Sanz Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 058/418] Translated using Weblate (Spanish) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Paul Sanz Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/ Translation: Tusky/Tusky --- app/src/main/res/values-es/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0f3758a9..9b06c0b2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -4,11 +4,11 @@ ¡Se ha producido un error de red! ¡Por favor, comprueba tu conexión e inténtalo de nuevo! Este campo no puede estar vacío. Nombre de dominio incorrecto - Fallo de autenticación con esta instancia. Si esto persiste, prueba en el menú Iniciar sesión con el navegador + Fallo de autenticación con esta instancia. Si esto persiste, prueba en el menú Iniciar sesión con el navegador. No se ha encontrado ningún navegador web. - Ocurrió un error de autorización no identificado. Si esto persiste, prueba en el menú Iniciar sesión con el navegador - La autorización falló. Si estás seguro de que has suministrado las credenciales correctas, prueba en el menú Iniciar sesión con el navegador - Fallo al obtener identificador de login. Si esto persiste, prueba en el menú Iniciar sesión con el navegador + Ocurrió un error de autorización no identificado. Si esto persiste, prueba en el menú Iniciar sesión con el navegador. + La autorización falló. Si estás seguro de que has suministrado las credenciales correctas, prueba en el menú Iniciar sesión con el navegador. + Fallo al obtener identificador de login. Si esto persiste, prueba en el menú Iniciar sesión con el navegador. ¡La publicación es demasiado larga! No se admite este tipo de archivo. No pudo abrirse el fichero. From 94a49262595597ccf5ce0d7f0a2ab1dae69a414d Mon Sep 17 00:00:00 2001 From: "Gera, Zoltan" Date: Tue, 21 Feb 2023 18:30:19 +0000 Subject: [PATCH 059/418] Translated using Weblate (Hungarian) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Gera, Zoltan Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/hu/ Translation: Tusky/Tusky --- app/src/main/res/values-hu/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 63eae753..134fae94 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -624,4 +624,8 @@ Bejelentkezés Böngészővel A legtöbb esetben működik. Nem szivárog ki adat más alkalmazások számára. Támogathat más hitelesítési módozatokat is, de ehhez támogatott böngészőre van szükség. + Népszerű hashtagek + %1$d ember beszél a %2$s hashtagről + Összes használat + Összes fiók \ No newline at end of file From b0df135b99b5de29dab382d068e4b3c8e9f854f4 Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 060/418] Translated using Weblate (Italian) Currently translated at 99.2% (560 of 564 strings) Co-authored-by: Manuel Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/it/ Translation: Tusky/Tusky --- app/src/main/res/values-it/strings.xml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a4055010..6cb9ea71 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -29,7 +29,7 @@ Fissati Seguiti Seguaci - Preferiti + Apprezzati Utenti silenziati Utenti bloccati Richieste di seguirti @@ -48,7 +48,7 @@ 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 messaggio nei preferiti + %s ha apprezzato il tuo post %s ti segue Segnala @%s Commenti aggiuntivi? @@ -56,8 +56,8 @@ Rispondi Condividi Rimuovi condivisione - Aggiungi ai preferiti - Rimuovi preferito + Aggiungi ai post apprezzati + Rimuovi apprezzamento Di più Componi Accedi con Tusky @@ -78,7 +78,7 @@ Profilo Preferenze Preferenze account - Preferiti + Apprezzati Utenti silenziati Utenti bloccati Richieste di seguirti @@ -165,7 +165,7 @@ vengo menzionato vengo seguito i miei messaggi vengono condivisi - i miei messaggi vengono messi nei preferiti + i miei messaggi vengono messi negli apprezzamenti Aspetto Tema dell\'app Timeline @@ -208,8 +208,8 @@ Notifiche su nuovi seguaci Ricondivisioni Notifiche sui tuoi messaggi che vengono condivisi - Preferiti - Notifiche sui tuoi messaggi che vengono segnati come preferiti + Apprezzati + Notifiche sui tuoi messaggi che vengono segnati come apprezzati %s ti ha menzionato %1$s, %2$s, %3$s e %4$d altri %1$s, %2$s e %3$s @@ -341,7 +341,7 @@ %s ricondivisioni Condiviso da - Aggiunto ai preferiti da + Aggiunto ai post apprezzati da %1$s %1$s e %2$s %1$s, %2$s ed altri %3$d @@ -354,7 +354,7 @@ Contenuto sensibile: %s Nessuna descrizione Ribloggato - Messo nei preferiti + Messo nei post apprezzati Pubblico Non in elenco From afebee1f2706a3b5a078143e2fc6a53e5a0b90d5 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 061/418] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (566 of 566 strings) Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Eric Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/zh_Hans/ Translation: Tusky/Tusky --- app/src/main/res/values-zh-rCN/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 98dd0e43..1a69408e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -622,4 +622,10 @@ 你的嘟文上传失败,已被保存到草稿。 \n \n要么是无法联系服务器,要么是服务器拒绝了它。 + %1$d 人正谈论话题标签 %2$s + 热门话题标签 + 总使用 + 总账户 + #hashtag + 关注话题标签 \ No newline at end of file From e6c5f61da6c95a04eab38419f250b3930febf8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sveinn=20=C3=AD=20Felli?= Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 062/418] Translated using Weblate (Icelandic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Sveinn í Felli Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/ Translation: Tusky/Tusky --- app/src/main/res/values-is/strings.xml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 446a31ef..011f16f9 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -1,6 +1,6 @@ - Skrá inn með Mastodon + Skrá inn með Tusky Hvað er tilvik\? Eftirlæti Drög @@ -16,11 +16,11 @@ Villa í netkerfi: Athugaðu nettenginguna þína og prófaðu svo aftur! Þetta má ekki vera tómt. Ógilt lén sett inn - Mistókst að auðkenna gagnvart þessu tilviki. + Mistókst að auðkenna gagnvart þessu tilviki. Ef þetta er viðvarandi skaltu prófa \'Skrá inn í vafra\' úr valmyndinni. Gat ekki fundið neinn vafra til að nota. - Óskilgreind auðkenningarvilla kom upp. - Heimild var hafnað. - Mistókst að fá innskráningarteikn. + Óskilgreind auðkenningarvilla kom upp. Mistókst að auðkenna gagnvart þessu tilviki. Ef þetta er viðvarandi skaltu prófa \'Skrá inn í vafra\' úr valmyndinni. + Heimild var hafnað. Mistókst að auðkenna gagnvart þessu tilviki. Ef þetta er viðvarandi skaltu prófa \'Skrá inn í vafra\' úr valmyndinni. + Mistókst að fá innskráningarteikn. Mistókst að auðkenna gagnvart þessu tilviki. Ef þetta er viðvarandi skaltu prófa \'Skrá inn í vafra\' úr valmyndinni. Færslan er of löng! Þessa tegund skrár er ekki hægt að senda inn. Ekki var hægt að opna skrána. @@ -604,4 +604,20 @@ Deila notandanafni aðgangs til… Notandanafn afritað Þagga tilkynningar + Vinsæl myllumerki + %1$d manns eru að tala um myllumerkið %2$s + Innsending mistókst + Birta drög + Afgreiða + Ekki tókst að senda inn færsluna þína og hefur hún verið vistuð í Drög. +\n +\nAnnað hvort náðist ekki í netþjóninn, eða hann hafnaði færslunni. + Ekki tókst að senda inn færslurnar þína og hafa þær verið vistaðar í Drög. +\n +\nAnnað hvort náðist ekki í netþjóninn, eða hann hafnaði færslunum. + Skrá inn í vafra + Virkar í flestum tilvikum. Engum gögnum er lekið til annarra forrita. + Gæti stutt fleiri aðferðir til auðkenningar, en krefst studds vafra. + Heildarnotkun + Aðgangar alls \ No newline at end of file From 2b92fabdc0de264d162d2cd1df742ef1d1c2fb34 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 063/418] Translated using Weblate (Ukrainian) Currently translated at 100.0% (566 of 566 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Ihor Hordiichuk Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/ Translation: Tusky/Tusky --- app/src/main/res/values-uk/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4e4e4a89..89ab2003 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -642,4 +642,10 @@ Не вдалося вивантажити ваш допис і його було збережено в чернетках. \n \nАбо з не вдалося зв\'язатися з сервером, або він відхилив допис. + %1$d людей обговорюють хештеґ %2$s + Популярні хештеґи + Усього використано + Усього облікових записів + Стежити за хештегом + #hashtag \ No newline at end of file From 78a6bde25a7afcfd9897fbb3d3f527b6e1335fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 064/418] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (566 of 566 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: Hồ Nhất Duy Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/vi/ Translation: Tusky/Tusky --- app/src/main/res/values-vi/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index f0e14490..934d7710 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -603,4 +603,10 @@ \nKhông thể liên lạc được với máy chủ hoặc nó đã từ chối tút. Xem bản nháp Bỏ qua + Hashtag nổi bật + %1$d người đang thảo luận về %2$s + Tổng lượt dùng + Tổng tài khoản + Theo dõi hashtag + #hashtag \ No newline at end of file From 6923a4e06dae169df5c9c0246108ab0018101c50 Mon Sep 17 00:00:00 2001 From: Garutmaan Garuda Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 065/418] Translated using Weblate (Sanskrit) Currently translated at 85.1% (480 of 564 strings) Co-authored-by: Garutmaan Garuda Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sa/ Translation: Tusky/Tusky --- app/src/main/res/values-sa/strings.xml | 98 ++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 70080451..2eb19a21 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -11,7 +11,7 @@ श्रव्यदृश्यसामग्र्यः द्रष्टुमनुमतिर्दातव्या । सा सञ्चिका नोद्घाट्यते । नैतादृशा सञ्चिका उपारोपणीया । - सम्प्रवेशस्तोकं न लब्धः । + सम्प्रवेशस्तोकं न लब्धम् । प्रमाणीकरणं निषिद्धम् । अज्ञातः प्रमाणीकरणदोषो जातः । प्रयोजनार्थं जालसञ्चारकं न लब्धम् । @@ -45,8 +45,8 @@ अनुसरति कीलिताः सप्रत्युत्तरम् - प्रकटनानि - दौत्यम् + दौत्यानि + दौत्यमाला पीठिकाः प्रत्यक्षसन्देशाः सङ्घीयाः @@ -112,11 +112,11 @@ उद्घाट्यताम् #%d जालस्थलानि उल्लेखाः - प्रचलितवस्तूनि + निश्रेणिचिह्नशीर्षकाः प्रियाणि दृश्यन्ताम् प्रकाशनानि दृश्यन्ताम् प्रकाशनलेखकः उद्घाट्यताम् - प्रचलितवस्तूनि + निश्रेणिचिह्नशीर्षकाः उल्लेखाः जालस्थलानि पीठिका युज्यताम् @@ -431,7 +431,7 @@ मार्ज्यन्ताम् सूचिः सूचिरवचीयताम् - प्रचलितानि + निश्रेणिचिह्नशीर्षकाः # चिह्नं विना प्रचलितम् प्रचलितं युज्यताम् सूचिनाम @@ -461,4 +461,90 @@ %1$s प्रियम् %1$s प्रिये + समस्त-उपयोगः + सकललेखाः + दौत्यमाला दृश्यते + %s नियमाः + पाठनक्रमः + पुरातनं प्रथमम् + नूतनं प्रथमम् + अपरिमितम् + केन्द्रबिन्दुं स्थाप्यताम् + सूचनाः निशब्दाः करोतु + कीलनं विफलं जातम् + कीलनस्य अपाकरणं विफलं जातम् + अशक्तं कृतम् + <अप्रमाणम्> + चित्रं सम्पाद्यताम् + <अनियुक्तम्> + १८० दिनानि + ३६५ दिनानि + (परिवर्तनं नास्ति) + सूचनाः सम्दृश्यन्ताम् + %s (🔗 %s) + न कदापि + पूर्वनिविष्टा प्रकाशका भाषा + आवेदनानि + सम्पादनानि निवेद्यन्ताम् + साधनशलाकासु उपभोक्तृनाम दृश्यताम् + सम्पादनानि + नूतनोपभोक्तॄन् प्रति सूचनाः + ध्वनिः + %s (%s) + ३० दिनानि + अन्यम् + सम्प्रवेशः + इदं सम्भाषणं निष्कास्यताम् \? + कश्चन पञ्जीकरणम् अकरोत् + सर्वदा + नूतन-प्रकटनानि + पञ्जीकरणानि + उद्घोषणाः न सन्ति। + अधुना + ६० दिनानि + ग्राहकता-समापनम् + %s (%s) + उपारोपनं विफलं जातम् + उत्सृज्यताम् + लेखविकर्षान् दर्शयतु + सम्पादनं कृतम् + %1$s निर्मितम् %2$s + %1$s सम्पादितम् %2$s + ग्राहकता + प्रारूपं निष्कासितम् + नियम-उल्लङ्घनम् + #%s-निश्रेणिचिह्नशीर्षकस्य निशब्दीकरणे दोषः + #%s-निश्रेणिचिह्नशीर्षकस्य सशब्दीकरणे दोषः + जालसञ्चारकेन सम्प्रविश्यताम् + प्रतिक्रियां योजयतु + %s-व्यक्तिः %s-व्यक्तिं प्रति आवेदनमकरोत् + परिवर्तनानि निष्कासयतु + सम्पादनम् अनुवर्तताम् + %s सम्पादितम् + %s सद्यः प्रसारितम् + %s पञ्जीकरणम् अकरोत् + सम्भाषणं निष्कास्यताम् + ९० दिनानि + पुटचिह्नं निस्कास्यताम् + %s स्वस्य दौत्यं समपादयत् + दौत्यं संस्कुरुताम् + लेखविकर्षं रक्षामि… + %1$s सदस्यः अभवत् + उपभोक्तृनाम्नः प्रतिकृतिः कृतः + समयः + अनिष्टसन्देशाः + १४ दिनानि + दौत्यस्य भाषा + #%s-अनुसरणे दोषो जातः + #%s-अनुसरण-अपाकरणे दोषो जातः + विवरणानि + उत्सृज्यताम् + सम्योजितानि + १+ + रक्षितम् ! + उद्घोषणाः + चित्रं सम्पादयितुं न शक्यते। + व्यक्तित्वलेखस्य विवरणानि दर्शनं विफलं जातम् + सम्प्रवेशपुटं दर्शयितुं न शक्यते। + पुनःसम्प्रवेशाय विज्ञापन-सूचनाः \ No newline at end of file From d82200569e83c9514c47e9ff7e0bd5e6fa8d1eab Mon Sep 17 00:00:00 2001 From: XoseM Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 066/418] Translated using Weblate (Galician) Currently translated at 100.0% (564 of 564 strings) Co-authored-by: XoseM Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/ Translation: Tusky/Tusky --- app/src/main/res/values-gl/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 2feaf61a..c9558e33 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -616,4 +616,8 @@ Acceder no Navegador Pode ter soporte para métodos adicionais de autenticación, pero require un navegador soportado. Funciona case sempre. Non se filtran datos a outras apps. + %1$d persoas están falando acerca do cancelo %2$s + Cancelos en voga + Uso total + Total de contas \ No newline at end of file From 5e3d6a8f978d28d328c050988ee66e0b8a582440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81rti=C5=86=C5=A1=20Bru=C5=86enieks?= Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 067/418] Translated using Weblate (Latvian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 95.0% (536 of 564 strings) Co-authored-by: Mārtiņš Bruņenieks Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/ Translation: Tusky/Tusky --- app/src/main/res/values-lv/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 18dfd1a7..3d2bc52d 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -574,4 +574,16 @@ \n - Sekotāju/ierakstu skaitu profilos \n \n Pašpiegādātie paziņojumi netiks ietekmēti, bet paziņojumu preferences var pārskatīt manuāli. + Var atbalstīt papildu autentifikācijas metodes, bet ir nepieciešama atbalstīta pārlūkprogramma. + Šeit var ievadīt jebkuras instances adresi vai domēnu, piemēram, mastodon.social, icosahedron.website, social.tchncs.de un citus! +\n +\nJa tev vēl nav konta, vari ievadīt tās instances nosaukumu, kurai vēlies pievienoties un izveidot kontu. +\n +\nInstance ir vieta, kur tiek mitināts tavs konts, taču tu vari viegli sazināties ar cilvēkiem citās instancēs un sekot tiem, it kā atrastos tajā pašā vietnē. +\n +\nVairāk informācijas var atrast joinmastodon.org. + Neizdevās iegūt ierakstus + %1$d cilvēki runā par tēmturi %2$s + Kopējais lietojums + Konti kopā \ No newline at end of file From 766dab21731d49c7c6c4c2a84d6988271ef08c5d Mon Sep 17 00:00:00 2001 From: Deleted User Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 068/418] Translated using Weblate (German) Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Deleted User Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/de/ Translation: Tusky/Tusky --- app/src/main/res/values-de/strings.xml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 820c0f05..52455f16 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -138,7 +138,7 @@ Profilbild Titelbild Was ist eine Instanz\? - Verbinde … + Wird verbunden … Die Adresse oder Domain einer Instanz kann hier eingegeben werden, wie z. B. mastodon.social, icosahedron.website, social.tchncs.de, and more! \n \nWenn du bis jetzt kein Konto hast, kannst du hier den Namen einer Instanz eingeben und dort ein Konto einrichten. @@ -272,7 +272,7 @@ Gesperrtes Profil Wer dir folgen möchte, muss um deine Erlaubnis bitten Entwurf speichern? - Sende Beitrag … + Beitrag wird gesendet … Fehler beim Senden Beiträge senden Senden abgebrochen @@ -282,7 +282,7 @@ Emoji-Stil System-Standard Du musst diese Emoji-Sets zunächst herunterladen - Schlage nach … + Wird nachgeschlagen … Alle Beiträge aus-/einklappen Beitrag öffnen App-Neustart erforderlich @@ -433,10 +433,10 @@ Als Lesezeichen gespeichert Liste auswählen Liste - Fehler beim Nachschlagen von Beitrag %s + Fehler beim Nachschlagen von %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. + Das Datum des geplanten Beitrags muss mindestens 5 Minuten in der Zukunft liegen. Benachrichtigungen über neue Follower-Anfragen Anfrage zum Folgen gesendet Follower-Anfragen @@ -546,7 +546,7 @@ Bild bearbeiten Details Das Bild konnte nicht bearbeitet werden. - Speichere Entwurf … + Entwurf wird gespeichert … Video- und Audiodateien dürfen nicht größer als %s MB sein. Fehler beim Folgen von #%s Fehler beim Entfolgen von #%s @@ -611,9 +611,9 @@ Mit Browser anmelden Name des Profils teilen Link zum Profil teilen - Teile Profil-Link mit … + Profil-Link teilen an … Profilname teilen an … - Proilname kopiert + Profilname kopiert %1$s bearbeitete %2$s %1$s erstellte %2$s Neue Meldung über %s @@ -628,4 +628,6 @@ %1$d Leute schreiben über den Hashtag %2$s Insgesamt verwendet Konten insgesamt + Hashtag folgen + #Hashtag \ No newline at end of file From 837a91e12cbdc409c720ad2cbb4bcea9e2dbac7e Mon Sep 17 00:00:00 2001 From: Danial Behzadi Date: Tue, 21 Feb 2023 18:30:20 +0000 Subject: [PATCH 069/418] Translated using Weblate (Persian) Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Danial Behzadi Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/fa/ Translation: Tusky/Tusky --- app/src/main/res/values-fa/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index c507b67b..1f06f426 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -627,4 +627,6 @@ %1$d نفر دارند دربارهٔ برچسب %2$s حرف می‌زنند استفادهٔ کل مجموع حساب‌ها + پی‌گیری برچسب + #برچسب \ No newline at end of file From aa14013adc206d2ff65ae3a7885a2544051fbd56 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:37:07 +0100 Subject: [PATCH 070/418] Update dependency io.reactivex.rxjava3:rxjava to v3.1.6 (#3353) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2d3f588..2ffd93d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ okhttp = "4.10.0" retrofit = "2.9.0" robolectric = "4.8.1" rxandroid3 = "3.0.0" -rxjava3 = "3.1.3" +rxjava3 = "3.1.6" rxkotlin3 = "3.0.1" photoview = "2.3.0" sparkbutton = "4.1.0" From 53f7afd9ee49401a13edf79d46a8855b7ff6889d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:37:33 +0100 Subject: [PATCH 071/418] Update androidx-work to v2.8.0 (#3354) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ffd93d2..9c235168 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ 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" +androidx-work = "2.8.0" androidx-room = "2.5.0" autodispose = "2.1.1" bouncycastle = "1.70" From b62fc9bf07377a23551332f8b815368600387c26 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:37:44 +0100 Subject: [PATCH 072/418] Update dagger to v2.45 (#3355) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c235168..1cf51453 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ autodispose = "2.1.1" bouncycastle = "1.70" conscrypt = "2.5.2" coroutines = "1.6.4" -dagger = "2.43.2" +dagger = "2.45" emoji2 = "1.1.0" espresso = "3.5.1" filemoji-compat = "3.2.7" From 01eefd94a0053a7ce856b266931681c581cd40ea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:39:39 +0100 Subject: [PATCH 073/418] Update dependency androidx.browser:browser to v1.5.0 (#3356) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cf51453..1f2afeb8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "7.4.1" androidx-activity = "1.6.1" androidx-appcompat = "1.6.1" -androidx-browser = "1.4.0" +androidx-browser = "1.5.0" androidx-cardview = "1.0.0" androidx-constraintlayout = "2.1.4" androidx-core = "1.9.0" From fc71e398d5e32d6d7cfdde030073b869f4ccda82 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:39:49 +0100 Subject: [PATCH 074/418] Update dependency com.github.UnifiedPush:android-connector to v2.1.1 (#3357) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f2afeb8..ce0f23c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ rxjava3 = "3.1.6" rxkotlin3 = "3.0.1" photoview = "2.3.0" sparkbutton = "4.1.0" -unified-push = "2.0.1" +unified-push = "2.1.1" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From b35cc1fd6b969965c1a815012a272dc64706ba44 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:41:53 +0100 Subject: [PATCH 075/418] Update dependency com.github.penfeizhou.android.animation:glide-plugin to v2.24.0 (#3358) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce0f23c0..8a15c96d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ emoji2 = "1.1.0" espresso = "3.5.1" filemoji-compat = "3.2.7" glide = "4.13.2" -glide-animation-plugin = "2.23.0" +glide-animation-plugin = "2.24.0" gson = "2.9.0" kotlin = "1.8.10" image-cropper = "4.3.1" From 62c7d631310e5c3ef40c6c8821a087274bf1aabe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 19:43:51 +0100 Subject: [PATCH 076/418] Update dependency com.github.CanHub:Android-Image-Cropper to v4.3.2 (#3347) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a15c96d..7c6a4309 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ glide = "4.13.2" glide-animation-plugin = "2.24.0" gson = "2.9.0" kotlin = "1.8.10" -image-cropper = "4.3.1" +image-cropper = "4.3.2" lifecycle = "2.5.1" material = "1.6.1" material-drawer = "8.4.5" From 142fe4b743d6410ec9a6edecbee6240577a1a6cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Feb 2023 20:17:45 +0100 Subject: [PATCH 077/418] Update dependency io.reactivex.rxjava3:rxandroid to v3.0.2 (#3348) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c6a4309..8330e830 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ networkresult-calladapter = "1.0.0" okhttp = "4.10.0" retrofit = "2.9.0" robolectric = "4.8.1" -rxandroid3 = "3.0.0" +rxandroid3 = "3.0.2" rxjava3 = "3.1.6" rxkotlin3 = "3.0.1" photoview = "2.3.0" From 60fd9cf0e7037ada00a94a2353d97107bf9527f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 23 Feb 2023 17:34:04 +0100 Subject: [PATCH 078/418] Update dependency com.google.code.gson:gson to v2.10.1 (#3362) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8330e830..1b7b2127 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ espresso = "3.5.1" filemoji-compat = "3.2.7" glide = "4.13.2" glide-animation-plugin = "2.24.0" -gson = "2.9.0" +gson = "2.10.1" kotlin = "1.8.10" image-cropper = "4.3.2" lifecycle = "2.5.1" From 70092c8de2f4e34cf113c72cc0d4eff435c008d7 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 23 Feb 2023 19:30:27 +0100 Subject: [PATCH 079/418] Make "Up" and "Overflow" menu icons more visible in AccountProfile (#3272) * Make "Up" and "Overflow" menu icons more visible in AccountProfile The toolbar in AccountProfile is transparent, so any profile image the user has chosen is shown under it. This makes the "Up" and "Overflow" menu icons also have transparent backgrouns. Consequently, they can be hard to spot, or possibly invisible, on backgrounds that are very dark or very light. Fix this by compositing the icons in a LayerDrawable, with a circular background identical to the surface colour. This ensures they stand out against the background image, and blend in when the user scrolls. * Get and reuse the background drawable * Apply a smidgen of transparency --- .../components/account/AccountActivity.kt | 19 +++++++++++++++++++ .../main/res/drawable/background_circle.xml | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 app/src/main/res/drawable/background_circle.xml 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 7f17bfe5..ba261c1d 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 @@ -22,6 +22,7 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.text.Editable import android.view.Menu @@ -32,6 +33,7 @@ import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.Px import androidx.appcompat.app.AlertDialog +import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -299,6 +301,23 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) binding.accountToolbar.background = toolbarBackground + // Provide a non-transparent background to the navigation and overflow icons to ensure + // they remain visible over whatever the profile background image might be. + val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!! + backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere + binding.accountToolbar.navigationIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.navigationIcon + ) + ) + binding.accountToolbar.overflowIcon = LayerDrawable( + arrayOf( + backgroundCircle, + binding.accountToolbar.overflowIcon + ) + ) + binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply { diff --git a/app/src/main/res/drawable/background_circle.xml b/app/src/main/res/drawable/background_circle.xml new file mode 100644 index 00000000..e10c9756 --- /dev/null +++ b/app/src/main/res/drawable/background_circle.xml @@ -0,0 +1,5 @@ + + + + From 9e0ff78fb4ef2162998f0d30ccfe9cb7dea109ef Mon Sep 17 00:00:00 2001 From: Eric Frohnhoefer Date: Thu, 23 Feb 2023 10:41:16 -0800 Subject: [PATCH 080/418] Fix media controller UI not showing during audio playback (#3286) * Update ViewVideoFragment.kt Testing - Open audio attachment: https://solarpunk.moe/@vv/109562659215759090 - Ensure media control UI and alt text is shown once playback starts Fixes #3261 * Fix commit issue * Fix spacing --- .../tusky/fragment/ViewVideoFragment.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 9576825e..68dc6687 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -73,8 +73,8 @@ class ViewVideoFragment : ViewMediaFragment() { super.onResume() if (_binding != null) { - if (mediaActivity.isToolbarVisible) { - handler.postDelayed(hideToolbar, TOOLBAR_HIDE_DELAY_MS) + if (mediaActivity.isToolbarVisible && !isAudio) { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } binding.videoView.start() } @@ -130,18 +130,17 @@ class ViewVideoFragment : ViewMediaFragment() { binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { - override fun onPause() { - handler.removeCallbacks(hideToolbar) - } override fun onPlay() { - // Audio doesn't cause the controller to show automatically, - // and we only want to hide the toolbar if it's a video. - if (isAudio) { - mediaController.show() - } else { + if (!isAudio) { hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) } } + + override fun onPause() { + if (!isAudio) { + handler.removeCallbacks(hideToolbar) + } + } }) binding.videoView.setOnPreparedListener { mp -> val containerWidth = binding.videoContainer.measuredWidth.toFloat() @@ -167,6 +166,11 @@ class ViewVideoFragment : ViewMediaFragment() { false } + // Audio doesn't cause the controller to show automatically + if (isAudio) { + mediaController.show() + } + binding.progressBar.hide() mp.isLooping = true } From 77dbf3c1a93800f21a3abafb3e3086699f037626 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Feb 2023 20:36:18 +0100 Subject: [PATCH 081/418] Update dependency com.google.android.material:material to v1.8.0 (#3361) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1b7b2127..7c749a41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ gson = "2.10.1" kotlin = "1.8.10" image-cropper = "4.3.2" lifecycle = "2.5.1" -material = "1.6.1" +material = "1.8.0" material-drawer = "8.4.5" material-typeface = "4.0.0.2-kotlin" mockito-inline = "4.7.0" From a0ee3072f0dee592f02f8bebdff7337187ce3ba9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Feb 2023 20:37:40 +0100 Subject: [PATCH 082/418] Update dependency org.mockito.kotlin:mockito-kotlin to v4.1.0 (#3364) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c749a41..3c8b99c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ material = "1.8.0" material-drawer = "8.4.5" material-typeface = "4.0.0.2-kotlin" mockito-inline = "4.7.0" -mockito-kotlin = "4.0.0" +mockito-kotlin = "4.1.0" networkresult-calladapter = "1.0.0" okhttp = "4.10.0" retrofit = "2.9.0" From 9eec1ab5c013f2ee70dd607a70f51255b2639420 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Feb 2023 20:38:14 +0100 Subject: [PATCH 083/418] Update emoji2 to v1.2.0 (#3368) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c8b99c8..3039cb49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ bouncycastle = "1.70" conscrypt = "2.5.2" coroutines = "1.6.4" dagger = "2.45" -emoji2 = "1.1.0" +emoji2 = "1.2.0" espresso = "3.5.1" filemoji-compat = "3.2.7" glide = "4.13.2" From c6f7ecdb5b9a1210997d10663a37aea4d8c0d9a5 Mon Sep 17 00:00:00 2001 From: Goooler Date: Sun, 26 Feb 2023 03:59:39 +0800 Subject: [PATCH 084/418] Gradle 8.0.1 (#3338) https://docs.gradle.org/8.0/release-notes.html --- app/build.gradle | 5 ++--- build.gradle | 6 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 61574 -> 61608 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 ++-- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ab77fe0a..3ca78704 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -94,13 +94,12 @@ android { includeInApk false includeInBundle false } + // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. + // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11 - } applicationVariants.configureEach { variant -> variant.outputs.configureEach { outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + diff --git a/build.gradle b/build.gradle index 4ee7a054..14527214 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,12 @@ plugins { allprojects { apply plugin: libs.plugins.ktlint.get().pluginId + + plugins.withType(JavaBasePlugin).configureEach { + java { + toolchain.languageVersion = JavaLanguageVersion.of(11) + } + } } tasks.register('clean') { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 943f0cbfa754578e88a3dae77fce6e3dea56edbf..ccebba7710deaf9f98673a68957ea02138b60d0a 100644 GIT binary patch delta 5094 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 delta 5107 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 I6Xdi10eY*&F8}}l diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f398c33c..fc10b601 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 65dcd68d..79a61d42 100755 --- a/gradlew +++ b/gradlew @@ -144,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac From 4a0251800d60c820383f5c692e11a70a6b5b89dd Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 25 Feb 2023 21:06:22 +0100 Subject: [PATCH 085/418] Fix lifecycle handling bug (#3319) Fragments can go `onCreate` -> `onCreateView` -> `onViewCreated` -> `onDestroyView` without transitioning through `onStart`. The previous code assumed `onStart` was always called. Se https://itnext.io/an-update-to-the-fragmentviewbindingdelegate-the-bug-weve-inherited-from-autoclearedvalue-7fc0a89fcae1 --- .../tusky/util/ViewBindingExtensions.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 5342cbf3..f398e2b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer import androidx.viewbinding.ViewBinding import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty @@ -28,23 +29,26 @@ class FragmentViewBindingDelegate( private var binding: T? = null init { - fragment.lifecycle.addObserver( - 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.lifecycle.addObserver(object : DefaultLifecycleObserver { + val viewLifecycleOwnerLiveDataObserver = + Observer { + val viewLifecycleOwner = it ?: return@Observer + + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + }) } + + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerLiveDataObserver) } - ) + + override fun onDestroy(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerLiveDataObserver) + } + }) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { @@ -58,7 +62,7 @@ class FragmentViewBindingDelegate( throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") } - return viewBindingFactory(thisRef.requireView()).also { this@FragmentViewBindingDelegate.binding = it } + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } } } From f2b07196e6accb478c93cbc118864fb7efe83310 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Sat, 25 Feb 2023 21:15:21 +0100 Subject: [PATCH 086/418] Improve language list prioritization. (#3293) Partially addresses #3277 --- .../components/compose/ComposeActivity.kt | 8 +- .../preference/AccountPreferencesFragment.kt | 4 +- .../keylesspalace/tusky/util/LocaleUtils.kt | 90 +++++++++---------- .../tusky/util/LocaleUtilsTest.kt | 82 +++++++++++++++++ 4 files changed, 128 insertions(+), 56 deletions(-) create mode 100644 app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt 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 b49b5e7f..7ff0733e 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 @@ -90,7 +90,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.afterTextChanged -import com.keylesspalace.tusky.util.getInitialLanguage +import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.hide @@ -267,7 +267,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguage(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() @@ -543,7 +543,7 @@ class ComposeActivity : ) } - private fun setupLanguageSpinner(initialLanguage: String) { + private fun setupLanguageSpinner(initialLanguages: List) { 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 @@ -554,7 +554,7 @@ class ComposeActivity : } } binding.composePostLanguageButton.apply { - adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguage)) + adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages)) setSelection(0) } } 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 25024d00..0cbdacba 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 @@ -49,7 +49,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.getInitialLanguage +import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.makeIcon @@ -197,7 +197,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } listPreference { - val locales = getLocaleList(getInitialLanguage(null, accountManager.activeAccount)) + val locales = getLocaleList(getInitialLanguages(null, accountManager.activeAccount)) setTitle(R.string.pref_default_post_language) // Explicitly add "System default" to the start of the list entries = ( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt index 316e14d0..655348b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -23,67 +23,57 @@ import java.util.Locale private const val TAG: String = "LocaleUtils" -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) - } +private fun LocaleListCompat.toList(): List { + val list = mutableListOf() + for (index in 0 until this.size()) { + this[index]?.let { list.add(it) } } + return list } // 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 } - +private fun ensureLanguagesAreFirst(locales: MutableList, languages: List) { + for (language in languages.reversed()) { + // Iterate prioritized languages in reverse to retain the order once bubbled to the top + var currentLocaleIndex = locales.indexOfFirst { it.language == 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 - } - } + // 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) { - // Move preselected locale to the top - locales.add(0, locales.removeAt(currentLocaleIndex)) + 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'") + continue + } + } + + if (currentLocaleIndex > 0) { + // Move preselected locale to the top + locales.add(0, locales.removeAt(currentLocaleIndex)) + } } } -fun getInitialLanguage(language: String? = null, activeAccount: AccountEntity? = null): String { - return if (language.isNullOrEmpty()) { - // Account-specific language set on the server - if (activeAccount?.defaultPostLanguage?.isNotEmpty() == true) { - activeAccount.defaultPostLanguage - } else { - // Setting the application ui preference sets the default locale - AppCompatDelegate.getApplicationLocales()[0]?.language - ?: Locale.getDefault().language - } - } else { - language - } +fun getInitialLanguages(language: String? = null, activeAccount: AccountEntity? = null): List { + val selected = listOfNotNull(language, activeAccount?.defaultPostLanguage) + val system = AppCompatDelegate.getApplicationLocales().toList() + + LocaleListCompat.getDefault().toList() + + return (selected + system.map { it.language }).distinct().filter { it.isNotEmpty() } } -fun getLocaleList(initialLanguage: String): List { - val locales = mutableListOf() - mergeLocaleListCompat(locales, AppCompatDelegate.getApplicationLocales()) // configured app languages first - mergeLocaleListCompat(locales, LocaleListCompat.getDefault()) // then configured system languages - locales.addAll( // finally, other languages +fun getLocaleList(initialLanguages: List): List { + val locales = Locale.getAvailableLocales().filter { // Only "base" languages, "en" but not "en_DK" - Locale.getAvailableLocales().filter { - it.country.isNullOrEmpty() && - it.script.isNullOrEmpty() && - it.variant.isNullOrEmpty() - }.sortedBy { it.displayName } - ) - ensureLanguageIsFirst(locales, initialLanguage) + it.country.isNullOrEmpty() && + it.script.isNullOrEmpty() && + it.variant.isNullOrEmpty() + }.sortedBy { it.displayName }.toMutableList() + ensureLanguagesAreFirst(locales, initialLanguages) return locales } diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt new file mode 100644 index 00000000..db287cca --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt @@ -0,0 +1,82 @@ +package com.keylesspalace.tusky.util + +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.db.AccountEntity +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.robolectric.annotation.Config + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class LocaleUtilsTest { + @Test + fun initialLanguagesContainReplySelectedAppAndSystem() { + val expectedLanguages = arrayOf("yi", "tok", "da", "fr", "sv", "kab") + val languages = getMockedInitialLanguages(expectedLanguages) + Assert.assertArrayEquals(expectedLanguages, languages.subList(0, expectedLanguages.size).toTypedArray()) + } + + @Test + fun whenReplyLanguageIsNull_DefaultLanguageIsFirst() { + val defaultLanguage = "tok" + val languages = getMockedInitialLanguages(arrayOf(null, defaultLanguage, "da", "fr", "sv", "kab")) + Assert.assertEquals(defaultLanguage, languages[0]) + } + + @Test + fun initialLanguagesAreDistinct() { + val defaultLanguage = "da" + val languages = getMockedInitialLanguages(arrayOf(defaultLanguage, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage)) + Assert.assertEquals(1, languages.count { it == defaultLanguage }) + } + + @Test + fun initialLanguageDeduplicationDoesNotReorder() { + val defaultLanguage = "da" + + Assert.assertEquals( + defaultLanguage, + getMockedInitialLanguages(arrayOf(defaultLanguage, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage))[0] + ) + Assert.assertEquals( + defaultLanguage, + getMockedInitialLanguages(arrayOf(null, defaultLanguage, "fr", defaultLanguage, "kab", defaultLanguage))[0] + ) + } + + @Test + fun emptyInitialLanguagesAreDropped() { + val languages = getMockedInitialLanguages(arrayOf("", "", "fr", "", "kab", "")) + Assert.assertFalse(languages.any { it.isEmpty() }) + } + + private fun getMockedInitialLanguages(configuredLanguages: Array): List { + val appLanguages = LocaleListCompat.forLanguageTags(configuredLanguages.slice(2 until 4).joinToString(",")) + val systemLanguages = LocaleListCompat.forLanguageTags(configuredLanguages.slice(4 until configuredLanguages.size).joinToString(",")) + + Mockito.mockStatic(AppCompatDelegate::class.java).use { appCompatDelegate -> + appCompatDelegate.`when` { AppCompatDelegate.getApplicationLocales() }.thenReturn(appLanguages) + + Mockito.mockStatic(LocaleListCompat::class.java).use { localeListCompat -> + localeListCompat.`when` { LocaleListCompat.getDefault() }.thenReturn(systemLanguages) + + return getInitialLanguages( + configuredLanguages[0], + AccountEntity( + id = 0, + domain = "foo.bar", + accessToken = "", + clientId = null, + clientSecret = null, + isActive = true, + defaultPostLanguage = configuredLanguages[1] ?: "", + ) + ) + } + } + } +} From 4ab305f3dc52af1105cce98b40dfca3d3cb57052 Mon Sep 17 00:00:00 2001 From: UlrichKu Date: Sat, 25 Feb 2023 21:18:03 +0100 Subject: [PATCH 087/418] 2528: Do not remove notifications on general resume (#3312) * 2528: Do not remove notifications on general resume * 2528: Have notification removal in the right onResume --- app/src/main/java/com/keylesspalace/tusky/MainActivity.kt | 5 ----- .../keylesspalace/tusky/fragment/NotificationsFragment.java | 4 ++++ .../tusky/receiver/SendStatusBroadcastReceiver.kt | 3 ++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 1e147325..7a19149f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -354,7 +354,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun onResume() { super.onResume() - NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") if (currentEmojiPack != selectedEmojiPack) { Log.d( @@ -726,10 +725,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - if (tab.position == notificationTabPosition) { - NotificationHelper.clearNotificationsForActiveAccount(this@MainActivity, accountManager) - } - binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity) refreshComposeButtonState(tabAdapter, tab.position) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 5023b85e..9c9d3c6d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -63,6 +63,7 @@ import com.keylesspalace.tusky.appstore.FavoriteEvent; import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; +import com.keylesspalace.tusky.components.notifications.NotificationHelper; import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; @@ -1210,6 +1211,9 @@ public class NotificationsFragment extends SFragment implements @Override public void onResume() { super.onResume(); + + NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager); + String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); if (!notificationFilter.equals(accountNotificationFilter)) { 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 0ac30b10..dbeff406 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -117,7 +117,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) builder.setOnlyAlertOnce(true) - notificationManager.notify(notificationId, builder.build()) + // There is a separate "I am sending" notification, so simply remove the handled one. + notificationManager.cancel(notificationId) } } } From fda8c80949626293db2b7e99f2d7f00d0bc583d0 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 25 Feb 2023 21:22:49 +0100 Subject: [PATCH 088/418] Use an explicit SCHEMA_VERSION instead of BuildConfig.VERSION_CODE (#3324) * Use an explicit SCHEMA_VERSION instead of BuildConfig.VERSION_CODE Every nightly release has a new BuildConfig.VERSION_CODE, so the previous code would not do the right thing. Require the schema version to be explicitly set. While I'm here, provide a clear set of guidelines as to what has to happen when the schema changes. * Improve documentation links --- .../keylesspalace/tusky/TuskyApplication.kt | 9 +++--- .../tusky/settings/SettingsConstants.kt | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 59978310..c755b217 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -23,6 +23,7 @@ import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.SCHEMA_VERSION import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode @@ -69,10 +70,10 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) // Migrate shared preference keys and defaults from version to version. The last - // version that did not have a SCHEMA_VERSION was 97, so that's the default. - val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 97) - if (oldVersion != BuildConfig.VERSION_CODE) { - upgradeSharedPreferences(oldVersion, BuildConfig.VERSION_CODE) + // version that did not have a SCHEMA_VERSION was 100, so that's the default. + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 100) + if (oldVersion != SCHEMA_VERSION) { + upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } // In this case, we want to have the emoji preferences merged with the other ones 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 61900321..4b4f5f18 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -12,6 +12,37 @@ enum class AppTheme(val value: String) { } } +/** + * Current preferences schema version. Format is 4-digit year + 2 digit month (zero padded) + 2 + * digit day (zero padded) + 2 digit counter (zero padded). + * + * If you make an incompatible change to the preferences schema you must: + * + * - Update this value + * - Update the code in + * [TuskyApplication.upgradeSharedPreferences][com.keylesspalace.tusky.TuskyApplication.upgradeSharedPreferences] + * to migrate from the old schema version to the new schema version. + * + * An incompatible change is: + * + * - Deleting a preference. The migration should delete the old preference. + * - Changing a preference's default value (e.g., from true to false, or from one enum value to + * another). The migration should check to see if the user had set an explicit value for + * that preference ([SharedPreferences.contains][android.content.SharedPreferences.contains]); + * if they hadn't then the migration should set the *old* default value as the preference's + * value, so the app behaviour does not unexpectedly change. + * - Changing a preference's type (e.g,. from a boolean to an enum). If you do this you may want + * to give the preference a different name, but you still need to migrate the user's previous + * preference value to the new preference. + * - Renaming a preference key. The migration should copy the user's previous value for the + * preference under the old key to the value for the new, and delete the old preference. + * + * A compatible change is: + * + * - Adding a new preference that does not change the interpretation of an existing preference + */ +const val SCHEMA_VERSION = 2023021501 + object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give // each preference a key for it to work. From 2e189a17dc11012a59e4cbd8210ac330c726f710 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Sat, 25 Feb 2023 21:27:26 +0100 Subject: [PATCH 089/418] When looking up fediverse urls, verify that account results returned match the input query. (#3341) Fixes #2804 --- .../com/keylesspalace/tusky/BottomSheetActivity.kt | 7 +++++-- .../keylesspalace/tusky/BottomSheetActivityTest.kt | 12 +++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 60d1966d..8df0c661 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -86,8 +86,11 @@ abstract class BottomSheetActivity : BaseActivity() { if (statuses.isNotEmpty()) { viewThread(statuses[0].id, statuses[0].url) return@subscribe - } else if (accounts.isNotEmpty()) { - viewAccount(accounts[0].id) + } + accounts.firstOrNull { it.url == url }?.let { account -> + // Some servers return (unrelated) accounts for url searches (#2804) + // Verify that the account's url matches the query + viewAccount(account.id) return@subscribe } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index bc4e617b..3438f614 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -46,6 +46,7 @@ class BottomSheetActivityTest { private lateinit var apiMock: MastodonApi private val accountQuery = "http://mastodon.foo.bar/@User" private val statusQuery = "http://mastodon.foo.bar/@User/345678" + private val nonexistentStatusQuery = "http://mastodon.foo.bar/@User/345678000" private val nonMastodonQuery = "http://medium.com/@correspondent/345678" private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList())) private val testScheduler = TestScheduler() @@ -55,7 +56,7 @@ class BottomSheetActivityTest { localUsername = "admin", username = "admin", displayName = "Ad Min", - url = "http://mastodon.foo.bar", + url = "http://mastodon.foo.bar/@User", avatar = "" ) private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList())) @@ -101,6 +102,7 @@ class BottomSheetActivityTest { apiMock = mock { on { searchObservable(eq(accountQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle on { searchObservable(eq(statusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn statusSingle + on { searchObservable(eq(nonexistentStatusQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn accountSingle on { searchObservable(eq(nonMastodonQuery), eq(null), anyBoolean(), eq(null), eq(null), eq(null)) } doReturn emptyCallback } @@ -184,6 +186,14 @@ class BottomSheetActivityTest { } } + @Test + fun search_doesNotRespectUnrelatedResult() { + activity.viewUrl(nonexistentStatusQuery) + testScheduler.advanceTimeBy(100, TimeUnit.MILLISECONDS) + assertEquals(nonexistentStatusQuery, activity.link) + assertEquals(null, activity.accountId) + } + @Test fun search_withCancellation_doesNotLoadUrl_forAccount() { activity.viewUrl(accountQuery) From d4eb744241a39e1f34ea990234f002450cebf0fe Mon Sep 17 00:00:00 2001 From: fruyek Date: Sat, 25 Feb 2023 21:27:54 +0100 Subject: [PATCH 090/418] Perform unicode text isolation on user names in post edit history view (#3342) --- .../tusky/components/viewthread/edits/ViewEditsAdapter.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 931f88b8..88396bce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.toViewData @@ -72,7 +73,7 @@ class ViewEditsAdapter( binding.statusEditInfo.text = context.getString( infoStringRes, - edit.account.name, + edit.account.name.unicodeWrap(), timestamp ).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis) From 92bd2153e9d52cbb9a08b44d8b90c82ed522770c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Feb 2023 21:28:09 +0100 Subject: [PATCH 091/418] Update dependency org.mockito:mockito-inline to v4.11.0 (#3365) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3039cb49..4e95fdd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ lifecycle = "2.5.1" material = "1.8.0" material-drawer = "8.4.5" material-typeface = "4.0.0.2-kotlin" -mockito-inline = "4.7.0" +mockito-inline = "4.11.0" mockito-kotlin = "4.1.0" networkresult-calladapter = "1.0.0" okhttp = "4.10.0" From 2da7bc5bc852dce581b7a7ed17f95563b841ad17 Mon Sep 17 00:00:00 2001 From: Goooler Date: Sun, 26 Feb 2023 04:30:52 +0800 Subject: [PATCH 092/418] Use TypedArray.use to obtain attrs (#3349) https://github.com/androidx/androidx/blob/17346638fffb173a69801ee4c9ea293588800214/core/core-ktx/src/main/java/androidx/core/content/res/TypedArray.kt#L227-L236 --- .../components/compose/ComposeActivity.kt | 7 +- .../keylesspalace/tusky/util/ThemeUtils.kt | 8 +-- .../com/keylesspalace/tusky/view/GraphView.kt | 71 +++++++++---------- .../keylesspalace/tusky/view/LicenseCard.kt | 16 +++-- 4 files changed, 53 insertions(+), 49 deletions(-) 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 7ff0733e..db606a31 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 @@ -51,6 +51,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.res.use import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone @@ -571,9 +572,9 @@ class ComposeActivity : private fun setupAvatar(activeAccount: AccountEntity) { val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) - val a = obtainStyledAttributes(null, actionBarSizeAttr) - val avatarSize = a.getDimensionPixelSize(0, 1) - a.recycle() + val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a -> + a.getDimensionPixelSize(0, 1) + } val animateAvatars = preferences.getBoolean("animateGifAvatars", false) loadAvatar( diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index 8c7c0b23..a03a5026 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -22,6 +22,7 @@ import android.graphics.PorterDuff import android.graphics.drawable.Drawable import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.res.use import com.google.android.material.color.MaterialColors /** @@ -37,10 +38,9 @@ private const val THEME_SYSTEM = "auto_system" const val APP_THEME_DEFAULT = THEME_NIGHT fun getDimension(context: Context, @AttrRes attribute: Int): Int { - val array = context.obtainStyledAttributes(intArrayOf(attribute)) - val dimen = array.getDimensionPixelSize(0, -1) - array.recycle() - return dimen + return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> + array.getDimensionPixelSize(0, -1) + } } fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt index 1d3c49d9..cc779432 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -26,6 +26,7 @@ import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.ContextCompat +import androidx.core.content.res.use import com.keylesspalace.tusky.R import kotlin.math.max @@ -95,49 +96,49 @@ class GraphView @JvmOverloads constructor( } private fun initFromXML(attr: AttributeSet?) { - val a = context.obtainStyledAttributes(attr, R.styleable.GraphView) - - primaryLineColor = ContextCompat.getColor( - context, - a.getResourceId( - R.styleable.GraphView_primaryLineColor, - R.color.tusky_blue, + context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a -> + primaryLineColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_primaryLineColor, + R.color.tusky_blue, + ) ) - ) - secondaryLineColor = ContextCompat.getColor( - context, - a.getResourceId( - R.styleable.GraphView_secondaryLineColor, - R.color.tusky_red, + secondaryLineColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_secondaryLineColor, + R.color.tusky_red, + ) ) - ) - lineWidth = a.getDimensionPixelSize( - R.styleable.GraphView_lineWidth, - R.dimen.graph_line_thickness - ).toFloat() + lineWidth = a.getDimensionPixelSize( + R.styleable.GraphView_lineWidth, + R.dimen.graph_line_thickness + ).toFloat() - graphColor = ContextCompat.getColor( - context, - a.getResourceId( - R.styleable.GraphView_graphColor, - R.color.colorBackground, + graphColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_graphColor, + R.color.colorBackground, + ) ) - ) - metaColor = ContextCompat.getColor( - context, - a.getResourceId( - R.styleable.GraphView_metaColor, - R.color.dividerColor, + metaColor = ContextCompat.getColor( + context, + a.getResourceId( + R.styleable.GraphView_metaColor, + R.color.dividerColor, + ) ) - ) - proportionalTrending = a.getBoolean( - R.styleable.GraphView_proportionalTrending, - proportionalTrending, - ) + proportionalTrending = a.getBoolean( + R.styleable.GraphView_proportionalTrending, + proportionalTrending, + ) + } primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryLineColor @@ -170,8 +171,6 @@ class GraphView @JvmOverloads constructor( strokeWidth = 0f style = Paint.Style.STROKE } - - a.recycle() } private fun initializeVertices() { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 44febf40..6d553a26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -19,6 +19,7 @@ import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.view.LayoutInflater +import androidx.core.content.res.use import com.google.android.material.card.MaterialCardView import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R @@ -38,12 +39,15 @@ class LicenseCard setCardBackgroundColor(MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)) - val a = context.theme.obtainStyledAttributes(attrs, R.styleable.LicenseCard, 0, 0) - - val name: String? = a.getString(R.styleable.LicenseCard_name) - val license: String? = a.getString(R.styleable.LicenseCard_license) - val link: String? = a.getString(R.styleable.LicenseCard_link) - a.recycle() + val (name, license, link) = context.theme.obtainStyledAttributes( + attrs, R.styleable.LicenseCard, 0, 0 + ).use { a -> + Triple( + a.getString(R.styleable.LicenseCard_name), + a.getString(R.styleable.LicenseCard_license), + a.getString(R.styleable.LicenseCard_link), + ) + } binding.licenseCardName.text = name binding.licenseCardLicense.text = license From 000681c7023a742ad4e3612624541dc016efda56 Mon Sep 17 00:00:00 2001 From: Goooler Date: Sun, 26 Feb 2023 04:40:13 +0800 Subject: [PATCH 093/418] Add extra proguard rules for OkHttp (#3350) * Add extra proguard rules for OkHttp https://github.com/square/okhttp/blob/339732e3a1b78be5d792860109047f68a011b5eb/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro#L11-L14 * Update proguard-rules.pro --- app/proguard-rules.pro | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9ab3dd83..0769626a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -78,6 +78,13 @@ -keepattributes Signature -keep class kotlin.coroutines.Continuation +# We can remove these rules after updating to OkHttp 4.10.1 +# https://github.com/square/okhttp/blob/339732e3a1b78be5d792860109047f68a011b5eb/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro#L11-L14 +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** + # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile From b3f173b2b0bd7f459660ffcf2838e72537d8835a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 08:54:07 +0100 Subject: [PATCH 094/418] fix(deps): update dependency org.mockito:mockito-inline to v5 (#3373) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e95fdd3..89cd95bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ lifecycle = "2.5.1" material = "1.8.0" material-drawer = "8.4.5" material-typeface = "4.0.0.2-kotlin" -mockito-inline = "4.11.0" +mockito-inline = "5.1.1" mockito-kotlin = "4.1.0" networkresult-calladapter = "1.0.0" okhttp = "4.10.0" From 9340e7a6f4401789602cca1997e6da3e1044728e Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 27 Feb 2023 08:54:26 +0100 Subject: [PATCH 095/418] update Glide to 4.15.0 (#3384) --- .../keylesspalace/tusky/adapter/NotificationsAdapter.java | 2 +- .../keylesspalace/tusky/adapter/StatusBaseViewHolder.java | 5 ++--- gradle/libs.versions.toml | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) 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 8f45c0af..87096232 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -599,7 +599,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter Date: Mon, 27 Feb 2023 08:54:51 +0100 Subject: [PATCH 096/418] Ignore clicks outside the start/end of a line (#3380) * Ignore clicks outside the start/end of a line `LinkMovementMethod` has a bug in its calculation of the clickable width of a span on a line. If the span is the last thing on the line the clickable area extends to the end of the view. So the user can tap what appears to be whitespace and open a link. Previous code tried to fix this by adding a zero-width space after the link so that `LinkMovementMethod` wouldn't consider it empty. However the ZWS was selected by copy/paste operations, resulting in junk results if users tried to copy the link. Fix this by subclassing `LinkMovementMethod` and fixing the click detection code to ignore clicks that are outside the bounds of the line that was clicked on. Remove the code that adds the ZWS. Fixes https://github.com/tuskyapp/Tusky/issues/1567 * Assume arguments are all non-null * Use `object` for singleton * getInstance as a one-liner --- .../keylesspalace/tusky/util/LinkHelper.kt | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) 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 ff225951..7e23e45d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -11,7 +11,8 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ @file:JvmName("LinkHelper") package com.keylesspalace.tusky.util @@ -21,12 +22,15 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.URLSpan import android.util.Log +import android.view.MotionEvent +import android.view.MotionEvent.ACTION_UP import android.view.View import android.widget.TextView import androidx.annotation.VisibleForTesting @@ -68,7 +72,7 @@ fun setClickableText(view: TextView, content: CharSequence, mentions: List= length || subSequence(end, end + 1).toString() == "\n") { - insert(end, "\u200B") - } } @VisibleForTesting @@ -199,12 +196,10 @@ fun setClickableMentions(view: TextView, mentions: List?, listener: Lin append("@") append(mention.localUsername) setSpan(customSpan, start, end, flags) - append("\u200B") // same reasoning as in setClickableText - end += 1 // shift position to take the previous character into account start = end } } - view.movementMethod = LinkMovementMethod.getInstance() + view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() } fun createClickableText(text: String, link: String): CharSequence { @@ -322,3 +317,35 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { } private const val TAG = "LinkHelper" + +/** + * [LinkMovementMethod] that doesn't add a leading/trailing clickable area. + * + * [LinkMovementMethod] has a bug in its calculation of the clickable width of a span on a line. If + * the span is the last thing on the line the clickable area extends to the end of the view. So the + * user can tap what appears to be whitespace and open a link. + * + * Fix this by overriding ACTION_UP touch events and calculating the true start and end of the + * content on the line that was tapped. Then ignore clicks that are outside this area. + * + * See https://github.com/tuskyapp/Tusky/issues/1567. + */ +object NoTrailingSpaceLinkMovementMethod : LinkMovementMethod() { + override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { + val action = event.action + if (action != ACTION_UP) return super.onTouchEvent(widget, buffer, event) + + val x = event.x.toInt() + val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY + val line = widget.layout.getLineForVertical(y) + val lineLeft = widget.layout.getLineLeft(line) + val lineRight = widget.layout.getLineRight(line) + if (x > lineRight || x >= 0 && x < lineLeft) { + return true + } + + return super.onTouchEvent(widget, buffer, event) + } + + fun getInstance() = NoTrailingSpaceLinkMovementMethod +} From efb2c031a32be1492307b88e0a58e3285b0b665e Mon Sep 17 00:00:00 2001 From: Danial Behzadi Date: Tue, 21 Feb 2023 20:35:54 +0000 Subject: [PATCH 097/418] Translated using Weblate (Persian) Currently translated at 100.0% (20 of 20 strings) Translation: Tusky/Tusky description Translate-URL: https://weblate.tusky.app/projects/tusky/tusky-app/fa/ --- fastlane/metadata/android/fa/changelogs/89.txt | 7 +++++++ fastlane/metadata/android/fa/changelogs/94.txt | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 fastlane/metadata/android/fa/changelogs/89.txt create mode 100644 fastlane/metadata/android/fa/changelogs/94.txt diff --git a/fastlane/metadata/android/fa/changelogs/89.txt b/fastlane/metadata/android/fa/changelogs/89.txt new file mode 100644 index 00000000..99c57479 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/89.txt @@ -0,0 +1,7 @@ +تاسکی ن۱۷٫۰ + +- "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/fa/changelogs/94.txt b/fastlane/metadata/android/fa/changelogs/94.txt new file mode 100644 index 00000000..5a969737 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/94.txt @@ -0,0 +1,9 @@ +تاسکی ۱۹٫۰ + +- 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 From 6b9db02c08ccf0d0811ad749a2893b0c07e70a21 Mon Sep 17 00:00:00 2001 From: UlrichKu Date: Mon, 27 Feb 2023 11:33:11 +0100 Subject: [PATCH 098/418] 3311 load more more prominent (#3376) * 3311: Add a zig-zag line to top and bottom of "load more" * 3311: Remove unneeded extensions * 3311: Use a simple gradient instead of zigzag line * 3311: Use a simple gradient drawable (remove custom view) * 3311: Remove gradient lines --- app/src/main/res/layout/item_status_placeholder.xml | 3 ++- app/src/main/res/values-night/theme_colors.xml | 3 ++- app/src/main/res/values/theme_colors.xml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/item_status_placeholder.xml b/app/src/main/res/layout/item_status_placeholder.xml index daa45fd2..660c99ea 100644 --- a/app/src/main/res/layout/item_status_placeholder.xml +++ b/app/src/main/res/layout/item_status_placeholder.xml @@ -31,6 +31,7 @@ The attributes are set to get a specific behaviour: xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="72dp" + android:background="@color/dividerColorOther" android:clickable="true" android:focusable="true"> @@ -47,4 +48,4 @@ The attributes are set to get a specific behaviour: android:text="@string/load_more_placeholder_text" android:textStyle="bold" android:textSize="?attr/status_text_large" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml index 21e56190..348652a7 100644 --- a/app/src/main/res/values-night/theme_colors.xml +++ b/app/src/main/res/values-night/theme_colors.xml @@ -17,6 +17,7 @@ @color/tusky_grey_30 @color/tusky_grey_50 @color/tusky_grey_25 + @color/tusky_grey_10 @color/tusky_orange @@ -27,4 +28,4 @@ @color/white @color/tusky_grey_10 - \ No newline at end of file + diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml index 657363d8..2f5da6b8 100644 --- a/app/src/main/res/values/theme_colors.xml +++ b/app/src/main/res/values/theme_colors.xml @@ -17,6 +17,7 @@ @color/tusky_grey_70 @color/tusky_grey_50 @color/tusky_grey_80 + @color/tusky_grey_90 @color/tusky_orange_light @@ -27,4 +28,4 @@ @color/tusky_grey_20 @color/white - \ No newline at end of file + From 03640c8ce7040cd6f811ff8b8a9e03cbc5a581fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quent=C3=AD?= Date: Mon, 27 Feb 2023 07:46:52 +0000 Subject: [PATCH 099/418] Translated using Weblate (Occitan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Quentí Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/oc/ Translation: Tusky/Tusky --- app/src/main/res/values-oc/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 234ce0ce..18f849fd 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -625,4 +625,10 @@ Connexion via Navigador Poiriá prendre en carga de metòdes d’autentificacions suplementaris, mas requerís un navigador compatible. Fonciona los tres quarts del temps. Cap de donadas pèrdon pas per las autras aplicacions. + Seguir lo hashtag + #hashtag + %1$d personas parlan d’aqueste hashtag %2$s + Utilizacion totala + Total de comptes + Hashtags populars \ No newline at end of file From 81f347458364d5527fa48a98429b5ef0e1a6212d Mon Sep 17 00:00:00 2001 From: Garutmaan Garuda Date: Mon, 27 Feb 2023 07:46:52 +0000 Subject: [PATCH 100/418] Translated using Weblate (Sanskrit) Currently translated at 88.8% (503 of 566 strings) Co-authored-by: Garutmaan Garuda Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/sa/ Translation: Tusky/Tusky --- app/src/main/res/values-sa/strings.xml | 57 ++++++++++++++++++-------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 2eb19a21..20e7c9dc 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -1,7 +1,7 @@ स्थानीयाः - सूचनाः + ज्ञापनसूचनाः गृहम् दौत्यप्रेषणं विफलं जातम् । उपारोपणं विफलं जातम् । @@ -63,7 +63,7 @@ प्रकाशनानि छाद्यन्ताम् अवरोधो नश्यताम् अवरुध्यताम् - अनुसरणं नश्यताम् + अनुसरणं अपाकुरुताम् अनुस्रियताम् नूनमेव बहिर्गन्तुमीहते %1$s इति व्यक्तित्वलेखात् \? बहिर्गम्यताम् @@ -159,25 +159,25 @@ सफलं प्रत्युत्तरप्रेषणम् । प्रेषितम्! %s अनावृतः - सूच्यतां मे यदा - ज्योत्या सूच्यताम् - कम्पनेन सूच्यताम् - ध्वनिना सूच्यताम् - सतर्कताः - सूचनाः - सूचनाः + अहं ज्ञप्यतां यदा + ज्योत्या ज्ञप्यताम् + कम्पनेन ज्ञप्यताम् + ध्वनिना ज्ञप्यताम् + सतर्कत-ज्ञापनसूचनाः + ज्ञापनसूचनाः + ज्ञापनसूचनाः प्रत्यक्षम् - केवलमुल्लिखितयोक्तृभ्यः प्रकट्यताम् केवलमनुसर्तृृणाम् :- कृते प्रकट्यताम् अनिर्दिष्टः = सार्वजनिकसमयतालिकायां मा प्रकट्यताम् सार्वजनिकः‍‍‍‍= प्रकट्यतां सार्वजनिकसमयतालिकासु - सूचनाः छाद्यन्ताम् + ज्ञापनसूचनाः छाद्यन्ताम् निःशब्दं क्रियताम् @%s\? किल अवरुध्यताम् @%s\? प्रदेशः छाद्यताम् निश्चियेन सर्वमेव निषिद्धं भवेदेतस्य जनस्य %s \? कोऽपि विषयो न द्रष्टुं शक्यते तत्प्रदेशात् कस्यामपि समयतालिकायामुत वा ते सूचनापेटिकायाम् । भवदनुसर्तारः तस्मात्प्रदेशान्निष्क्रियन्ते । विनश्य पुनः लिख्यताम् \? दौत्यमेतन्नश्यताम्\? - अनुसरणं नश्यताम् \? + व्यक्तित्वविवरणलेखायाः अनुसरणम् अपाकरणीयं किम् \? अवारोप्यताम् उपारोप्यमाणम्… सामग्रीणामुपारोपणसिद्धिः वर्तमाना @@ -242,7 +242,8 @@ विज्ञप्तिः कपाटितव्यक्तिविवरणलेखः - %d नवपरस्परक्रियाः + %d नवपरस्परक्रिया + %d नवपरस्परक्रिये %1$s च %2$s च %1$s, %2$s, तथैव %3$s @@ -250,14 +251,14 @@ %s मित्रेण भवन्नामोल्लिखितम् मतदाने समाप्ते सति सूचनाः मतदानानि - प्रीतिः इत्यङ्किते सति सूचनाः + प्रीतिः इत्यङ्किते सति ज्ञापनसूचनाः प्रियाः - दौत्यप्रकाशने सति सूचनाः + दौत्यप्रकाशने सति ज्ञापनसूचनाः प्रकाशनानि - अनुसरणानुरोधान्नधिकृत्य सूचनाः + अनुसरणानुरोधान्नधिकृत्य ज्ञापनसूचनाः अनुसरणार्थमनुरोधाः - नवोल्लेखान्नधिकृत्य सूचनाः - नवानुसर्तृृन्नधिकृत्य सूचनाः + नवोल्लेखान्नधिकृत्य ज्ञापनसूचनाः + नवानुसर्तृृन्नधिकृत्य ज्ञापनसूचनाः नवानुसर्तारः नवोल्लेखाः स्थूलतमः @@ -546,5 +547,25 @@ चित्रं सम्पादयितुं न शक्यते। व्यक्तित्वलेखस्य विवरणानि दर्शनं विफलं जातम् सम्प्रवेशपुटं दर्शयितुं न शक्यते। - पुनःसम्प्रवेशाय विज्ञापन-सूचनाः + पुनःसम्प्रवेशाय विज्ञापन-ज्ञापनसूचनाः + #निश्रेणिचिह्नशीर्षकः + व्यक्तित्वविवरणलेखा अनुसरतु + कालानुक्रमपङ्क्त्याः सूचनाः परिमिताः कुरुताम् + परिमितावेदनानि प्रति ज्ञापनसूचनाः + भवतः अरक्षितानि परिवर्तनानि सन्ति। + नूतनम् आवेदनमस्ति + सुस्थितिः + इदं कालबद्धदौत्यं विनश्येत् किम् \? + प्रत्युत्तरसमाचारस्य आरोपनं विफलं जातम् + आल्ट् + स्वीयानुकूलानि भावचिह्नानि सञ्जीव्यताम् + %s-विषये नूतनम् आवेदनम् + लेखायाः उपभोक्तृनाम्नः संविभागं कुरुताम् + लेखायै शृङ्खलायाः संविभागं कुरुताम् + #%s-चिह्नस्य अनुसरणम् अपाकरणीयं किम् \? + अनुसृताः निश्रेणिचिह्नशीर्षकाः + जनप्रियाः निश्रेणिचिह्नशीर्षकाः + लेखायाः उपभोक्तृनाम्नः संविभागं कुरुताम् अस्मै… + #%s अनुसरणम् अपाकृतम् + लेखायाः निरपेक्ष-सार्वत्रिक-वस्तुसङ्केतस्य संविभागं कुरुताम् अस्मै… \ No newline at end of file From 46d00a31085dca5b21359faf2f27f0ce754780b4 Mon Sep 17 00:00:00 2001 From: XoseM Date: Mon, 27 Feb 2023 07:46:52 +0000 Subject: [PATCH 101/418] Translated using Weblate (Galician) Currently translated at 100.0% (566 of 566 strings) Co-authored-by: XoseM Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/gl/ Translation: Tusky/Tusky --- app/src/main/res/values-gl/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index c9558e33..6d4bc193 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -620,4 +620,6 @@ Cancelos en voga Uso total Total de contas + Seguir cancelo + #cancelo \ No newline at end of file From 7751b88a575842c8bc1b532087c7821faf5129aa Mon Sep 17 00:00:00 2001 From: Ricard Torres Date: Mon, 27 Feb 2023 07:46:52 +0000 Subject: [PATCH 102/418] Translated using Weblate (Catalan) Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Ricard Torres Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/ca/ Translation: Tusky/Tusky --- app/src/main/res/values-ca/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a9ba8756..6ac60d70 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -629,4 +629,6 @@ %1$d persones parlen del hashtag %2$s Ús total Total de comptes + Segueix hashtag + #hashtag \ No newline at end of file From b4d10c9613e373d3488da862e85406f52787ce15 Mon Sep 17 00:00:00 2001 From: UlrichKu Date: Mon, 27 Feb 2023 14:07:28 +0100 Subject: [PATCH 103/418] 3204: Add an account based preference store (#3205) * 3204: Add an account based preference store * 3204: (related) reformat a bit, add todo * 3204: Use the preference data store for all three account settings * 3204: Move event handling to account settings handler * 3204: Correct includes * 3204: Appease linter * 3204: Appease linter again * 3204: Add an account based preference store * 3204: Use the preference data store for all three account settings * 3204: Move event handling to account settings handler * 3204: Correct includes * 3204: Add general "preference upgrade loop stepper"; use it for removing obsolete account settings (in shared) * 3204: Add missing spaces * 3204: Key is non-nullable * 3204: Upgrade to new settings migration code * 3204: Remove (commented) DI code --- .../keylesspalace/tusky/TuskyApplication.kt | 13 +++-- .../preference/AccountPreferencesFragment.kt | 56 ++++++------------- .../settings/AccountPreferenceHandler.kt | 35 ++++++++++++ .../tusky/settings/SettingsConstants.kt | 2 +- 4 files changed, 61 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index c755b217..ef5b8cab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -69,9 +69,8 @@ class TuskyApplication : Application(), HasAndroidInjector { AppInjector.init(this) - // Migrate shared preference keys and defaults from version to version. The last - // version that did not have a SCHEMA_VERSION was 100, so that's the default. - val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 100) + // Migrate shared preference keys and defaults from version to version. + val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0) if (oldVersion != SCHEMA_VERSION) { upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } @@ -105,7 +104,13 @@ class TuskyApplication : Application(), HasAndroidInjector { Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion") val editor = sharedPreferences.edit() - // Future upgrade code goes here + if (oldVersion < 2023022701) { + // These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity. + + editor.remove(PrefKeys.ALWAYS_OPEN_SPOILER) + editor.remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA) + editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) + } editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() 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 0cbdacba..a863db48 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 @@ -36,13 +36,13 @@ import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration -import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.AccountPreferenceHandler import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen @@ -85,7 +85,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { colorInt = MaterialColors.getColor(context, R.attr.iconColor, Color.BLACK) } setOnPreferenceClickListener { - openNotificationPrefs() + openNotificationSystemPrefs() true } } @@ -184,8 +184,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setEntryValues(R.array.post_privacy_values) key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } - val visibility = accountManager.activeAccount?.defaultPostPrivacy - ?: Status.Visibility.PUBLIC + val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC value = visibility.serverString() setIcon(getIconForVisibility(visibility)) setOnPreferenceChangeListener { _, newValue -> @@ -224,8 +223,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setIcon(R.drawable.ic_eye_24dp) key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY isSingleLineTitle = false - val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity - ?: false + val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity ?: false setDefaultValue(sensitivity) setIcon(getIconForSensitivity(sensitivity)) setOnPreferenceChangeListener { _, newValue -> @@ -238,40 +236,29 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } preferenceCategory(R.string.pref_title_timelines) { + // TODO having no activeAccount in this fragment does not really make sense, enforce it? + // All other locations here make it optional, however. + val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, eventHub) + switchPreference { key = PrefKeys.MEDIA_PREVIEW_ENABLED setTitle(R.string.pref_title_show_media_preview) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.mediaPreviewEnabled ?: true - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.mediaPreviewEnabled = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } + preferenceDataStore = accountPreferenceHandler } switchPreference { key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA setTitle(R.string.pref_title_alway_show_sensitive_media) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.alwaysShowSensitiveMedia ?: false - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.alwaysShowSensitiveMedia = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } + preferenceDataStore = accountPreferenceHandler } switchPreference { key = PrefKeys.ALWAYS_OPEN_SPOILER setTitle(R.string.pref_title_alway_open_spoiler) isSingleLineTitle = false - isChecked = accountManager.activeAccount?.alwaysOpenSpoiler ?: false - setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.alwaysOpenSpoiler = newValue as Boolean } - eventHub.dispatch(PreferenceChangedEvent(key)) - true - } + preferenceDataStore = accountPreferenceHandler } } @@ -279,10 +266,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.pref_title_public_filter_keywords) setOnPreferenceClickListener { - launchFilterActivity( - Filter.PUBLIC, - R.string.pref_title_public_filter_keywords - ) + launchFilterActivity(Filter.PUBLIC, R.string.pref_title_public_filter_keywords) true } } @@ -306,10 +290,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preference { setTitle(R.string.pref_title_thread_filter_keywords) setOnPreferenceClickListener { - launchFilterActivity( - Filter.THREAD, - R.string.pref_title_thread_filter_keywords - ) + launchFilterActivity(Filter.THREAD, R.string.pref_title_thread_filter_keywords) true } } @@ -330,7 +311,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { requireActivity().setTitle(R.string.action_view_account_preferences) } - private fun openNotificationPrefs() { + private fun openNotificationSystemPrefs() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent() intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" @@ -345,14 +326,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) - } - } - private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) { + // TODO these could also be "datastore backed" preferences (a ServerPreferenceDataStore); follow-up of issue #3204 + mastodonApi.accountUpdateSource(visibility, sensitive, language) .enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt new file mode 100644 index 00000000..3d6b0c15 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceHandler.kt @@ -0,0 +1,35 @@ +package com.keylesspalace.tusky.settings + +import androidx.preference.PreferenceDataStore +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager + +class AccountPreferenceHandler( + private val account: AccountEntity, + private val accountManager: AccountManager, + private val eventHub: EventHub, +) : PreferenceDataStore() { + + override fun getBoolean(key: String, defValue: Boolean): Boolean { + return when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia + PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler + PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled + else -> defValue + } + } + + override fun putBoolean(key: String, value: Boolean) { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value + PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value + PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value + } + + accountManager.saveAccount(account) + + eventHub.dispatch(PreferenceChangedEvent(key)) + } +} 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 4b4f5f18..8b8d5c3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -41,7 +41,7 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023021501 +const val SCHEMA_VERSION = 2023022701 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give From ddc4d76c41abfdc53609c5f7f8f4f8a9089a5b43 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 28 Feb 2023 21:28:05 +0100 Subject: [PATCH 104/418] Lazily load activities in Robolectric tests (#3397) Not all Robolectric tests interact with activities, so lazy loading the activity can speed them up. I benchmarked the impact with gradle-profiler. Each test run performed 6 warmup runs, and then 10 benchmarked runs with and without this change. Without this change (all values rounded to nearest second): Mean: 151 Median: 149 P75: 157 StdDev: 10 With this change: Mean: 127 Median: 125 P75: 130 StdDev: 7 So a ~ 18% reduction in median and P75 times, --- app/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle b/app/build.gradle index 3ca78704..d1259f72 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -72,6 +72,7 @@ android { } unitTests.all { systemProperty 'robolectric.logging.enabled', 'true' + systemProperty 'robolectric.lazyload', 'ON' } } sourceSets { From e46e2720a492ec71392da1d31c16de7deff5d023 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Feb 2023 21:35:31 +0100 Subject: [PATCH 105/418] Update dependency androidx.arch.core:core-testing to v2.2.0 (#3378) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f460055..ccadf1af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ androidx-recyclerview = "1.2.1" androidx-sharetarget = "1.2.0" androidx-splashscreen = "1.0.0" androidx-swiperefresh-layout = "1.1.0" -androidx-testing = "2.1.0" +androidx-testing = "2.2.0" androidx-viewpager2 = "1.0.0" androidx-work = "2.8.0" androidx-room = "2.5.0" From e98925b42687fdf0a6eaf098231babdbcc802d48 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:05:32 +0100 Subject: [PATCH 106/418] chore(deps): update dependency com.android.application to v7.4.2 (#3395) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccadf1af..214f062c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "7.4.1" +agp = "7.4.2" androidx-activity = "1.6.1" androidx-appcompat = "1.6.1" androidx-browser = "1.5.0" From 29fc449f04cdf31010c842b05bc782c7ab760bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C4=81rti=C5=86=C5=A1=20Bru=C5=86enieks?= Date: Tue, 28 Feb 2023 21:35:54 +0000 Subject: [PATCH 107/418] Translated using Weblate (Latvian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 95.0% (538 of 566 strings) Co-authored-by: Mārtiņš Bruņenieks Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/lv/ Translation: Tusky/Tusky --- app/src/main/res/values-lv/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 3d2bc52d..eda9b701 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -586,4 +586,6 @@ %1$d cilvēki runā par tēmturi %2$s Kopējais lietojums Konti kopā + Sekot tēmturim + #tēmturis \ No newline at end of file From 1b6108ca94dd4e2da5e1c7c60518a42b38e9f205 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 1 Mar 2023 19:58:18 +0100 Subject: [PATCH 108/418] Add "Refresh" accessibility menu (#3121) * Add "Refresh" accessibility menu to TimelineFragment Per https://developer.android.com/reference/androidx/swiperefreshlayout/widget/SwipeRefreshLayout the layout does not provide accessibility events, and a menu item should be provided as an alternative method for refreshing the content. In `TimelineFragment`: - Implement the `MenuProvider` interface so it can populate the action bar menu in activities that host the fragment - Create a "Refresh" menu item, and refresh the state when it is selected `MainActivity` has to change how the menu is created, so that fragments can add items to it. In `MainActivity`: - Call `setSupportActionBar` so `mainToolbar` participates in menus - Implement the `MenuProvider` interface, and move menu creation there - Set the title via supportActionBar * Never show the refresh item as a menubar action Per guidelines in https://developer.android.com/develop/ui/views/touch-and-input/swipe/add-swipe-interface#AddRefreshAction * Add "Refresh" menu item for AccountMediaFragment Also, fix the colour of the refresh progress indicator * Implement "Refresh" for AnnouncementsActivity * Add "Refresh" menu for ConversationsFragment * Keep the tabs adapter over the life of the viewpager Make `tabs` `var` instead of `val` in `MainPagerAdapter` so it can be updated when tabs change. Then detach the `tabLayoutMediator`, update the tabs, and call `notifyItemRangeChanged` in `setupTabs()`. This fixes a bug (not sure if it's this code, or in ViewPager2) where assigning a new adapter to the view pager seemed to result in a leak of one or more fragments. This wasn't user-visible, but it's a leak, and it becomes user-visible when fragments want to display menus. This also fixes two other bugs: 1. Be on the left-most tab. Scroll down a bit. Then modify the tabs at "Account preferences > tabs", but keep the left-most tab as-is. Then go back to MainActivity. Your reading position in the left-most tab has been jumped to the top. 2. Be on any non-left-most tab. Then modify the tab list by reordering tabs (adding/removing tabs is also OK). Then go back to MainActivity. Your tab selection has been overridden, and the left-most tab has been selected. Because the fragments are not destroyed unnecessarily your reading position is retained. And it remembers the tab you had selected, and as long as that tab is still present you will be returned to it, even if it's changed position in the list. Fixes https://github.com/tuskyapp/Tusky/issues/3251 * Add "Refresh" menu for ScheduledStatusActivity * Lint * Add "Refresh" menu for SearchFragment / SearchActivity * Explicitly set the searchview width Using "collapseActionView" requires the user to press "Back" twice to exit the activity, which is not acceptable. * Move toolbar handling in to ViewThreadActivity Previous code had the toolbar in the fragment's layout. Refactor to make consistent with other activities, and move the toolbar in to the activity layout. Implement MenuProvider in ViewThreadFragment to adjust the menu in the activity. * Add "Refresh" menu to ViewThreadFragment * Implement "Refresh" for ViewEditsFragment * Lint * Add "Refresh" menu to ReportStatusesFragment * Add "Refresh" menu to NotificationsFragment * Rename menu resource files Be consistent with the layout resource files, which have an activity/fragment prefix, then the lower_snake_case name of the activity or fragment it's for. * Only enable refresh menu if swiptorefresh is enabled Some timelines don't have swipetorefresh enabled (e.g., those shown on AccountActivity). In those cases don't add the refresh menu, rely on the hosting activity to provide it. Update AccountActivity to provide the refresh menu item. --- .../com/keylesspalace/tusky/MainActivity.kt | 40 ++++--- .../components/account/AccountActivity.kt | 37 ++++-- .../account/media/AccountMediaFragment.kt | 42 ++++++- .../announcements/AnnouncementsActivity.kt | 38 ++++++- .../conversation/ConversationsFragment.kt | 47 +++++++- .../fragments/ReportStatusesFragment.kt | 50 ++++++++- .../scheduled/ScheduledStatusActivity.kt | 41 ++++++- .../tusky/components/search/SearchActivity.kt | 50 +++++++-- .../search/fragments/SearchFragment.kt | 35 +++++- .../components/timeline/TimelineFragment.kt | 43 ++++++- .../viewthread/ViewThreadActivity.kt | 12 +- .../viewthread/ViewThreadFragment.kt | 105 ++++++++++++------ .../viewthread/edits/ViewEditsFragment.kt | 59 +++++++++- .../viewthread/edits/ViewEditsViewModel.kt | 43 ++++--- .../tusky/fragment/NotificationsFragment.java | 26 ++++- .../layout-sw640dp/fragment_view_thread.xml | 16 --- .../main/res/layout/activity_view_thread.xml | 19 +++- .../main/res/layout/fragment_view_thread.xml | 16 --- app/src/main/res/menu/account_toolbar.xml | 5 + .../main/res/menu/activity_announcements.xml | 8 ++ app/src/main/res/menu/activity_main.xml | 8 ++ .../res/menu/activity_scheduled_status.xml | 8 ++ .../main/res/menu/fragment_account_media.xml | 8 ++ .../main/res/menu/fragment_conversations.xml | 8 ++ .../main/res/menu/fragment_notifications.xml | 8 ++ .../res/menu/fragment_report_statuses.xml | 8 ++ app/src/main/res/menu/fragment_search.xml | 8 ++ app/src/main/res/menu/fragment_timeline.xml | 8 ++ app/src/main/res/menu/fragment_view_edits.xml | 8 ++ ...d_toolbar.xml => fragment_view_thread.xml} | 7 +- app/src/main/res/menu/search_toolbar.xml | 3 +- app/src/main/res/values/strings.xml | 1 + 32 files changed, 671 insertions(+), 144 deletions(-) create mode 100644 app/src/main/res/menu/activity_announcements.xml create mode 100644 app/src/main/res/menu/activity_main.xml create mode 100644 app/src/main/res/menu/activity_scheduled_status.xml create mode 100644 app/src/main/res/menu/fragment_account_media.xml create mode 100644 app/src/main/res/menu/fragment_conversations.xml create mode 100644 app/src/main/res/menu/fragment_notifications.xml create mode 100644 app/src/main/res/menu/fragment_report_statuses.xml create mode 100644 app/src/main/res/menu/fragment_search.xml create mode 100644 app/src/main/res/menu/fragment_timeline.xml create mode 100644 app/src/main/res/menu/fragment_view_edits.xml rename app/src/main/res/menu/{view_thread_toolbar.xml => fragment_view_thread.xml} (77%) diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 7a19149f..6b8978de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -29,6 +29,8 @@ import android.net.Uri import android.os.Bundle import android.util.Log import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.ImageView @@ -40,6 +42,7 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat +import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager @@ -135,7 +138,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.launch import javax.inject.Inject -class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector { +class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -244,6 +247,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) + setSupportActionBar(binding.mainToolbar) glide = Glide.with(this) @@ -257,17 +261,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje loadDrawerAvatar(activeAccount.profilePictureUrl, true) - binding.mainToolbar.menu.add(R.string.action_search).apply { - setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) - icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { - sizeDp = 20 - colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary) - } - setOnMenuItemClickListener { - startActivity(SearchActivity.getIntent(this@MainActivity)) - true - } - } + addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() @@ -352,6 +346,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje draftsAlert.observeInContext(this, true) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_main, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.mainToolbar, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_search -> { + startActivity(SearchActivity.getIntent(this@MainActivity)) + true + } + else -> super.onOptionsItemSelected(item) + } + } + override fun onResume() { super.onResume() val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") @@ -745,7 +759,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 - binding.mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity) + supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity) binding.mainToolbar.setOnClickListener { (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() } 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 ba261c1d..1b080020 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 @@ -26,6 +26,7 @@ import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.text.Editable import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -35,6 +36,7 @@ import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -88,6 +90,10 @@ import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog +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 dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import java.text.NumberFormat @@ -97,7 +103,7 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.abs -class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, LinkListener { +class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener { @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector @@ -153,6 +159,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI loadResources() makeNotificationBarTransparent() setContentView(binding.root) + addMenuProvider(this) // Obtain information to fill out the profile. viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) @@ -414,14 +421,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI draftsAlert.observeInContext(this, true) } + private fun onRefresh() { + viewModel.refresh() + adapter.refreshContent() + } + /** * Setup swipe to refresh layout */ private fun setupRefreshLayout() { - binding.swipeToRefreshLayout.setOnRefreshListener { - viewModel.refresh() - adapter.refreshContent() - } + binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } viewModel.isRefreshing.observe( this ) { isRefreshing -> @@ -731,7 +740,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.account_toolbar, menu) val openAsItem = menu.findItem(R.id.action_open_as) @@ -796,7 +805,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI menu.removeItem(R.id.action_add_or_remove_from_list) } - return super.onCreateOptionsMenu(menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@AccountActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.collapsingToolbar, android.R.attr.textColorPrimary) + } + } } private fun showFollowRequestPendingDialog() { @@ -884,7 +898,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewUrl(url) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun onMenuItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. @@ -949,6 +963,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI viewModel.changeShowReblogsState() return true } + R.id.action_refresh -> { + binding.swipeToRefreshLayout.isRefreshing = true + onRefresh() + return true + } R.id.action_report -> { loadedAccount?.let { loadedAccount -> startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)) @@ -956,7 +975,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI return true } } - return super.onOptionsItemSelected(item) + return false } override fun getActionButton(): FloatingActionButton? { 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 457cda7b..df87372e 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 @@ -16,15 +16,21 @@ package com.keylesspalace.tusky.components.account.media import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding @@ -39,20 +45,22 @@ import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +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.collectLatest import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject /** - * Created by charlag on 26/10/2017. - * * Fragment with multiple columns of media previews for the specified account. */ - class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, + MenuProvider, Injectable { @Inject @@ -73,6 +81,7 @@ class AccountMediaFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia @@ -95,6 +104,8 @@ class AccountMediaFragment : binding.recyclerView.adapter = adapter binding.swipeRefreshLayout.isEnabled = false + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.statusView.visibility = View.GONE @@ -108,6 +119,10 @@ class AccountMediaFragment : binding.statusView.hide() binding.progressBar.hide() + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { + binding.swipeRefreshLayout.isRefreshing = false + } + if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { @@ -133,6 +148,27 @@ class AccountMediaFragment : } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_account_media, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshContent() + true + } + else -> false + } + } + private fun onAttachmentClick(selected: AttachmentViewData, view: View) { if (!selected.isRevealed) { viewModel.revealAttachment(selected) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index 14fbcf8c..a62fb12b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -19,12 +19,17 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.widget.PopupWindow import androidx.activity.viewModels +import androidx.core.view.MenuProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -42,9 +47,18 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker +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 javax.inject.Inject -class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable { +class AnnouncementsActivity : + BottomSheetActivity(), + AnnouncementActionListener, + OnEmojiSelectedListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -71,6 +85,7 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) + addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -130,6 +145,27 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, binding.progressBar.show() } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_announcements, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@AnnouncementsActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshAnnouncements() + true + } + else -> false + } + } + private fun refreshAnnouncements() { viewModel.load() binding.swipeRefreshLayout.isRefreshing = true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index b05df2f8..fc5cebe6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -17,10 +17,14 @@ package com.keylesspalace.tusky.components.conversation import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu +import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -32,6 +36,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder @@ -52,6 +57,10 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData +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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -61,7 +70,12 @@ import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration -class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { +class ConversationsFragment : + SFragment(), + StatusActionListener, + Injectable, + ReselectableFragment, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -82,6 +96,8 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( @@ -189,6 +205,27 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_conversations, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshContent() + true + } + else -> false + } + } + private fun setupRecyclerView() { binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -200,10 +237,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } + private fun refreshContent() { + adapter.refresh() + } + private fun initSwipeToRefresh() { - binding.swipeRefreshLayout.setOnRefreshListener { - adapter.refresh() - } + binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 7210fbef..f65e29c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -16,17 +16,24 @@ package com.keylesspalace.tusky.components.report.fragments import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState 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.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -48,11 +55,20 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData +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.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Injectable, AdapterHandler { +class ReportStatusesFragment : + Fragment(R.layout.fragment_report_statuses), + Injectable, + OnRefreshListener, + MenuProvider, + AdapterHandler { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -90,18 +106,42 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) handleClicks() initStatusesView() setupSwipeRefreshLayout() } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_report_statuses, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onRefresh() { + snackbarErrorRetry?.dismiss() + adapter.refresh() + } + private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) - binding.swipeRefreshLayout.setOnRefreshListener { - snackbarErrorRetry?.dismiss() - adapter.refresh() - } + binding.swipeRefreshLayout.setOnRefreshListener(this) } private fun initStatusesView() { 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 ade93322..d78bc683 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 @@ -18,13 +18,18 @@ package com.keylesspalace.tusky.components.scheduled import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import autodispose2.androidx.lifecycle.autoDispose +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub @@ -36,12 +41,21 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import javax.inject.Inject -class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, Injectable { +class ScheduledStatusActivity : + BaseActivity(), + ScheduledStatusActionListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -51,13 +65,15 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I private val viewModel: ScheduledStatusViewModel by viewModels { viewModelFactory } + private val binding by viewBinding(ActivityScheduledStatusBinding::inflate) + private val adapter = ScheduledStatusAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityScheduledStatusBinding.inflate(layoutInflater) setContentView(binding.root) + addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { @@ -113,6 +129,27 @@ class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, I } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.activity_announcements, menu) + menu.findItem(R.id.action_search)?.apply { + icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.includedToolbar.toolbar, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + refreshStatuses() + true + } + else -> false + } + } + private fun refreshStatuses() { adapter.refresh() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index beb31611..90bf04e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -20,8 +20,11 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.widget.SearchView +import androidx.core.view.MenuProvider import androidx.preference.PreferenceManager import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity @@ -37,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class SearchActivity : BottomSheetActivity(), HasAndroidInjector { +class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -59,6 +62,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(false) } + addMenuProvider(this) setupPages() handleIntent(intent) } @@ -81,17 +85,18 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { handleIntent(intent) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.search_toolbar, menu) - val searchView = menu.findItem(R.id.action_search) - .actionView as SearchView + val searchViewMenuItem = menu.findItem(R.id.action_search) + searchViewMenuItem.expandActionView() + val searchView = searchViewMenuItem.actionView as SearchView setupSearchView(searchView) searchView.setQuery(viewModel.currentQuery, false) + } - return true + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return false } override fun finish() { @@ -116,17 +121,42 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { private fun setupSearchView(searchView: SearchView) { searchView.setIconifiedByDefault(false) - searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName)) - searchView.requestFocus() + // SearchView has a bug. If it's displayed 'app:showAsAction="always"' it's too wide, + // pushing other icons (including the options menu '...' icon) off the edge of the + // screen. + // + // E.g., see: + // + // - https://stackoverflow.com/questions/41662373/android-toolbar-searchview-too-wide-to-move-other-items + // - https://stackoverflow.com/questions/51525088/how-to-control-size-of-a-searchview-in-toolbar + // - https://stackoverflow.com/questions/36976163/push-icons-away-when-expandig-searchview-in-android-toolbar + // - https://issuetracker.google.com/issues/36976484 + // + // The fix is to use 'app:showAsAction="ifRoom|collapseActionView"' and then immediately + // expand it after inflating. That sets the width correctly. + // + // But if you do that code in AppCompatDelegateImpl activates, and when the user presses + // the "Back" button the SearchView is first set to its collapsed state. The user has to + // press "Back" again to exit the activity. This is clearly unacceptable. + // + // It appears to be impossible to override this behaviour on API level < 33. + // + // SearchView does allow you to specify the maximum width. So take the screen width, + // subtract 48dp * 2 (for the menu icon and back icon on either side), convert to pixels, + // and use that. + val pxScreenWidth = resources.displayMetrics.widthPixels + val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt() + searchView.maxWidth = pxScreenWidth - pxBuffer - searchView.maxWidth = Integer.MAX_VALUE + searchView.requestFocus() } override fun androidInjector() = androidInjector companion object { + const val TAG = "SearchActivity" fun getIntent(context: Context) = Intent(context, SearchActivity::class.java) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 3fe818cb..86b54238 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -1,9 +1,14 @@ package com.keylesspalace.tusky.components.search.fragments import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.paging.PagingData @@ -12,6 +17,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R @@ -25,6 +31,10 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import com.mikepenz.iconics.IconicsDrawable +import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial +import com.mikepenz.iconics.utils.colorInt +import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -34,7 +44,8 @@ abstract class SearchFragment : Fragment(R.layout.fragment_search), LinkListener, Injectable, - SwipeRefreshLayout.OnRefreshListener { + SwipeRefreshLayout.OnRefreshListener, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -58,6 +69,7 @@ abstract class SearchFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initAdapter() setupSwipeRefreshLayout() + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) subscribeObservables() } @@ -95,6 +107,27 @@ abstract class SearchFragment : } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_timeline, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + private fun initAdapter() { binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index fe7b5d14..36d20e68 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.timeline import android.os.Bundle import android.util.Log import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityManager import androidx.core.content.ContextCompat +import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -34,6 +38,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils import autodispose2.androidx.lifecycle.autoDispose +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder @@ -65,6 +70,10 @@ import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +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 io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import kotlinx.coroutines.flow.collectLatest @@ -79,7 +88,8 @@ class TimelineFragment : StatusActionListener, Injectable, ReselectableFragment, - RefreshableFragment { + RefreshableFragment, + MenuProvider { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -198,6 +208,8 @@ class TimelineFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + setupSwipeRefreshLayout() setupRecyclerView() @@ -293,6 +305,35 @@ class TimelineFragment : } } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + if (isSwipeToRefreshEnabled) { + menuInflater.inflate(R.menu.fragment_timeline, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = + MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + if (isSwipeToRefreshEnabled) { + binding.swipeRefreshLayout.isRefreshing = true + + refreshContent() + true + } else { + false + } + } + else -> false + } + } + /** * Set the correct reading position in the timeline after the user clicked "Load more", * assuming the reading position should be below the freshly-loaded statuses. 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 index ed0393fa..70c0df19 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt @@ -21,18 +21,28 @@ import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ActivityViewThreadBinding +import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject class ViewThreadActivity : BottomSheetActivity(), HasAndroidInjector { + private val binding by viewBinding(ActivityViewThreadBinding::inflate) + @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_view_thread) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + setDisplayShowTitleEnabled(true) + } val id = intent.getStringExtra(ID_EXTRA)!! val url = intent.getStringExtra(URL_EXTRA)!! val fragment = 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 index 84379e07..4baa0ff1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -18,10 +18,14 @@ package com.keylesspalace.tusky.components.viewthread import android.os.Bundle import android.util.Log import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.annotation.CheckResult +import androidx.core.view.MenuProvider import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -59,7 +63,12 @@ import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject -class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, Injectable { +class ViewThreadFragment : + SFragment(), + OnRefreshListener, + StatusActionListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -74,6 +83,16 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, private var alwaysShowSensitiveMedia = false private var alwaysOpenSpoiler = false + /** + * State of the "reveal" menu item that shows/hides content that is behind a content + * warning. Setting this invalidates the menu to redraw the menu item. + */ + private var revealButtonState = RevealButtonState.NO_BUTTON + set(value) { + field = value + requireActivity().invalidateMenu() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! @@ -107,24 +126,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - - binding.toolbar.setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } - binding.toolbar.inflateMenu(R.menu.view_thread_toolbar) - 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 - } - } + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) binding.swipeRefreshLayout.setOnRefreshListener(this) binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) @@ -154,7 +156,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.uiState.collect { uiState -> when (uiState) { is ThreadUiState.Loading -> { - updateRevealButton(RevealButtonState.NO_BUTTON) + revealButtonState = RevealButtonState.NO_BUTTON binding.recyclerView.hide() binding.statusView.hide() @@ -175,7 +177,7 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, adapter.submitList(listOf(uiState.statusViewDatum)) - updateRevealButton(uiState.revealButton) + revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() @@ -186,18 +188,24 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, initialProgressBar.cancel() threadProgressBar.cancel() - updateRevealButton(RevealButtonState.NO_BUTTON) + revealButtonState = RevealButtonState.NO_BUTTON binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() if (uiState.throwable is IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) { + 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) { + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { viewModel.retry(thisThreadsStatusId) } } @@ -216,11 +224,14 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.isInitialLoad = false // Ensure the top of the status is visible - (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(uiState.detailedStatusPosition, 0) + (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + uiState.detailedStatusPosition, + 0 + ) } } - updateRevealButton(uiState.revealButton) + revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() @@ -247,6 +258,41 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, viewModel.loadThread(thisThreadsStatusId) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_view_thread, menu) + val actionReveal = menu.findItem(R.id.action_reveal) + actionReveal.isVisible = revealButtonState != RevealButtonState.NO_BUTTON + actionReveal.setIcon( + when (revealButtonState) { + RevealButtonState.REVEAL -> R.drawable.ic_eye_24dp + else -> R.drawable.ic_hide_media_24dp + } + ) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_reveal -> { + viewModel.toggleRevealButton() + true + } + R.id.action_open_in_web -> { + context?.openLink(requireArguments().getString(URL_EXTRA)!!) + true + } + R.id.action_refresh -> { + onRefresh() + true + } + else -> false + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.title_view_thread) + } + /** * Create a job to implement a delayed-visible progress bar. * @@ -269,13 +315,6 @@ class ViewThreadFragment : SFragment(), OnRefreshListener, StatusActionListener, } } - 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) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt index d02d017d..f829141a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -17,15 +17,22 @@ package com.keylesspalace.tusky.components.viewthread.edits import android.os.Bundle import android.util.Log +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.widget.LinearLayout +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle 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.color.MaterialColors import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity @@ -38,11 +45,20 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding +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 java.io.IOException import javax.inject.Inject -class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, Injectable { +class ViewEditsFragment : + Fragment(R.layout.fragment_view_thread), + LinkListener, + OnRefreshListener, + MenuProvider, + Injectable { @Inject lateinit var viewModelFactory: ViewModelFactory @@ -54,12 +70,10 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, private lateinit var statusId: String override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - binding.toolbar.setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } - binding.toolbar.title = getString(R.string.title_edits) - binding.swipeRefreshLayout.isEnabled = false + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) @@ -84,9 +98,11 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, binding.statusView.hide() binding.initialProgressBar.show() } + EditsUiState.Refreshing -> {} is EditsUiState.Error -> { Log.w(TAG, "failed to load edits", uiState.throwable) + binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() binding.initialProgressBar.hide() @@ -102,6 +118,7 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, } } is EditsUiState.Success -> { + binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() binding.initialProgressBar.hide() @@ -121,6 +138,36 @@ class ViewEditsFragment : Fragment(R.layout.fragment_view_thread), LinkListener, viewModel.loadEdits(statusId) } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_view_edits, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.title_edits) + } + + override fun onRefresh() { + viewModel.loadEdits(statusId, force = true, refreshing = true) + } + override fun onViewAccount(id: String) { bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index a76078ed..c5959d26 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -20,8 +20,9 @@ import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -30,25 +31,27 @@ class ViewEditsViewModel @Inject constructor( ) : ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) - val uiState: Flow - get() = _uiState + val uiState: StateFlow = _uiState.asStateFlow() fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { - if (force || _uiState.value is EditsUiState.Initial) { - if (!refreshing) { - _uiState.value = EditsUiState.Loading - } - viewModelScope.launch { - api.statusEdits(statusId).fold( - { edits -> - val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() - _uiState.value = EditsUiState.Success(sortedEdits) - }, - { throwable -> - _uiState.value = EditsUiState.Error(throwable) - } - ) - } + if (!force && _uiState.value !is EditsUiState.Initial) return + + if (refreshing) { + _uiState.value = EditsUiState.Refreshing + } else { + _uiState.value = EditsUiState.Loading + } + + viewModelScope.launch { + api.statusEdits(statusId).fold( + { edits -> + val sortedEdits = edits.sortedBy { edit -> edit.createdAt }.reversed() + _uiState.value = EditsUiState.Success(sortedEdits) + }, + { throwable -> + _uiState.value = EditsUiState.Error(throwable) + } + ) } } } @@ -56,6 +59,10 @@ class ViewEditsViewModel @Inject constructor( sealed interface EditsUiState { object Initial : EditsUiState object Loading : EditsUiState + + // "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success, + // and state flows don't emit repeated states, so the UI never updates. + object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 9c9d3c6d..24d026cc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -27,6 +27,9 @@ import android.os.Bundle; import android.util.Log; import android.util.SparseBooleanArray; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; @@ -39,6 +42,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.arch.core.util.Function; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.util.Pair; +import androidx.core.view.MenuProvider; import androidx.lifecycle.Lifecycle; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.AsyncDifferConfig; @@ -120,7 +124,9 @@ public class NotificationsFragment extends SFragment implements StatusActionListener, NotificationsAdapter.NotificationActionListener, AccountActionListener, - Injectable, ReselectableFragment { + Injectable, + MenuProvider, + ReselectableFragment { private static final String TAG = "NotificationF"; // logging tag private static final int LOAD_AT_ONCE = 30; @@ -205,6 +211,8 @@ public class NotificationsFragment extends SFragment implements @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); + binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); @NonNull Context context = inflater.getContext(); // from inflater to silence warning @@ -287,6 +295,22 @@ public class NotificationsFragment extends SFragment implements binding = null; } + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { + if (menuItem.getItemId() == R.id.action_refresh) { + binding.swipeRefreshLayout.setRefreshing(true); + onRefresh(); + return true; + } + + return false; + } + private void updateFilterVisibility() { CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); 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 9248c542..e11b8c25 100644 --- a/app/src/main/res/layout-sw640dp/fragment_view_thread.xml +++ b/app/src/main/res/layout-sw640dp/fragment_view_thread.xml @@ -4,22 +4,6 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - + + + + + - \ 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 f0f348c5..539efe57 100644 --- a/app/src/main/res/layout/fragment_view_thread.xml +++ b/app/src/main/res/layout/fragment_view_thread.xml @@ -4,22 +4,6 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - + + diff --git a/app/src/main/res/menu/activity_announcements.xml b/app/src/main/res/menu/activity_announcements.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/activity_announcements.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/activity_main.xml b/app/src/main/res/menu/activity_main.xml new file mode 100644 index 00000000..a1ca8b75 --- /dev/null +++ b/app/src/main/res/menu/activity_main.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/activity_scheduled_status.xml b/app/src/main/res/menu/activity_scheduled_status.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/activity_scheduled_status.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_account_media.xml b/app/src/main/res/menu/fragment_account_media.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_account_media.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_conversations.xml b/app/src/main/res/menu/fragment_conversations.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_conversations.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_notifications.xml b/app/src/main/res/menu/fragment_notifications.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_notifications.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_report_statuses.xml b/app/src/main/res/menu/fragment_report_statuses.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_report_statuses.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_search.xml b/app/src/main/res/menu/fragment_search.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_search.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_timeline.xml b/app/src/main/res/menu/fragment_timeline.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_timeline.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/fragment_view_edits.xml b/app/src/main/res/menu/fragment_view_edits.xml new file mode 100644 index 00000000..bf722917 --- /dev/null +++ b/app/src/main/res/menu/fragment_view_edits.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/menu/view_thread_toolbar.xml b/app/src/main/res/menu/fragment_view_thread.xml similarity index 77% rename from app/src/main/res/menu/view_thread_toolbar.xml rename to app/src/main/res/menu/fragment_view_thread.xml index 65577d20..286ca56e 100644 --- a/app/src/main/res/menu/view_thread_toolbar.xml +++ b/app/src/main/res/menu/fragment_view_thread.xml @@ -13,5 +13,8 @@ app:showAsAction="ifRoom" android:icon="@drawable/ic_eye_24dp" /> - - \ No newline at end of file + + diff --git a/app/src/main/res/menu/search_toolbar.xml b/app/src/main/res/menu/search_toolbar.xml index 633ca7ef..6ac14511 100644 --- a/app/src/main/res/menu/search_toolbar.xml +++ b/app/src/main/res/menu/search_toolbar.xml @@ -1,6 +1,7 @@ + - \ 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 7a08a1ba..9ee06107 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -730,6 +730,7 @@ Other Unfollow #%s? + Refresh Mute notifications From b8c77a795c942e4089cad20d523cfd183a056be1 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 1 Mar 2023 19:59:40 +0100 Subject: [PATCH 109/418] prevent adding multiple tabs of the same type (#3390) * prevent adding multiple tabs of the same type * use Objects.hash --- .../java/com/keylesspalace/tusky/TabData.kt | 89 +++++++++++-------- 1 file changed, 52 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 8d2b2a75..43e59500 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -24,6 +24,7 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.fragment.NotificationsFragment +import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -43,63 +44,77 @@ data class TabData( val fragment: (List) -> Fragment, val arguments: List = emptyList(), val title: (Context) -> String = { context -> context.getString(text) } -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TabData + + if (id != other.id) return false + if (arguments != other.arguments) return false + + return true + } + + override fun hashCode() = Objects.hash(id, arguments) +} fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { HOME -> TabData( - HOME, - R.string.title_home, - R.drawable.ic_home_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } + id = HOME, + text = R.string.title_home, + icon = R.drawable.ic_home_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } ) NOTIFICATIONS -> TabData( - NOTIFICATIONS, - R.string.title_notifications, - R.drawable.ic_notifications_24dp, - { NotificationsFragment.newInstance() } + id = NOTIFICATIONS, + text = R.string.title_notifications, + icon = R.drawable.ic_notifications_24dp, + fragment = { NotificationsFragment.newInstance() } ) LOCAL -> TabData( - LOCAL, - R.string.title_public_local, - R.drawable.ic_local_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } + id = LOCAL, + text = R.string.title_public_local, + icon = R.drawable.ic_local_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } ) FEDERATED -> TabData( - FEDERATED, - R.string.title_public_federated, - R.drawable.ic_public_24dp, - { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } + id = FEDERATED, + text = R.string.title_public_federated, + icon = R.drawable.ic_public_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } ) DIRECT -> TabData( - DIRECT, - R.string.title_direct_messages, - R.drawable.ic_reblog_direct_24dp, - { ConversationsFragment.newInstance() } + id = DIRECT, + text = R.string.title_direct_messages, + icon = R.drawable.ic_reblog_direct_24dp, + fragment = { ConversationsFragment.newInstance() } ) TRENDING -> TabData( - TRENDING, - R.string.title_public_trending_hashtags, - R.drawable.ic_trending_up_24px, - { TrendingFragment.newInstance() } + id = TRENDING, + text = R.string.title_public_trending_hashtags, + icon = R.drawable.ic_trending_up_24px, + fragment = { TrendingFragment.newInstance() } ) HASHTAG -> TabData( - HASHTAG, - R.string.hashtags, - R.drawable.ic_hashtag, - { args -> TimelineFragment.newHashtagInstance(args) }, - arguments, - { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } + id = HASHTAG, + text = R.string.hashtags, + icon = R.drawable.ic_hashtag, + fragment = { args -> TimelineFragment.newHashtagInstance(args) }, + arguments = arguments, + title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) LIST -> TabData( - LIST, - R.string.list, - R.drawable.ic_list, - { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, - arguments, - { arguments.getOrNull(1).orEmpty() } + id = LIST, + text = R.string.list, + icon = R.drawable.ic_list, + fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + arguments = arguments, + title = { arguments.getOrNull(1).orEmpty() } ) else -> throw IllegalArgumentException("unknown tab type") } From 816dc0cbbc6e264ea3fd138d5214b8d1a3d9a2a3 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 1 Mar 2023 20:00:19 +0100 Subject: [PATCH 110/418] make sure all timeline database operations run on a background thread (#3391) --- .../tusky/appstore/CacheUpdater.kt | 51 +++++++++++-------- .../com/keylesspalace/tusky/db/TimelineDao.kt | 30 +++++------ 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 66ae898b..df551e10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -3,45 +3,52 @@ package com.keylesspalace.tusky.appstore import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow import javax.inject.Inject class CacheUpdater @Inject constructor( eventHub: EventHub, - private val accountManager: AccountManager, + accountManager: AccountManager, appDatabase: AppDatabase, gson: Gson ) { - private val disposable: Disposable + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) init { val timelineDao = appDatabase.timelineDao() - disposable = eventHub.events.subscribe { event -> - val accountId = accountManager.activeAccount?.id ?: return@subscribe - when (event) { - is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) - is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) - is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) - is UnfollowEvent -> - timelineDao.removeAllByUser(accountId, event.accountId) - is StatusDeletedEvent -> - timelineDao.delete(accountId, event.statusId) - is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) - timelineDao.setVoted(accountId, event.statusId, pollString) + scope.launch { + eventHub.events.asFlow().collect { event -> + val accountId = accountManager.activeAccount?.id ?: return@collect + when (event) { + is FavoriteEvent -> + timelineDao.setFavourited(accountId, event.statusId, event.favourite) + is ReblogEvent -> + timelineDao.setReblogged(accountId, event.statusId, event.reblog) + is BookmarkEvent -> + timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is UnfollowEvent -> + timelineDao.removeAllByUser(accountId, event.accountId) + is StatusDeletedEvent -> + timelineDao.delete(accountId, event.statusId) + is PollVoteEvent -> { + val pollString = gson.toJson(event.poll) + timelineDao.setVoted(accountId, event.statusId, pollString) + } + is PinEvent -> + timelineDao.setPinned(accountId, event.statusId, event.pinned) } - is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } fun stop() { - this.disposable.dispose() + this.scope.cancel() } } 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 d8423600..18f2f4d4 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.editedAt, 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.language, +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', @@ -59,7 +59,7 @@ SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, 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.language, +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', @@ -71,7 +71,7 @@ rb.emojis as 'rb_emojis', rb.bot as 'rb_bot' FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) -WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) +WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) AND s.authorServerId IS NOT NULL""" ) abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? @@ -89,25 +89,25 @@ AND """UPDATE TimelineStatusEntity SET favourited = :favourited WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) + abstract suspend fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) @Query( """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) + abstract suspend fun setBookmarked(accountId: Long, statusId: String, bookmarked: Boolean) @Query( """UPDATE TimelineStatusEntity SET reblogged = :reblogged WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) + abstract suspend fun setReblogged(accountId: Long, statusId: String, reblogged: Boolean) @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (authorServerId = :userId OR reblogAccountId = :userId)""" ) - abstract fun removeAllByUser(accountId: Long, userId: String) + abstract suspend fun removeAllByUser(accountId: Long, userId: String) /** * Removes everything in the TimelineStatusEntity and TimelineAccountEntity tables for one user account @@ -128,7 +128,7 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND serverId = :statusId""" ) - abstract fun delete(accountId: Long, statusId: String) + abstract suspend fun delete(accountId: Long, statusId: String) /** * Cleans the TimelineStatusEntity and TimelineAccountEntity tables from old entries. @@ -158,8 +158,8 @@ AND serverId = :statusId""" */ @Query( """DELETE FROM TimelineAccountEntity WHERE timelineUserId = :accountId AND serverId NOT IN - (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) - AND serverId NOT IN + (SELECT authorServerId FROM TimelineStatusEntity WHERE timelineUserId = :accountId) + AND serverId NOT IN (SELECT reblogAccountId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND reblogAccountId IS NOT NULL)""" ) abstract suspend fun cleanupAccounts(accountId: Long) @@ -168,31 +168,31 @@ AND serverId = :statusId""" """UPDATE TimelineStatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setVoted(accountId: Long, statusId: String, poll: String) + abstract suspend fun setVoted(accountId: Long, statusId: String, poll: String) @Query( """UPDATE TimelineStatusEntity SET expanded = :expanded WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) + abstract suspend fun setExpanded(accountId: Long, statusId: String, expanded: Boolean) @Query( """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) + abstract suspend fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) @Query( """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) + abstract suspend fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) @Query( """UPDATE TimelineStatusEntity SET pinned = :pinned WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract fun setPinned(accountId: Long, statusId: String, pinned: Boolean) + abstract suspend fun setPinned(accountId: Long, statusId: String, pinned: Boolean) @Query( """DELETE FROM TimelineStatusEntity From ed188783de8cd622dc764ae629f1ef25471e5664 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 1 Mar 2023 20:00:56 +0100 Subject: [PATCH 111/418] include card and collapsed state in instant expanded change (#3394) --- .../tusky/adapter/StatusBaseViewHolder.java | 99 +++++++++++-------- .../adapter/StatusDetailedViewHolder.java | 2 +- .../tusky/adapter/StatusViewHolder.java | 25 ++++- .../conversation/ConversationViewHolder.java | 5 +- 4 files changed, 81 insertions(+), 50 deletions(-) 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 f38a5d68..582abee4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -191,16 +191,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningButton.performClick(); } - protected void setSpoilerAndContent(boolean expanded, - @NonNull Spanned content, - @Nullable String spoilerText, - @Nullable List mentions, - @Nullable List tags, - @NonNull List emojis, - @Nullable PollViewData poll, + protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + String spoilerText = status.getSpoilerText(); + List emojis = actionable.getEmojis(); + boolean sensitive = !TextUtils.isEmpty(spoilerText); + boolean expanded = status.isExpanded(); + if (sensitive) { CharSequence emojiSpoiler = CustomEmojiHelper.emojify( spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() @@ -209,20 +210,12 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningDescription.setVisibility(View.VISIBLE); contentWarningButton.setVisibility(View.VISIBLE); setContentWarningButtonText(expanded); - contentWarningButton.setOnClickListener(view -> { - contentWarningDescription.invalidate(); - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - listener.onExpandedChange(!expanded, getBindingAdapterPosition()); - } - setContentWarningButtonText(!expanded); - - this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); - }); - this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); + this.setTextVisible(true, expanded, status, statusDisplayOptions, listener); } else { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); + this.setTextVisible(false, true, status, statusDisplayOptions, listener); } } @@ -234,20 +227,42 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + protected void toggleExpandedState(boolean sensitive, + boolean expanded, + @NonNull final StatusViewData.Concrete status, + @NonNull final StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusActionListener listener) { + + contentWarningDescription.invalidate(); + int adapterPosition = getBindingAdapterPosition(); + if (adapterPosition != RecyclerView.NO_POSITION) { + listener.onExpandedChange(expanded, adapterPosition); + } + setContentWarningButtonText(expanded); + + this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener); + + setupCard(status, expanded, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); + } + private void setTextVisible(boolean sensitive, boolean expanded, - Spanned content, - List mentions, - List tags, - List emojis, - @Nullable PollViewData poll, - StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusViewData.Concrete status, + @NonNull final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { + + Status actionable = status.getActionable(); + Spanned content = status.getContent(); + List mentions = actionable.getMentions(); + List tags =actionable.getTags(); + List emojis = actionable.getEmojis(); + PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); + if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); for (int i = 0; i < mediaLabels.length; ++i) { - updateMediaLabel(i, sensitive, expanded); + updateMediaLabel(i, sensitive, true); } if (poll != null) { setupPoll(poll, emojis, statusDisplayOptions, listener); @@ -742,18 +757,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { hideSensitiveMediaWarning(); } - if (cardView != null) { - setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); - } + setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); - setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), - actionable.getMentions(), actionable.getTags(), actionable.getEmojis(), - PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, - listener); + setSpoilerAndContent(status, statusDisplayOptions, listener); setDescriptionForStatus(status, statusDisplayOptions); @@ -1008,20 +1018,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard( - StatusViewData.Concrete status, - CardViewMode cardViewMode, - StatusDisplayOptions statusDisplayOptions, + final StatusViewData.Concrete status, + boolean expanded, + final CardViewMode cardViewMode, + final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener ) { + if (cardView == null) { + return; + } + final Status actionable = status.getActionable(); final Card card = actionable.getCard(); + if (cardViewMode != CardViewMode.NONE && - actionable.getAttachments().size() == 0 && - actionable.getPoll() == null && - card != null && - !TextUtils.isEmpty(card.getUrl()) && - (!actionable.getSensitive() || status.isExpanded()) && - (!status.isCollapsible() || !status.isCollapsed())) { + actionable.getAttachments().size() == 0 && + actionable.getPoll() == null && + card != null && + !TextUtils.isEmpty(card.getUrl()) && + (!actionable.getSensitive() || expanded) && + (!status.isCollapsible() || !status.isCollapsed())) { + cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); if (TextUtils.isEmpty(card.getDescription()) && TextUtils.isEmpty(card.getAuthorName())) { 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 1725dac9..76eda110 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -159,7 +159,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); - setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status + setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { Status actionable = uncollapsedStatus.getActionable(); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 13d175a0..18a669a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -60,7 +60,10 @@ public class StatusViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { if (payloads == null) { - setupCollapsedState(status, listener); + boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); + boolean expanded = status.isExpanded(); + + setupCollapsedState(sensitive, expanded, status, listener); Status reblogging = status.getRebloggingStatus(); if (reblogging == null) { @@ -74,7 +77,6 @@ public class StatusViewHolder extends StatusBaseViewHolder { } super.setupWithStatus(status, listener, statusDisplayOptions, payloads); - } private void setRebloggedByDisplayName(final CharSequence name, @@ -103,9 +105,12 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.GONE); } - private void setupCollapsedState(final StatusViewData.Concrete status, final StatusActionListener listener) { + private void setupCollapsedState(boolean sensitive, + boolean expanded, + final StatusViewData.Concrete status, + final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ - if (status.isCollapsible() && (status.isExpanded() || TextUtils.isEmpty(status.getSpoilerText()))) { + if (status.isCollapsible() && (!sensitive || expanded)) { contentCollapseButton.setOnClickListener(view -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) @@ -130,4 +135,16 @@ public class StatusViewHolder extends StatusBaseViewHolder { super.showStatusContent(show); contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); } + + @Override + protected void toggleExpandedState(boolean sensitive, + boolean expanded, + @NonNull StatusViewData.Concrete status, + @NonNull StatusDisplayOptions statusDisplayOptions, + @NonNull final StatusActionListener listener) { + + setupCollapsedState(sensitive, expanded, status, listener); + + super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 64b42eaa..722a9f3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -36,7 +36,6 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; @@ -110,9 +109,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { setupButtons(listener, account.getId(), statusViewData.getContent().toString(), statusDisplayOptions); - setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(), - status.getMentions(), status.getTags(), status.getEmojis(), - PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); + setSpoilerAndContent(statusViewData, statusDisplayOptions, listener); setConversationName(conversation.getAccounts()); From eb2a8abc23100741472a0556fe079697b55f7bc9 Mon Sep 17 00:00:00 2001 From: Paul Sanz Date: Wed, 1 Mar 2023 19:59:30 +0000 Subject: [PATCH 112/418] Translated using Weblate (Spanish) Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Paul Sanz Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/es/ Translation: Tusky/Tusky --- app/src/main/res/values-es/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9b06c0b2..2309425f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -646,4 +646,6 @@ %1$d personas están hablando de #%2$s Uso total Cuentas totales + Seguir etiqueta + #etiqueta \ No newline at end of file From 4afca08ac8f2d43522bebb527b88dcc3f42ee9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sveinn=20=C3=AD=20Felli?= Date: Wed, 1 Mar 2023 19:59:30 +0000 Subject: [PATCH 113/418] Translated using Weblate (Icelandic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (566 of 566 strings) Co-authored-by: Sveinn í Felli Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/is/ Translation: Tusky/Tusky --- app/src/main/res/values-is/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 011f16f9..d6cc3111 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -620,4 +620,6 @@ Gæti stutt fleiri aðferðir til auðkenningar, en krefst studds vafra. Heildarnotkun Aðgangar alls + Fylgjast með myllumerki + #myllumerki \ No newline at end of file From 7fa0c51599f37e3c6d64b639f0acc76e6051ab44 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Wed, 1 Mar 2023 19:59:30 +0000 Subject: [PATCH 114/418] Translated using Weblate (Ukrainian) Currently translated at 100.0% (567 of 567 strings) Co-authored-by: Ihor Hordiichuk Translate-URL: https://weblate.tusky.app/projects/tusky/tusky/uk/ Translation: Tusky/Tusky --- app/src/main/res/values-uk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 89ab2003..751d2486 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -648,4 +648,5 @@ Усього облікових записів Стежити за хештегом #hashtag + Оновити \ No newline at end of file From ca29ee2b0b25142d3dbb7dc182b84038b16a38e7 Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 2 Mar 2023 04:06:55 +0800 Subject: [PATCH 115/418] Use more orEmpty extensions (#3399) https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.text/or-empty.html https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/or-empty.html --- .../java/com/keylesspalace/tusky/AccountsInListFragment.kt | 2 +- .../main/java/com/keylesspalace/tusky/EditProfileActivity.kt | 2 +- .../keylesspalace/tusky/components/account/AccountActivity.kt | 4 ++-- .../tusky/components/conversation/ConversationEntity.kt | 2 +- .../tusky/components/preference/AccountPreferencesFragment.kt | 4 ++-- .../tusky/components/report/fragments/ReportNoteFragment.kt | 2 +- .../keylesspalace/tusky/components/search/SearchActivity.kt | 2 +- .../components/search/fragments/SearchStatusesFragment.kt | 2 +- .../tusky/components/trending/TrendingAdapter.kt | 2 +- .../main/java/com/keylesspalace/tusky/db/AccountManager.kt | 4 ++-- .../tusky/receiver/SendStatusBroadcastReceiver.kt | 4 ++-- app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt | 2 +- .../test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 6be4224a..2235958c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -114,7 +114,7 @@ class AccountsInListFragment : DialogFragment(), Injectable { binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - viewModel.search(query ?: "") + viewModel.search(query.orEmpty()) return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 369f6926..33d7a281 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -136,7 +136,7 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.noteEditText.setText(me.source?.note) binding.lockedCheckBox.isChecked = me.locked - accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) + accountFieldEditAdapter.setFields(me.source?.fields.orEmpty()) binding.addFieldButton.isVisible = (me.source?.fields?.size ?: 0) < maxAccountFields 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 1b080020..cdb30608 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 @@ -463,8 +463,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - accountFieldAdapter.fields = account.fields ?: emptyList() - accountFieldAdapter.emojis = account.emojis ?: emptyList() + accountFieldAdapter.fields = account.fields.orEmpty() + accountFieldAdapter.emojis = account.emojis.orEmpty() accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) 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 0a75654b..c338a1c0 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 @@ -145,7 +145,7 @@ fun TimelineAccount.toEntity() = username = username, displayName = name, avatar = avatar, - emojis = emojis ?: emptyList() + emojis = emojis.orEmpty() ) fun Status.toEntity( 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 a863db48..61b759f1 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 @@ -207,7 +207,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { entryValues = (listOf("") + locales.map { it.language }).toTypedArray() key = PrefKeys.DEFAULT_POST_LANGUAGE icon = makeIcon(requireContext(), GoogleMaterial.Icon.gmd_translate, iconSize) - value = accountManager.activeAccount?.defaultPostLanguage ?: "" + value = accountManager.activeAccount?.defaultPostLanguage.orEmpty() isPersistent = false // This will be entirely server-driven setSummaryProvider { entry } @@ -339,7 +339,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language ?: "" + it.defaultPostLanguage = language.orEmpty() accountManager.saveAccount(it) } } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index 56f812a0..74b40fb9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -54,7 +54,7 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { private fun handleChanges() { binding.editNote.doAfterTextChanged { - viewModel.reportNote = it?.toString() ?: "" + viewModel.reportNote = it?.toString().orEmpty() } binding.checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked -> viewModel.isRemoteNotify = isChecked diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 90bf04e3..ca60e95c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -114,7 +114,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { private fun handleIntent(intent: Intent) { if (Intent.ACTION_SEARCH == intent.action) { - viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY) ?: "" + viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty() viewModel.search(viewModel.currentQuery) } } 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 9973f3e8..12aeaf81 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 @@ -468,7 +468,7 @@ class SearchStatusesFragment : SearchFragment(), Status val intent = ComposeActivity.startIntent( requireContext(), ComposeOptions( - content = redraftStatus.text ?: "", + content = redraftStatus.text.orEmpty(), inReplyToId = redraftStatus.inReplyToId, visibility = redraftStatus.visibility, contentWarning = redraftStatus.spoilerText, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt index 73a327b7..3e6f0341 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt @@ -72,7 +72,7 @@ class TrendingAdapter( is TrendingViewData.Tag -> { val maxTrendingValue = currentList .flatMap { trendingViewData -> - trendingViewData.asTagOrNull()?.tag?.history ?: emptyList() + trendingViewData.asTagOrNull()?.tag?.history.orEmpty() } .mapNotNull { it.uses.toLongOrNull() } .maxOrNull() ?: 1 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 bf8d414f..eaa36bb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -153,9 +153,9 @@ 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.defaultPostLanguage = account.source?.language.orEmpty() it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.emojis = account.emojis ?: emptyList() + it.emojis = account.emojis.orEmpty() Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) accountDao.insertOrReplace(it) 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 dbeff406..0c1d0e37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -49,8 +49,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME) val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility - val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER) ?: "" - val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS) ?: emptyArray() + val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty() + val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty() val account = accountManager.getAccountById(senderId) 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 7e23e45d..0f6d502e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -86,7 +86,7 @@ fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBu false } else { val text = spannableContent.subSequence(start, spannableContent.getSpanEnd(it)).toString() - .split(' ').lastOrNull() ?: "" + .split(' ').lastOrNull().orEmpty() var textDomain = getDomain(text) if (textDomain.isBlank()) { textDomain = getDomain("https://$text") diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt index db287cca..da4c48d5 100644 --- a/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/util/LocaleUtilsTest.kt @@ -73,7 +73,7 @@ class LocaleUtilsTest { clientId = null, clientSecret = null, isActive = true, - defaultPostLanguage = configuredLanguages[1] ?: "", + defaultPostLanguage = configuredLanguages[1].orEmpty(), ) ) } From a792d2c0d6b94d25277d6698d5e9fed57fc14351 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 1 Mar 2023 21:07:57 +0100 Subject: [PATCH 116/418] Enable parallel GC for Gradle builds (#3404) * Enable parallel GC for Gradle builds Per https://developer.android.com/studio/build/optimize-your-build#experiment-with-the-jvm-parallel-garbage-collector I benchmarked this, and p75 incremental build time dropped from 33s to 30s. https://github.com/gradle/gradle/issues/19750 means that if `org.gradle.jvmargs` is set any unchanged default values are lost, so include those too. * Update gradle.properties --- gradle.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 24b3ba99..f7cd3016 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,6 @@ org.gradle.caching=true -org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +# If jvmargs is changed then the default values must also be included, see https://github.com/gradle/gradle/issues/19750 +org.gradle.jvmargs=-XX:+UseParallelGC -Xmx4g -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Xms256m # use parallel execution org.gradle.parallel=true # https://docs.gradle.org/7.6/userguide/configuration_cache.html From 7261fa0b18a818338b543fe84db6dec2914a4fba Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Thu, 2 Mar 2023 18:30:26 +0100 Subject: [PATCH 117/418] By explicit about the `firstStrong` text direction on UGC (#3328) `activity_account` sets the root text direction to `anyRtl`. This is OK for UI elements, but can be a problem for user generated content (UGC) that may contain bidirectional text. Fix this by explicitly restoring the default behaviour, `firstStrong`, on views in `activity_account` that can show UGC. Fixes https://github.com/tuskyapp/Tusky/issues/3294 --- app/src/main/res/layout/activity_account.xml | 4 +++- app/src/main/res/layout/item_account_field.xml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index d794d4e5..bf011c0b 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -201,7 +201,8 @@ + android:hint="@string/account_note_hint" + android:textDirection="firstStrong" /> @@ -225,6 +226,7 @@ android:textColor="?android:textColorTertiary" android:textIsSelectable="true" android:textSize="?attr/status_text_medium" + android:textDirection="firstStrong" 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/item_account_field.xml b/app/src/main/res/layout/item_account_field.xml index 3d68e3df..17ea0dbc 100644 --- a/app/src/main/res/layout/item_account_field.xml +++ b/app/src/main/res/layout/item_account_field.xml @@ -16,6 +16,7 @@ android:lineSpacingMultiplier="1.1" android:textColor="?android:textColorPrimary" android:textSize="?attr/status_text_medium" + android:textDirection="firstStrong" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintWidth_percent=".3" @@ -31,10 +32,11 @@ android:lineSpacingMultiplier="1.1" android:textIsSelectable="true" android:textSize="?attr/status_text_medium" + android:textDirection="firstStrong" app:layout_constrainedWidth="true" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/accountFieldName" app:layout_constraintTop_toTopOf="parent" tools:text="Field content. This can contain links and custom emojis" /> - \ No newline at end of file + From ce6a350267d5ccdc16a8d1dca59c3a948bb70737 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 5 Mar 2023 16:56:19 +0100 Subject: [PATCH 118/418] chore(deps): update dependency gradle to v8.0.2 (#3372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fc10b601..bdc9a83b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 4d401c787858f7dbe2f799591f93c361b3b1d5a0 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 10 Mar 2023 20:12:33 +0100 Subject: [PATCH 119/418] Convert NotificationsFragment and related code to Kotlin, use the Paging library (#3159) * Unmodified output from "Convert Java to Kotlin" on NotificationsFragment.java * Bare minimum changes to get this to compile and run - Use `lateinit` for `eventhub`, `adapter`, `preferences`, and `scrolllistener` - Removed override for accountManager, it can be used from the superclass - Add `?.` where non-nullity could not (yet) be guaranteed - Remove `?` from type lists where non-nullity is guaranteed - Explicitly convert lists to mutable where necessary - Delete unused function `findReplyPosition` * Remove all unnecessary non-null (!!) assertions The previous change meant some values are no longer nullable. Remove the non-null assertions. * Lint ListStatusAccessibilityDelegate call - Remove redundant constructor - Move block outside of `()` * Use `let` when handling compose button visibility on scroll * Replace a `requireNonNull` with `!!` * Remove redundant return values * Remove or rename unused lambda parameters * Remove unnecessary type parameters * Remove unnecessary null checks * Replace cascading-if statement with `when` * Simplify calculation of `topId` * Use more appropriate list properties and methods - Access the last value with `.last()` - Access the last index with `.lastIndex` - Replace logical-chain with `asRightOrNull` and `?.` - `.isNotEmpty()`, not `!...isEmpty()` * Inline unnecessary variable * Use PrefKeys constants instead of bare strings * Use `requireContext()` instead of `context!!` * Replace deprecated `onActivityCreated()` with `onViewCreated()` * Remove unnecessary variable setting * Replace `size == 0` check with `isEmpty()` * Format with ktlint, no functionality changes * Convert NotifcationsAdapter to Kotlin Does not compile, this is the unchanged output of the "Convert to Kotlin" function * Minimum changes to get NotificationsAdapter to compile * Remove unnecessary visibility modifiers * Use `isNotEmpty()` * Remove unused lambda parameters * Convert cascading-if to `when` * Simplifiy assignment op * Use explicit argument names with `copy()` * Use `.firstOrNull()` instead of `if` * Mark as lateinit to avoid unnecessary null checks * Format with ktlint, whitespace changes only * Bare minimum necessary to demonstrate paging in notifications Create `NotificationsPagingSource`. This uses a new `notifications2()` API call, which will exist until all the code has been adapted. Instead of using placeholders, Create `NotificationsPagingAdapter` (will replace `NotificationsAdapater`) to consume this data. Expose the paging source view a new `NotificationsViewModel` `flow`, and submit new pages to the adapter as they are available in `NotificationsFragment`. Comment out any other code in `NotificationsFragment` that deals with loading data from the network. This will be updated as necessary, either here, or in the view model. Lots of functionality is missing, including: - Different views for different notification types - Starting at the remembered notification position - Interacting with notifications - Adjusting the UI state to match the loading state These will be added incrementally. * Migrate StatusNotificationViewHolder impl. to NotificationsPagingAdapter With this change `NotificationsPagingAdapter` shows notifications about a status correctly. - Introduce a `ViewHolder` abstract class that all Notification view holders derive from. Modify the fallback view holder to use this. - Implement `StatusNotificationViewHolder`. Much of the code is from the existing implementation in the `NotificationAdapater`. - The original code split the code that binds values to views between the adapter's `bindViewHolder` method and the view holder's methods. In this code, all of the binding code is in the view holder, in a `bind` method. This is called by the adapter's `bindViewHolder` method. This keeps all the binding logic in the view holder, where it belongs. - The new `StatusNotificationViewHolder` uses view binding to access its views instead of `findViewById`. - Logically, information about whether to show sensitive media, or open content warnings should be part of the `StatusDisplayOptions`. So add those as fields, and populate them appropriately. This affects code outside notification handling, which will be adjusted later. * Note some TODOs to complete before the PR is finished * Extract StatusNotificationViewHolder to a new file * Add TODO for NotificationViewData.Concrete * Convert the adapter to take NotificationViewData.Concrete * Add a view holder for regular status notifications * Migrate Follow and FollowRequest notifications * Migrate report notifications * Convert onViewThread to use the adapter data * Convert onViewMedia to use the adapter data * Convert onMore to use the adapter data * Convert onReply to use the adapter data * Convert NotificationViewData to Kotlin * Re-implement the reblog functionality - Move reblogging in to the view model - Update the UI via the adapter's `snapshot()` and `notifyItemChanged()` methods * Re-implement the favourite functionality Same approach as reblog * Re-implement the bookmark functionality Same approach as reblog * Add TODO re StatusActionListener interface * Add TODO re event handling * Re-implementing the voting functionality * Re-implement viewing hidden content - Hidden media - Content behind a content warning * Add a TODO re pinning * Re-implement "Show more" / "Show less" * Delete unused updateStatus() function * Comment out the scroll listener for the moment * Re-implement applying filters to notifications Introduce `NotificationsRepository`, to provide access to the notifications stream. When changing the filters the flow is as follows: - User clicks "Apply" in the fragment. - Fragment calls `viewModel.accept()` with a `UiAction.ApplyFilter` (new class). - View model maintains a private flow of incoming UI actions. The new action is emitted to that flow. - In view model, `notificationFilter` waits for `.ApplyFilter` actions, and ensures the filter is saved, then emits it. - In view model, `pagingDataFlow` waits for new items from `notificationsFilter` and fetches the notifications from the repository in response. The repository provides `Notification`, so the model maps them to `NotificationViewData.Concrete` for display by the adapter. - In view model the UI state also waits for new items from `notificationsFilter` and emits a new `UiState` every time the filter is changed. When opening the fragment for the first time: - All of the above machinery, but `notificationFilter` also fetches the filter from the active account and emits that first. This triggers the first fetch and the first update of `uiState`. Also: - Add TODOs for functionality that is not implemented yet - Delete a lot of dead code from NotificationsFragment * Include important preference values in `uiState` Listen to the flow of eventHub events, filtered to preference changes that are relevant to the notification view. When preferences change (or when the view model starts), fetch the current values, and include them in `uiState`. Remove preference handling from `NotificationsFragment`, and just use the values from `uiState`. Adjust how the `useAbsoluteTime` preference is handled. The previous code loaded new content (via a diffutil) in to the adapter, which would trigger a re-binding of the timestamp. As the adapter content is immutable, the new code simply triggers a re-binding of the views that are currently visible on screen. * Update UI in response to different load states Notifications can be loaded at the top and bottom of the timeline. Add a new layout to show the progress of these loads, and any errors that can occur. Catch network errors in `NotificationsPagingSource` and convert to `LoadState.Error`. Add a header/footer to the notifications list to show the load state. Collect the load state from the adapter, use this to drive the visibility of different views. * Save and restore the last read notification ID Use this when fetching notifications, to centre the list around the notification that was last read. * Call notifyItemRangeChanged with the correct parameters * Don't try and save list position if there are no items in the list * Show/hide the "Nothing to see" view appropriately * Update comments * Handle the case where the notification key no longer exists * Re-implement support for showMediaPreview and other settings * Re-implement "hide FAB when scrolling" preference * Delete dead code * Delete Notifications Adapater and Placeholder types * Remove NotificationViewData.Concrete subclass Now there's no Placeholder, everything is a NotificationViewData. * Improve how notification pages are loaded if the first notification is missing or filtered * Re-implement clear notifications, show errors * s/default/from/ * Add missing headers * Don't process bookmarking via EventHub - Initiating a bookmark is triggered by the fragment sending a StatusUiAction.Bookmark - View model receives this, makes API call, waits for response, emits either a success or failure state - Fragment collects success/failure states, updates the UI accordingly * Don't process favourites via EventHub * Don't process reblog via EventHub * Don't process poll votes with EventHub This removes EventHub from the fragment * Respond to follow requests via the view model * Docs and cleanup * Typo and editing pass * Minor edits for clarity * Remove newline in diagram * Reorder sequence diagram * s/authorize/accept/ * s/pagingDataFlow/pagingData/ * Add brief KDoc * Try and fetch a full first page of notifications * Call the API method `notifications` again * Log UI errors at the point of handling * Remove unused variable * Replace String.format() with interpolation * Convert NotificationViewData to data class * Rename copy() to make(), to avoid confusion with default copy() method * Lint * Update app/src/main/res/layout/simple_list_item_1.xml * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt * Update app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt * Update app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt * Initial NotificationsViewModel tests * Add missing import * More tests, some cleanup * Comments, re-order some code * Set StateRestorationPolicy.PREVENT_WHEN_EMPTY * Mark clearNotifications() as "suspend" * Catch exceptions from clearNotifications and emit * Update TODOs with explanations * Ensure initial fetch uses a null ID * Stop/start collecting pagingData based on the lifecycle * Don't hide the list while refreshing * Refresh notifications on mutes and blocks * Update tests now clearNotifications is a suspend fun * Add "Refresh" menu to NotificationsFragment * Use account.name over account.displayName * Update app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.kt Co-authored-by: Konrad Pozniak * Mark layoutmanager as lateinit * Mark layoutmanager as lateinit * Refactor generating UI text * Add Copyright header * Correctly apply notification filters * Show follow request header in notifications * Wait for follow request actions to complete, so the reqeuest is sent * Remove duplicate copyright header * Revert copyright change in unmodified file * Null check response body * Move NotificationsFragment to component.notifications * Use viewlifecycleowner.lifecyclescope * Show notification filter as a dialog rather than a popup window The popup window: - Is inconsistent UI - Requires a custom layout - Didn't play nicely with viewbinding * Refresh adapter on block/mute * Scroll up slightly when new content is loaded * Restore progressbar * Lint * Update app/src/main/res/layout/simple_list_item_1.xml --------- Co-authored-by: Konrad Pozniak --- app/build.gradle | 2 + .../java/com/keylesspalace/tusky/TabData.kt | 2 +- .../tusky/adapter/FollowRequestViewHolder.kt | 47 +- .../tusky/adapter/NotificationsAdapter.java | 691 --------- .../adapter/ReportNotificationViewHolder.kt | 75 +- .../tusky/adapter/StatusViewHolder.java | 4 +- .../adapter/FollowRequestsAdapter.kt | 10 +- .../conversation/ConversationsFragment.kt | 4 +- .../notifications/FollowViewHolder.kt | 100 ++ .../notifications/NotificationsFragment.kt | 681 +++++++++ .../NotificationsLoadStateAdapter.kt | 38 + .../NotificationsLoadStateViewHolder.kt | 73 + .../NotificationsPagingAdapter.kt | 209 +++ .../NotificationsPagingSource.kt | 184 +++ .../notifications/NotificationsRepository.kt | 74 + .../notifications/NotificationsViewModel.kt | 522 +++++++ .../notifications/PushNotificationHelper.kt | 2 +- .../StatusNotificationViewHolder.kt | 385 +++++ .../notifications/StatusViewHolder.kt | 60 + .../fragments/ReportStatusesFragment.kt | 4 +- .../fragments/SearchStatusesFragment.kt | 8 +- .../components/timeline/TimelineFragment.kt | 10 +- .../viewthread/ViewThreadFragment.kt | 4 +- .../keylesspalace/tusky/db/AccountEntity.kt | 5 + .../tusky/di/FragmentBuildersModule.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 23 + .../tusky/entity/Notification.kt | 56 +- .../tusky/fragment/NotificationsFragment.java | 1273 ----------------- .../tusky/network/MastodonApi.kt | 24 +- .../tusky/usecase/TimelineCases.kt | 9 + .../tusky/util/StatusDisplayOptions.kt | 106 +- .../keylesspalace/tusky/util/ViewDataUtils.kt | 21 +- .../tusky/viewdata/NotificationViewData.java | 138 -- .../tusky/viewdata/NotificationViewData.kt | 43 + .../tusky/viewdata/StatusViewData.kt | 15 - .../fragment_timeline_notifications.xml | 19 +- .../fragment_timeline_notifications.xml | 17 + ...m_notifications_load_state_footer_view.xml | 45 + .../main/res/layout/notifications_filter.xml | 19 - .../main/res/layout/simple_list_item_1.xml | 27 + .../main/res/menu/fragment_notifications.xml | 17 + app/src/main/res/values/strings.xml | 35 + .../com/keylesspalace/tusky/FilterTest.kt | 148 +- .../NotificationsViewModelTestBase.kt | 137 ++ ...icationsViewModelTestClearNotifications.kt | 65 + .../NotificationsViewModelTestFilter.kt | 66 + ...icationsViewModelTestNotificationAction.kt | 144 ++ .../NotificationsViewModelTestStatusAction.kt | 227 +++ ...ationsViewModelTestStatusDisplayOptions.kt | 102 ++ .../NotificationsViewModelTestUiState.kt | 88 ++ .../NotificationsViewModelTestVisibleId.kt | 43 + doc/ViewModelInterface.md | 615 ++++++++ gradle/libs.versions.toml | 4 + 53 files changed, 4460 insertions(+), 2262 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java delete mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt create mode 100644 app/src/main/res/layout/item_notifications_load_state_footer_view.xml delete mode 100644 app/src/main/res/layout/notifications_filter.xml create mode 100644 app/src/main/res/layout/simple_list_item_1.xml create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt create mode 100644 doc/ViewModelInterface.md diff --git a/app/build.gradle b/app/build.gradle index d1259f72..2ccfcebf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,8 @@ dependencies { testImplementation libs.androidx.core.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing + testImplementation libs.truth + testImplementation libs.turbine androidTestImplementation libs.espresso.core androidTestImplementation libs.androidx.room.testing diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 43e59500..09d1f1cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.components.trending.TrendingFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 7e675de3..b12b7170 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -21,18 +21,41 @@ import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible +import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, + private val accountActionListener: AccountActionListener, private val showHeader: Boolean -) : RecyclerView.ViewHolder(binding.root) { +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithAccount( + viewData.account, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis, + statusDisplayOptions.showBotOverlay + ) + + setupActionListener(accountActionListener, viewData.account.id) + } fun setupWithAccount( account: TimelineAccount, @@ -41,18 +64,32 @@ class FollowRequestViewHolder( showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() - val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) + val emojifiedName: CharSequence = wrappedName.emojify( + account.emojis, + itemView, + animateEmojis + ) binding.displayNameTextView.text = emojifiedName if (showHeader) { - val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) + val wholeMessage: String = itemView.context.getString( + R.string.notification_follow_request_format, + wrappedName + ) binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + StyleSpan(Typeface.BOLD), + 0, + wrappedName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) }.emojify(account.emojis, itemView, animateEmojis) } binding.notificationTextView.visible(showHeader) val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) binding.usernameTextView.text = formattedUsername - val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) binding.avatarBadge.visible(showBotOverlay && account.bot) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java deleted file mode 100644 index 87096232..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ /dev/null @@ -1,691 +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.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.text.InputFilter; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.StyleSpan; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.ColorRes; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; -import com.keylesspalace.tusky.entity.Emoji; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.entity.TimelineAccount; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.LinkListener; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.CustomEmojiHelper; -import com.keylesspalace.tusky.util.ImageLoadingHelper; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.SmartLengthInputFilter; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.StringUtils; -import com.keylesspalace.tusky.util.TimestampUtils; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.util.Date; -import java.util.List; - -import at.connyduck.sparkbutton.helpers.Utils; - -public class NotificationsAdapter extends RecyclerView.Adapter { - - public interface AdapterDataSource { - int getItemCount(); - - T getItemAt(int pos); - } - - - private static final int VIEW_TYPE_STATUS = 0; - private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; - private static final int VIEW_TYPE_FOLLOW = 2; - private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; - private static final int VIEW_TYPE_PLACEHOLDER = 4; - private static final int VIEW_TYPE_REPORT = 5; - private static final int VIEW_TYPE_UNKNOWN = 6; - - private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; - private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; - - private final String accountId; - private StatusDisplayOptions statusDisplayOptions; - private final StatusActionListener statusListener; - private final NotificationActionListener notificationActionListener; - private final AccountActionListener accountActionListener; - private final AdapterDataSource dataSource; - private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); - - public NotificationsAdapter(String accountId, - AdapterDataSource dataSource, - StatusDisplayOptions statusDisplayOptions, - StatusActionListener statusListener, - NotificationActionListener notificationActionListener, - AccountActionListener accountActionListener) { - - this.accountId = accountId; - this.dataSource = dataSource; - this.statusDisplayOptions = statusDisplayOptions; - this.statusListener = statusListener; - this.notificationActionListener = notificationActionListener; - this.accountActionListener = accountActionListener; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - switch (viewType) { - case VIEW_TYPE_STATUS: { - View view = inflater - .inflate(R.layout.item_status, parent, false); - return new StatusViewHolder(view); - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - View view = inflater - .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); - } - case VIEW_TYPE_FOLLOW: { - View view = inflater - .inflate(R.layout.item_follow, parent, false); - return new FollowViewHolder(view, statusDisplayOptions); - } - case VIEW_TYPE_FOLLOW_REQUEST: { - ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); - return new FollowRequestViewHolder(binding, true); - } - case VIEW_TYPE_PLACEHOLDER: { - View view = inflater - .inflate(R.layout.item_status_placeholder, parent, false); - return new PlaceholderViewHolder(view); - } - case VIEW_TYPE_REPORT: { - ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); - return new ReportNotificationViewHolder(binding); - } - default: - case VIEW_TYPE_UNKNOWN: { - View view = new View(parent.getContext()); - view.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - Utils.dpToPx(parent.getContext(), 24) - ) - ); - return new RecyclerView.ViewHolder(view) { - }; - } - } - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { - bindViewHolder(viewHolder, position, null); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { - bindViewHolder(viewHolder, position, payloads); - } - - private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { - Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; - if (position < this.dataSource.getItemCount()) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Placeholder) { - if (payloadForHolder == null) { - NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); - PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; - holder.setup(statusListener, placeholder.isLoading()); - } - return; - } - NotificationViewData.Concrete concreteNotification = - (NotificationViewData.Concrete) notification; - switch (viewHolder.getItemViewType()) { - case VIEW_TYPE_STATUS: { - StatusViewHolder holder = (StatusViewHolder) viewHolder; - 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 */ - holder.showStatusContent(false); - } else { - if (payloads == null) { - holder.showStatusContent(true); - } - holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); - } - if (concreteNotification.getType() == Notification.Type.POLL) { - holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); - } else { - holder.hideStatusInfo(); - } - break; - } - case VIEW_TYPE_STATUS_NOTIFICATION: { - StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; - 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, - * we have to handle it somehow */ - holder.showNotificationContent(false); - } else { - holder.showNotificationContent(true); - - Status status = statusViewData.getActionable(); - holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); - holder.setUsername(status.getAccount().getUsername()); - holder.setCreatedAt(status.getCreatedAt()); - - 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(), - concreteNotification.getAccount().getAvatar()); - } - } - - holder.setMessage(concreteNotification, statusListener); - holder.setupButtons(notificationActionListener, - concreteNotification.getAccount().getId(), - concreteNotification.getId()); - } else { - if (payloadForHolder instanceof List) - for (Object item : (List) payloadForHolder) { - if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { - holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); - } - } - } - break; - } - case VIEW_TYPE_FOLLOW: { - if (payloadForHolder == null) { - FollowViewHolder holder = (FollowViewHolder) viewHolder; - 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(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); - holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); - } - break; - } - case VIEW_TYPE_REPORT: { - if (payloadForHolder == null) { - ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; - holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); - holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); - } - } - default: - } - } - } - - @Override - public int getItemCount() { - return dataSource.getItemCount(); - } - - public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { - this.statusDisplayOptions = statusDisplayOptions.copy( - statusDisplayOptions.animateAvatars(), - mediaPreviewEnabled, - statusDisplayOptions.useAbsoluteTime(), - statusDisplayOptions.showBotOverlay(), - statusDisplayOptions.useBlurhash(), - CardViewMode.NONE, - statusDisplayOptions.confirmReblogs(), - statusDisplayOptions.confirmFavourites(), - statusDisplayOptions.hideStats(), - statusDisplayOptions.animateEmojis() - ); - } - - public boolean isMediaPreviewEnabled() { - return this.statusDisplayOptions.mediaPreviewEnabled(); - } - - @Override - public int getItemViewType(int position) { - NotificationViewData notification = dataSource.getItemAt(position); - if (notification instanceof NotificationViewData.Concrete) { - NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); - switch (concrete.getType()) { - case MENTION: - case POLL: { - return VIEW_TYPE_STATUS; - } - case STATUS: - case FAVOURITE: - case REBLOG: - case UPDATE: { - return VIEW_TYPE_STATUS_NOTIFICATION; - } - case FOLLOW: - case SIGN_UP: { - return VIEW_TYPE_FOLLOW; - } - case FOLLOW_REQUEST: { - return VIEW_TYPE_FOLLOW_REQUEST; - } - case REPORT: { - return VIEW_TYPE_REPORT; - } - default: { - return VIEW_TYPE_UNKNOWN; - } - } - } else if (notification instanceof NotificationViewData.Placeholder) { - return VIEW_TYPE_PLACEHOLDER; - } else { - throw new AssertionError("Unknown notification type"); - } - - - } - - public interface NotificationActionListener { - void onViewAccount(String id); - - void onViewStatusForNotificationId(String notificationId); - - void onViewReport(String reportId); - - void onExpandedChange(boolean expanded, int position); - - /** - * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - void onNotificationContentCollapsedChange(boolean isCollapsed, int position); - } - - private static class FollowViewHolder extends RecyclerView.ViewHolder { - private final TextView message; - private final TextView usernameView; - private final TextView displayNameView; - private final ImageView avatar; - private final StatusDisplayOptions statusDisplayOptions; - - FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { - super(itemView); - message = itemView.findViewById(R.id.notification_text); - usernameView = itemView.findViewById(R.id.notification_username); - displayNameView = itemView.findViewById(R.id.notification_display_name); - avatar = itemView.findViewById(R.id.notification_avatar); - this.statusDisplayOptions = statusDisplayOptions; - } - - void setMessage(TimelineAccount account, Boolean isSignUp) { - Context context = message.getContext(); - - String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); - String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); - String wholeMessage = String.format(format, wrappedDisplayName); - CharSequence emojifiedMessage = CustomEmojiHelper.emojify( - wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedMessage); - - String username = context.getString(R.string.post_username_format, account.getUsername()); - usernameView.setText(username); - - CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( - wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() - ); - - displayNameView.setText(emojifiedDisplayName); - - int avatarRadius = avatar.getContext().getResources() - .getDimensionPixelSize(R.dimen.avatar_radius_42dp); - - ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - - } - - void setupButtons(final NotificationActionListener listener, final String accountId) { - itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); - } - } - - private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder - implements View.OnClickListener { - private final TextView message; - private final View statusNameBar; - private final TextView displayName; - private final TextView username; - private final TextView timestampInfo; - private final TextView statusContent; - private final ImageView statusAvatar; - private final ImageView notificationAvatar; - private final TextView contentWarningDescriptionTextView; - private final Button contentWarningButton; - private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder - private final StatusDisplayOptions statusDisplayOptions; - private final AbsoluteTimeFormatter absoluteTimeFormatter; - - private String accountId; - private String notificationId; - private NotificationActionListener notificationActionListener; - private StatusViewData.Concrete statusViewData; - - private final int avatarRadius48dp; - private final int avatarRadius36dp; - private final int avatarRadius24dp; - - StatusNotificationViewHolder( - View itemView, - StatusDisplayOptions statusDisplayOptions, - AbsoluteTimeFormatter absoluteTimeFormatter - ) { - super(itemView); - message = itemView.findViewById(R.id.notification_top_text); - statusNameBar = itemView.findViewById(R.id.status_name_bar); - displayName = itemView.findViewById(R.id.status_display_name); - username = itemView.findViewById(R.id.status_username); - timestampInfo = itemView.findViewById(R.id.status_meta_info); - statusContent = itemView.findViewById(R.id.notification_content); - statusAvatar = itemView.findViewById(R.id.notification_status_avatar); - notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); - contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); - contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); - contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); - this.statusDisplayOptions = statusDisplayOptions; - this.absoluteTimeFormatter = absoluteTimeFormatter; - - int darkerFilter = Color.rgb(123, 123, 123); - statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); - - itemView.setOnClickListener(this); - message.setOnClickListener(this); - statusContent.setOnClickListener(this); - - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); - this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); - this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); - } - - private void showNotificationContent(boolean show) { - statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); - statusContent.setVisibility(show ? View.VISIBLE : View.GONE); - statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); - } - - private void setDisplayName(String name, List emojis) { - CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); - displayName.setText(emojifiedName); - } - - private void setUsername(String name) { - Context context = username.getContext(); - String format = context.getString(R.string.post_username_format); - String usernameText = String.format(format, name); - username.setText(usernameText); - } - - protected void setCreatedAt(@Nullable Date createdAt) { - if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); - } else { - // This is the visible timestampInfo. - String readout; - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - CharSequence readoutAloud; - if (createdAt != null) { - long then = createdAt.getTime(); - long now = new Date().getTime(); - readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); - readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, - android.text.format.DateUtils.SECOND_IN_MILLIS, - android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); - } else { - // unknown minutes~ - readout = "?m"; - readoutAloud = "? minutes"; - } - timestampInfo.setText(readout); - timestampInfo.setContentDescription(readoutAloud); - } - } - - Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { - Drawable icon = ContextCompat.getDrawable(context, drawable); - if (icon != null) { - icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); - } - return icon; - } - - void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { - this.statusViewData = notificationViewData.getStatusViewData(); - - String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); - Notification.Type type = notificationViewData.getType(); - - Context context = message.getContext(); - String format; - Drawable icon; - switch (type) { - default: - case FAVOURITE: { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); - format = context.getString(R.string.notification_favourite_format); - break; - } - case REBLOG: { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_reblog_format); - break; - } - case STATUS: { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_subscription_format); - break; - } - case UPDATE: { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); - format = context.getString(R.string.notification_update_format); - break; - } - } - message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); - String wholeMessage = String.format(format, displayName); - final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); - int displayNameIndex = format.indexOf("%s"); - str.setSpan( - new StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); - CharSequence emojifiedText = CustomEmojiHelper.emojify( - str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() - ); - message.setText(emojifiedText); - - if (statusViewData != null) { - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); - contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); - if (statusViewData.isExpanded()) { - contentWarningButton.setText(R.string.post_content_warning_show_less); - } else { - contentWarningButton.setText(R.string.post_content_warning_show_more); - } - - contentWarningButton.setOnClickListener(view -> { - if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); - } - statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); - }); - - setupContentAndSpoiler(listener); - } - - } - - void setupButtons(final NotificationActionListener listener, final String accountId, - final String notificationId) { - this.notificationActionListener = listener; - this.accountId = accountId; - this.notificationId = notificationId; - } - - void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { - statusAvatar.setPaddingRelative(0, 0, 0, 0); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars()); - - if (statusDisplayOptions.showBotOverlay() && isBot) { - notificationAvatar.setVisibility(View.VISIBLE); - Glide.with(notificationAvatar) - .load(R.drawable.bot_badge) - .into(notificationAvatar); - - } else { - notificationAvatar.setVisibility(View.GONE); - } - } - - void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { - int padding = Utils.dpToPx(statusAvatar.getContext(), 12); - statusAvatar.setPaddingRelative(0, 0, padding, padding); - - ImageLoadingHelper.loadAvatar(statusAvatarUrl, - statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars()); - - notificationAvatar.setVisibility(View.VISIBLE); - ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, - avatarRadius24dp, statusDisplayOptions.animateAvatars()); - } - - @Override - public void onClick(View v) { - switch (v.getId()) { - case R.id.notification_container: - case R.id.notification_content: - if (notificationActionListener != null) - notificationActionListener.onViewStatusForNotificationId(notificationId); - break; - case R.id.notification_top_text: - if (notificationActionListener != null) - notificationActionListener.onViewAccount(accountId); - break; - } - } - - private void setupContentAndSpoiler(final LinkListener listener) { - - boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); - boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); - if (!shouldShowContentIfSpoiler && hasSpoiler) { - statusContent.setVisibility(View.GONE); - } else { - statusContent.setVisibility(View.VISIBLE); - } - - Spanned content = statusViewData.getContent(); - List emojis = statusViewData.getActionable().getEmojis(); - - if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { - contentCollapseButton.setOnClickListener(view -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { - notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); - } - }); - - contentCollapseButton.setVisibility(View.VISIBLE); - if (statusViewData.isCollapsed()) { - contentCollapseButton.setText(R.string.post_content_warning_show_more); - statusContent.setFilters(COLLAPSE_INPUT_FILTER); - } else { - contentCollapseButton.setText(R.string.post_content_warning_show_less); - statusContent.setFilters(NO_INPUT_FILTER); - } - } else { - contentCollapseButton.setVisibility(View.GONE); - statusContent.setFilters(NO_INPUT_FILTER); - } - - CharSequence emojifiedText = CustomEmojiHelper.emojify( - content, emojis, statusContent, statusDisplayOptions.animateEmojis() - ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); - - CharSequence emojifiedContentWarning; - if (statusViewData.getSpoilerText() != null) { - emojifiedContentWarning = CustomEmojiHelper.emojify( - statusViewData.getSpoilerText(), - statusViewData.getActionable().getEmojis(), - contentWarningDescriptionTextView, - statusDisplayOptions.animateEmojis() - ); - } else { - emojifiedContentWarning = ""; - } - contentWarningDescriptionTextView.setText(emojifiedContentWarning); - } - - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index db2f79a9..d4712159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,28 +20,76 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationActionListener +import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, -) : RecyclerView.ViewHolder(binding.root) { + private val notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - fun setupWithReport(reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean) { - val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, itemView, animateEmojis) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, itemView, animateEmojis) - val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setupWithReport( + viewData.account, + viewData.report!!, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupActionListener( + notificationActionListener, + viewData.report.targetAccount.id, + viewData.account.id, + viewData.report.id + ) + } + + private fun setupWithReport( + reporter: TimelineAccount, + report: Report, + animateAvatar: Boolean, + animateEmojis: Boolean + ) { + val reporterName = reporter.name.unicodeWrap().emojify( + reporter.emojis, + binding.root, + animateEmojis + ) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify( + report.targetAccount.emojis, + itemView, + animateEmojis + ) + val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), report.status_ids?.size ?: 0) + binding.notificationTopText.text = itemView.context.getString( + R.string.notification_header_report_format, + reporterName, + reporteeName + ) + binding.notificationSummary.text = itemView.context.getString( + R.string.notification_summary_report_format, + getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), + report.status_ids?.size ?: 0 + ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -52,17 +100,22 @@ class ReportNotificationViewHolder( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), - animateAvatar, + animateAvatar ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), - animateAvatar, + animateAvatar ) } - fun setupActionListener(listener: NotificationActionListener, reporteeId: String, reporterId: String, reportId: String) { + private fun setupActionListener( + listener: NotificationActionListener, + reporteeId: String, + reporterId: String, + reportId: String + ) { binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 18a669a1..b1881272 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -93,7 +93,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } // don't use this on the same ViewHolder as setRebloggedByDisplayName, will cause recycling issues as paddings are changed - void setPollInfo(final boolean ownPoll) { + protected void setPollInfo(final boolean ownPoll) { statusInfo.setText(ownPoll ? R.string.poll_ended_created : R.string.poll_ended_voted); statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0); statusInfo.setCompoundDrawablePadding(Utils.dpToPx(statusInfo.getContext(), 10)); @@ -101,7 +101,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { statusInfo.setVisibility(View.VISIBLE); } - void hideStatusInfo() { + protected void hideStatusInfo() { statusInfo.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index 35a59a8e..ab20d748 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -35,8 +35,14 @@ class FollowRequestsAdapter( ) { override fun createAccountViewHolder(parent: ViewGroup): FollowRequestViewHolder { - val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FollowRequestViewHolder(binding, false) + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return FollowRequestViewHolder( + binding, + accountActionListener, + showHeader = false + ) } override fun onBindAccountViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index fc5cebe6..9ce9604b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -110,7 +110,9 @@ class ConversationsFragment : 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) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ConversationAdapter(statusDisplayOptions, this) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt new file mode 100644 index 00000000..ca19455b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 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.notifications + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData + +class FollowViewHolder( + private val binding: ItemFollowBinding, + private val notificationActionListener: NotificationActionListener, +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + // Skip updates with payloads. That indicates a timestamp update, and + // this view does not have timestamps. + if (!payloads.isNullOrEmpty()) return + + setMessage( + viewData.account, + viewData.type === Notification.Type.SIGN_UP, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.animateEmojis + ) + setupButtons(notificationActionListener, viewData.account.id) + } + + private fun setMessage( + account: TimelineAccount, + isSignUp: Boolean, + animateAvatars: Boolean, + animateEmojis: Boolean + ) { + val context = binding.notificationText.context + val format = + context.getString( + if (isSignUp) { + R.string.notification_sign_up_format + } else { + R.string.notification_follow_format + } + ) + val wrappedDisplayName = account.name.unicodeWrap() + val wholeMessage = String.format(format, wrappedDisplayName) + val emojifiedMessage = + wholeMessage.emojify( + account.emojis, + binding.notificationText, + animateEmojis + ) + binding.notificationText.text = emojifiedMessage + val username = context.getString(R.string.post_username_format, account.username) + binding.notificationUsername.text = username + val emojifiedDisplayName = wrappedDisplayName.emojify( + account.emojis, + binding.notificationUsername, + animateEmojis + ) + binding.notificationDisplayName.text = emojifiedDisplayName + loadAvatar( + account.avatar, + binding.notificationAvatar, + avatarRadius42dp, + animateAvatars + ) + } + + private fun setupButtons(listener: NotificationActionListener, accountId: String) { + binding.root.setOnClickListener { listener.onViewAccount(accountId) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt new file mode 100644 index 00000000..b79156eb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -0,0 +1,681 @@ +/* + * Copyright 2023 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.notifications + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.MenuProvider +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.recyclerview.widget.DiffUtil +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.OnRefreshListener +import at.connyduck.sparkbutton.helpers.Utils +import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior +import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list +import com.keylesspalace.tusky.viewdata.NotificationViewData +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.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.io.IOException +import javax.inject.Inject + +class NotificationsFragment : + SFragment(), + StatusActionListener, + NotificationActionListener, + AccountActionListener, + OnRefreshListener, + MenuProvider, + Injectable, + ReselectableFragment { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) + + private lateinit var adapter: NotificationsPagingAdapter + + private lateinit var layoutManager: LinearLayoutManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + adapter = NotificationsPagingAdapter( + notificationDiffCallback, + accountId = accountManager.activeAccount!!.accountId, + statusActionListener = this, + notificationActionListener = this, + accountActionListener = this, + statusDisplayOptions = viewModel.statusDisplayOptions.value + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) + } + + private fun updateFilterVisibility(showFilter: Boolean) { + val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams + if (showFilter) { + binding.appBarOptions.setExpanded(true, false) + binding.appBarOptions.visibility = View.VISIBLE + // Set content behaviour to hide filter on scroll + params.behavior = ScrollingViewBehavior() + } else { + binding.appBarOptions.setExpanded(false, false) + binding.appBarOptions.visibility = View.GONE + // Clear behaviour to hide app bar + params.behavior = null + } + } + + private fun confirmClearNotifications() { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this) + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager + binding.recyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate( + binding.recyclerView, + this + ) { pos: Int -> + val notification = adapter.snapshot()[pos] + // We support replies only for now + if (notification is NotificationViewData) { + notification.statusViewData + } else { + null + } + } + ) + binding.recyclerView.addItemDecoration( + DividerItemDecoration( + context, + DividerItemDecoration.VERTICAL + ) + ) + + binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + val actionButton = (activity as ActionButtonActivity).actionButton + + override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { + actionButton?.let { fab -> + if (!viewModel.uiState.value.showFabWhileScrolling) { + if (dy > 0 && fab.isShown) { + fab.hide() // Hide when scrolling down + } else if (dy < 0 && !fab.isShown) { + fab.show() // Show when scrolling up + } + } else if (!fab.isShown) { + fab.show() + } + } + } + }) + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = NotificationsLoadStateAdapter { adapter.retry() }, + footer = NotificationsLoadStateAdapter { adapter.retry() } + ) + + binding.buttonClear.setOnClickListener { confirmClearNotifications() } + binding.buttonFilter.setOnClickListener { showFilterDialog() } + (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = + false + + // Signal the user that a refresh has loaded new items above their current position + // by scrolling up slightly to disclose the new content + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && adapter.itemCount != itemCount) { + binding.recyclerView.post { + binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + } + } + } + }) + + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + val updateTimestampFlow = flow { + while (true) { delay(60000); emit(Unit) } + }.onEach { + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.pagingData.collectLatest { pagingData -> + Log.d(TAG, "Submitting data to adapter") + adapter.submitData(pagingData) + } + } + + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.exception.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + val snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + action is StatusAction || return@let + + val position = adapter.snapshot().indexOfFirst { + it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + } + if (position != RecyclerView.NO_POSITION) { + adapter.notifyItemChanged(position) + } + } + } + } + + // Show successful notification action as brief snackbars, so the + // user is clear the action has happened. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + Snackbar.make( + (activity as ActionButtonActivity).actionButton ?: binding.root, + getString(it.msg), + Snackbar.LENGTH_SHORT + ).show() + + when (it) { + // The follow request is no longer valid, refresh the adapter to + // remove it. + is NotificationActionSuccess.AcceptFollowRequest, + is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() + } + } + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { notificationViewData -> + notificationViewData.value?.statusViewData?.status?.id == + it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value?.statusViewData ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + indexedViewData.value?.statusViewData = statusViewData.copy( + status = status + ) + + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> + adapter.refresh() + else -> { /* nothing to do */ + } + } + } + } + + // Update filter option visibility from uiState + launch { + viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } + } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically re-bind the UI. + launch { + viewModel.statusDisplayOptions + .collectLatest { + adapter.statusDisplayOptions = it + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + null + ) + } + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + + // Update the UI from the loadState + adapter.loadStateFlow + .distinctUntilChangedBy { it.refresh } + .collect { loadState -> + binding.recyclerView.isVisible = true + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && + !binding.swipeRefreshLayout.isRefreshing + binding.swipeRefreshLayout.isRefreshing = + loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible + + binding.statusView.isVisible = false + if (loadState.refresh is LoadState.NotLoading) { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } else { + binding.statusView.isVisible = false + } + } + + if (loadState.refresh is LoadState.Error) { + when ((loadState.refresh as LoadState.Error).error) { + is IOException -> { + binding.statusView.setup( + R.drawable.elephant_offline, + R.string.error_network + ) { adapter.retry() } + } + else -> { + binding.statusView.setup( + R.drawable.elephant_error, + R.string.error_generic + ) { adapter.retry() } + } + } + binding.recyclerView.isVisible = false + binding.statusView.isVisible = true + } + } + } + } + } + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu) + menu.findItem(R.id.action_refresh)?.apply { + icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { + sizeDp = 20 + colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) + } + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_refresh -> { + binding.swipeRefreshLayout.isRefreshing = true + onRefresh() + true + } + else -> false + } + } + + override fun onRefresh() { + binding.progressBar.isVisible = false + adapter.refresh() + } + + override fun onPause() { + super.onPause() + + // Save the ID of the first notification visible in the list + val position = layoutManager.findFirstVisibleItemPosition() + if (position >= 0) { + adapter.snapshot()[position]?.id?.let { id -> + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + } + } + } + + override fun onResume() { + super.onResume() + NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager) + } + + override fun onReply(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.reply(status) + } + + override fun onReblog(reblog: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) + } + + override fun onFavourite(favourite: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) + } + + override fun onBookmark(bookmark: Boolean, position: Int) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) + } + + override fun onVoteInPoll(position: Int, choices: List) { + val statusViewData = adapter.peek(position)?.statusViewData ?: return + val poll = statusViewData.status.poll ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) + } + + override fun onMore(view: View, position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.more(status, view, position) + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewMedia(attachmentIndex, list(status), view) + } + + override fun onViewThread(position: Int) { + val status = adapter.peek(position)?.statusViewData?.status ?: return + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onOpenReblog(position: Int) { + val account = adapter.peek(position)?.account!! + onViewAccount(account.id) + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isExpanded = expanded + ) + adapter.notifyItemChanged(position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isShowingContent = isShowing + ) + adapter.notifyItemChanged(position) + } + + override fun onLoadMore(position: Int) { + // Empty -- this fragment doesn't show placeholders + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + val notificationViewData = adapter.snapshot()[position] ?: return + notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( + isCollapsed = isCollapsed + ) + adapter.notifyItemChanged(position) + } + + override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { + onContentCollapsedChange(isCollapsed, position) + } + + private fun clearNotifications() { + binding.swipeRefreshLayout.isRefreshing = false + binding.progressBar.isVisible = false + viewModel.accept(FallibleUiAction.ClearNotifications) + } + + private fun showFilterDialog() { + FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> + if (viewModel.uiState.value.activeFilter != filter) { + viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) + } + } + .show(parentFragmentManager, "dialogFilter") + } + + override fun onViewTag(tag: String) { + super.viewTag(tag) + } + + override fun onViewAccount(id: String) { + super.viewAccount(id) + } + + override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { + adapter.refresh() + } + + override fun onBlock(block: Boolean, id: String, position: Int) { + adapter.refresh() + } + + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + if (accept) { + viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) + } else { + viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) + } + } + + override fun onViewThreadForStatus(status: Status) { + super.viewThread(status.actionableId, status.actionableStatus.url) + } + + override fun onViewReport(reportId: String) { + requireContext().openLink( + "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" + ) + } + + public override fun removeItem(position: Int) { + // Empty -- this fragment doesn't remove items + } + + override fun onReselect() { + if (isAdded) { + binding.appBarOptions.setExpanded(true, false) + layoutManager.scrollToPosition(0) + } + } + + companion object { + private const val TAG = "NotificationF" + fun newInstance() = NotificationsFragment() + + private val notificationDiffCallback: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Boolean { + return false + } + + override fun getChangePayload( + oldItem: NotificationViewData, + newItem: NotificationViewData + ): Any? { + return if (oldItem == newItem) { + // If items are equal - update timestamp only + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + } else { + // If items are different - update a whole view holder + null + } + } + } + } +} + +class FilterDialogFragment( + private val activeFilter: Set, + private val listener: ((filter: Set) -> Unit) +) : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() + val checkedItems = Notification.Type.visibleTypes.map { + !activeFilter.contains(it) + }.toBooleanArray() + + val builder = AlertDialog.Builder(context) + .setTitle(R.string.notifications_apply_filter) + .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> + checkedItems[which] = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + val excludes: MutableSet = HashSet() + for (i in Notification.Type.visibleTypes.indices) { + if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) + } + listener(excludes) + } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + return builder.create() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt new file mode 100644 index 00000000..0a281ccd --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 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.notifications + +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter + +/** Show load state and retry options when loading notifications */ +class NotificationsLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + loadState: LoadState + ): NotificationsLoadStateViewHolder { + return NotificationsLoadStateViewHolder.create(parent, retry) + } + + override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt new file mode 100644 index 00000000..f3c006d3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2023 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.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding +import java.net.SocketTimeoutException + +/** + * Display the header/footer loading state to the user. + * + * Either: + * + * 1. A page is being loaded, display a progress view, or + * 2. An error occurred, display an error message with a "retry" button + * + * @param retry function to invoke if the user clicks the "retry" button + */ +class NotificationsLoadStateViewHolder( + private val binding: ItemNotificationsLoadStateFooterViewBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + val ctx = binding.root.context + binding.errorMsg.text = when (loadState.error) { + is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) + // Other exceptions to consider: + // - UnknownHostException, default text is: + // Unable to resolve "%s": No address associated with hostname + else -> loadState.error.localizedMessage + } + } + binding.progressBar.isVisible = loadState is LoadState.Loading + binding.retryButton.isVisible = loadState is LoadState.Error + binding.errorMsg.isVisible = loadState is LoadState.Error + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { + val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return NotificationsLoadStateViewHolder(binding, retry) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt new file mode 100644 index 00000000..067778e2 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2023 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.notifications + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.FollowRequestViewHolder +import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder +import com.keylesspalace.tusky.databinding.ItemFollowBinding +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.databinding.SimpleListItem1Binding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.AccountActionListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +/** How to present the notification in the UI */ +enum class NotificationViewKind { + /** View as the original status */ + STATUS, + + /** View as the original status, with the interaction type above */ + NOTIFICATION, + FOLLOW, + FOLLOW_REQUEST, + REPORT, + UNKNOWN; + + companion object { + fun from(kind: Notification.Type?): NotificationViewKind { + return when (kind) { + Notification.Type.MENTION, + Notification.Type.POLL, + Notification.Type.UNKNOWN -> STATUS + Notification.Type.FAVOURITE, + Notification.Type.REBLOG, + Notification.Type.STATUS, + Notification.Type.UPDATE -> NOTIFICATION + Notification.Type.FOLLOW, + Notification.Type.SIGN_UP -> FOLLOW + Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST + Notification.Type.REPORT -> REPORT + null -> UNKNOWN + } + } + } +} + +interface NotificationActionListener { + fun onViewAccount(id: String) + fun onViewThreadForStatus(status: Status) + fun onViewReport(reportId: String) + + /** + * Called when the status has a content warning and the visibility of the content behind + * the warning is being changed. + * + * @param expanded the desired state of the content behind the content warning + * @param position the adapter position of the view + * + */ + fun onExpandedChange(expanded: Boolean, position: Int) + + /** + * Called when the status [android.widget.ToggleButton] responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) +} + +class NotificationsPagingAdapter( + diffCallback: DiffUtil.ItemCallback, + /** ID of the the account that notifications are being displayed for */ + private val accountId: String, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val accountActionListener: AccountActionListener, + var statusDisplayOptions: StatusDisplayOptions +) : PagingDataAdapter(diffCallback) { + + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + /** View holders in this adapter must implement this interface */ + interface ViewHolder { + /** Bind the data from the notification and payloads to the view */ + fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) + } + + init { + stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY + } + + override fun getItemViewType(position: Int): Int { + return NotificationViewKind.from(getItem(position)?.type).ordinal + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + + return when (NotificationViewKind.values()[viewType]) { + NotificationViewKind.STATUS -> { + StatusViewHolder( + ItemStatusBinding.inflate(inflater, parent, false), + statusActionListener, + accountId + ) + } + NotificationViewKind.NOTIFICATION -> { + StatusNotificationViewHolder( + ItemStatusNotificationBinding.inflate(inflater, parent, false), + statusActionListener, + notificationActionListener, + absoluteTimeFormatter + ) + } + NotificationViewKind.FOLLOW -> { + FollowViewHolder( + ItemFollowBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + NotificationViewKind.FOLLOW_REQUEST -> { + FollowRequestViewHolder( + ItemFollowRequestBinding.inflate(inflater, parent, false), + accountActionListener, + showHeader = true + ) + } + NotificationViewKind.REPORT -> { + ReportNotificationViewHolder( + ItemReportNotificationBinding.inflate(inflater, parent, false), + notificationActionListener + ) + } + else -> { + FallbackNotificationViewHolder( + SimpleListItem1Binding.inflate(inflater, parent, false) + ) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + bindViewHolder(holder, position, null) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + bindViewHolder(holder, position, payloads) + } + + private fun bindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List<*>? + ) { + getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } + } + + /** + * Notification view holder to use if no other type is appropriate. Should never normally + * be used, but is useful when migrating code. + */ + private class FallbackNotificationViewHolder( + val binding: SimpleListItem1Binding + ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + binding.text1.text = viewData.statusViewData?.content + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt new file mode 100644 index 00000000..44db2308 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2023 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.notifications + +import android.util.Log +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.Headers +import retrofit2.Response +import javax.inject.Inject + +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) + +/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ +class NotificationsPagingSource @Inject constructor( + private val mastodonApi: MastodonApi, + private val notificationFilter: Set +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") + + try { + val response = when (params) { + is LoadParams.Refresh -> { + getInitialPage(params) + } + is LoadParams.Append -> mastodonApi.notifications( + maxId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + is LoadParams.Prepend -> mastodonApi.notifications( + minId = params.key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + if (!response.isSuccessful) { + return LoadResult.Error(Throwable(response.errorBody().toString())) + } + + val links = getPageLinks(response.headers()["link"]) + return LoadResult.Page( + data = response.body()!!, + nextKey = links.next, + prevKey = links.prev + ) + } catch (e: Exception) { + return LoadResult.Error(e) + } + } + + /** + * Fetch the initial page of notifications, using params.key as the ID of the initial + * notification to fetch. + * + * - If there is no key, a page of the most recent notifications is returned + * - If the notification exists, and is not filtered, a page of notifications is returned + * - If the notification does not exist, or is filtered, the page of notifications immediately + * before is returned + * - If there is no page of notifications immediately before then the page immediately after + * is returned + */ + private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { + // If the key is null this is straightforward, just return the most recent notifications. + val key = params.key + ?: return@coroutineScope mastodonApi.notifications( + limit = params.loadSize, + excludes = notificationFilter + ) + + // It's important to return *something* from this state. If an empty page is returned + // (even with next/prev links) Pager3 assumes there is no more data to load and stops. + // + // In addition, the Mastodon API does not let you fetch a page that contains a given key. + // You can fetch the page immediately before the key, or the page immediately after, but + // you can not fetch the page itself. + + // First, try and get the notification itself, and the notifications immediately before + // it. This is so that a full page of results can be returned. Returning just the + // single notification means the displayed list can jump around a bit as more data is + // loaded. + // + // Make both requests, and wait for the first to complete. + val deferredNotification = async { mastodonApi.notification(id = key) } + val deferredNotificationPage = async { + mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) + } + + val notification = deferredNotification.await() + if (notification.isSuccessful) { + // If this was successful we must still check that the user is not filtering this type + // of notification, as fetching a single notification ignores filters. Returning this + // notification if the user is filtering the type is wrong. + notification.body()?.let { body -> + if (!notificationFilter.contains(body.type)) { + // Notification is *not* filtered. We can return this, but need the next page of + // notifications as well + + // Collect all notifications in to this list + val notifications = mutableListOf(body) + val notificationPage = deferredNotificationPage.await() + if (notificationPage.isSuccessful) { + notificationPage.body()?.let { + notifications.addAll(it) + } + } + + // "notifications" now contains at least one notification we can return, and + // hopefully a full page. + + // Build correct max_id and min_id links for the response. The "min_id" to use + // when fetching the next page is the same as "key". The "max_id" is the ID of + // the oldest notification in the list. + val maxId = notifications.last().id + val headers = Headers.Builder() + .add("link: ; rel=\"next\", ; rel=\"prev\"") + .build() + + return@coroutineScope Response.success(notifications, headers) + } + } + } + + // The user's last read notification was missing or is filtered. Use the page of + // notifications chronologically older than their desired notification. + deferredNotificationPage.await().apply { + if (this.isSuccessful) return@coroutineScope this + } + + // There were no notifications older than the user's desired notification. Return the page + // of notifications immediately newer than their desired notification. + return@coroutineScope mastodonApi.notifications( + minId = key, + limit = params.loadSize, + excludes = notificationFilter + ) + } + + private fun getPageLinks(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + + override fun getRefreshKey(state: PagingState): String? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey ?: anchorPage?.nextKey + } + } + + companion object { + private const val TAG = "NotificationsPagingSource" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt new file mode 100644 index 00000000..25c8458a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 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.notifications + +import android.util.Log +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.flow.Flow +import okhttp3.ResponseBody +import retrofit2.Response +import javax.inject.Inject + +class NotificationsRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + private var factory: InvalidatingPagingSourceFactory? = null + + /** + * @return flow of Mastodon [Notification], excluding all types in [filter]. + * Notifications are loaded in [pageSize] increments. + */ + fun getNotificationsStream( + filter: Set, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getNotificationsStream(), filtering: $filter") + + factory = InvalidatingPagingSourceFactory { + NotificationsPagingSource(mastodonApi, filter) + } + + return Pager( + config = PagingConfig(pageSize = pageSize), + initialKey = initialKey, + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + /** Clear notifications */ + suspend fun clearNotifications(): Response { + return mastodonApi.clearNotifications() + } + + companion object { + private const val TAG = "NotificationsRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt new file mode 100644 index 00000000..1c84dcad --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -0,0 +1,522 @@ +/* + * Copyright 2023 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.notifications + +import android.content.SharedPreferences +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteConversationEvent +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.serialize +import com.keylesspalace.tusky.util.toViewData +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import retrofit2.HttpException +import javax.inject.Inject + +data class UiState( + /** Filtered notification types */ + val activeFilter: Set = emptySet(), + + /** True if the UI to filter and clear notifications should be shown */ + val showFilterOptions: Boolean = false, + + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val showFabWhileScrolling: Boolean, + val showFilter: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.SHOW_NOTIFICATIONS_FILTER + ) + } +} + +/** Parent class for all UI actions, fallible or infallible. */ +sealed class UiAction + +/** Actions the user can trigger from the UI. These actions may fail. */ +sealed class FallibleUiAction : UiAction() { + /** Clear all notifications */ + object ClearNotifications : FallibleUiAction() +} + +/** + * Actions the user can trigger from the UI that either cannot fail, or if they do fail, + * do not show an error. + */ +sealed class InfallibleUiAction : UiAction() { + /** Apply a new filter to the notification list */ + // This saves the list to the local database, which triggers a refresh of the data. + // Saving the data can't fail, which is why this is infallible. Refreshing the + // data may fail, but that's handled by the paging system / adapter refresh logic. + data class ApplyFilter(val filter: Set) : InfallibleUiAction() + + /** + * User is leaving the fragment, save the ID of the visible notification. + * + * Infallible because if it fails there's nowhere to show the error, and nothing the user + * can do. + */ + data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() +} + +/** Actions the user can trigger on an individual notification. These may fail. */ +sealed class NotificationAction : FallibleUiAction() { + data class AcceptFollowRequest(val accountId: String) : NotificationAction() + + data class RejectFollowRequest(val accountId: String) : NotificationAction() +} + +sealed class UiSuccess { + // These three are from menu items on the status. Currently they don't come to the + // viewModel as actions, they're noticed when events are posted. That will change, + // but for the moment we can still report them to the UI. Typically, receiving any + // of these three should trigger the UI to refresh. + + /** A user was blocked */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() +} + +/** The result of a successful action on a notification */ +sealed class NotificationActionSuccess( + /** String resource with an error message to show the user */ + @StringRes val msg: Int, + + /** + * The original action, in case additional information is required from it to display the + * message. + */ + open val action: NotificationAction +) : UiSuccess() { + data class AcceptFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) + data class RejectFollowRequest(override val action: NotificationAction) : + NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) + + companion object { + fun from(action: NotificationAction) = when (action) { + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) + } + } +} + +/** Actions the user can trigger on an individual status */ +sealed class StatusAction( + open val statusViewData: StatusViewData.Concrete +) : FallibleUiAction() { + /** Set the bookmark state for a status */ + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the favourite state for a status */ + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Set the reblog state for a status */ + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + StatusAction(statusViewData) + + /** Vote in a poll */ + data class VoteInPoll( + val poll: Poll, + val choices: List, + override val statusViewData: StatusViewData.Concrete + ) : StatusAction(statusViewData) +} + +/** Changes to a status' visible state after API calls */ +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + data class Favourite(override val action: StatusAction.Favourite) : + StatusActionSuccess(action) + + data class Reblog(override val action: StatusAction.Reblog) : + StatusActionSuccess(action) + + data class VoteInPoll(override val action: StatusAction.VoteInPoll) : + StatusActionSuccess(action) + + companion object { + fun from(action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + is StatusAction.Favourite -> Favourite(action) + is StatusAction.Reblog -> Reblog(action) + is StatusAction.VoteInPoll -> VoteInPoll(action) + } + } +} + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The exception associated with the error */ + open val exception: Exception, + + /** String resource with an error message to show the user */ + @StringRes val message: Int, + + /** The action that failed. Can be resent to retry the action */ + open val action: UiAction? = null +) { + data class ClearNotifications(override val exception: Exception) : UiError( + exception, + R.string.ui_error_clear_notifications + ) + + data class Bookmark( + override val exception: Exception, + override val action: StatusAction.Bookmark + ) : UiError(exception, R.string.ui_error_bookmark, action) + + data class Favourite( + override val exception: Exception, + override val action: StatusAction.Favourite + ) : UiError(exception, R.string.ui_error_favourite, action) + + data class Reblog( + override val exception: Exception, + override val action: StatusAction.Reblog + ) : UiError(exception, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val exception: Exception, + override val action: StatusAction.VoteInPoll + ) : UiError(exception, R.string.ui_error_vote, action) + + data class AcceptFollowRequest( + override val exception: Exception, + override val action: NotificationAction.AcceptFollowRequest + ) : UiError(exception, R.string.ui_error_accept_follow_request, action) + + data class RejectFollowRequest( + override val exception: Exception, + override val action: NotificationAction.RejectFollowRequest + ) : UiError(exception, R.string.ui_error_reject_follow_request, action) + + companion object { + fun make(exception: Exception, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(exception, action) + is StatusAction.Favourite -> Favourite(exception, action) + is StatusAction.Reblog -> Reblog(exception, action) + is StatusAction.VoteInPoll -> VoteInPoll(exception, action) + is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action) + is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action) + FallibleUiAction.ClearNotifications -> ClearNotifications(exception) + } + } +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, + private val preferences: SharedPreferences, + private val accountManager: AccountManager, + private val timelineCases: TimelineCases, + private val eventHub: EventHub +) : ViewModel() { + + val uiState: StateFlow + + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + + val pagingData: Flow> + + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow of successful action results */ + // Note: These are a SharedFlow instead of a StateFlow because success or error state does not + // need to be retained. A message is shown once to a user and then dismissed. Re-collecting the + // flow (e.g., after a device orientation change) should not re-show the most recent success or + // error message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Flow of transient errors for the UI to present */ + val uiError = MutableSharedFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + + init { + // Handle changes to notification filters + val notificationFilter = uiAction + .filterIsInstance() + .distinctUntilChanged() + // Save each change back to the active account + .onEach { action -> + Log.d(TAG, "notificationFilter: $action") + accountManager.activeAccount?.let { account -> + account.notificationsFilter = serialize(action.filter) + accountManager.saveAccount(account) + } + } + // Load the initial filter from the active account + .onStart { + emit( + InfallibleUiAction.ApplyFilter( + filter = deserialize(accountManager.activeAccount?.notificationsFilter) + ) + ) + } + + // Save the visible notification ID + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Saving visible ID: ${action.visibleId}") + accountManager.activeAccount?.let { account -> + account.lastNotificationId = action.visibleId + accountManager.saveAccount(account) + } + } + } + + // Set initial status display options from the user's preferences. + // + // Then collect future preference changes and emit new values in to + // statusDisplayOptions if necessary. + statusDisplayOptions = MutableStateFlow( + StatusDisplayOptions.from( + preferences, + accountManager.activeAccount!! + ) + ) + + viewModelScope.launch { + eventHub.events.asFlow() + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + preferences, + it.preferenceKey, + accountManager.activeAccount!! + ) + } + .collect { + statusDisplayOptions.emit(it) + } + } + + // Handle UiAction.ClearNotifications + viewModelScope.launch { + uiAction.filterIsInstance() + .collectLatest { + try { + repository.clearNotifications().apply { + if (this.isSuccessful) { + repository.invalidate() + } else { + uiError.emit(UiError.make(HttpException(this), it)) + } + } + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, it)) } + } + } + } + + // Handle NotificationAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) + .collect { action -> + try { + when (action) { + is NotificationAction.AcceptFollowRequest -> + timelineCases.acceptFollowRequest(action.accountId).await() + is NotificationAction.RejectFollowRequest -> + timelineCases.rejectFollowRequest(action.accountId).await() + } + uiSuccess.emit(NotificationActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ).await() + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ).await() + } + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { uiError.emit(UiError.make(e, action)) } + } + } + } + + // Handle events that should refresh the list + viewModelScope.launch { + eventHub.events.asFlow().collectLatest { + when (it) { + is BlockEvent -> uiSuccess.emit(UiSuccess.Block) + is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) + is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + } + } + } + + // The database stores "0" as the last notification ID if notifications have not been + // fetched. Convert to null to ensure a full fetch in this case + val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) { + "0" -> null + else -> id + } + Log.d(TAG, "Restoring at $lastNotificationId") + + pagingData = notificationFilter + .flatMapLatest { action -> + getNotifications(filters = action.filter, initialKey = lastNotificationId) + } + .cachedIn(viewModelScope) + + uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> + UiState( + activeFilter = filter.filter, + showFilterOptions = prefs.showFilter, + showFabWhileScrolling = prefs.showFabWhileScrolling + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) + } + + private fun getNotifications( + filters: Set, + initialKey: String? = null + ): Flow> { + return repository.getNotificationsStream(filter = filters, initialKey = initialKey) + .map { pagingData -> + pagingData.map { notification -> + notification.toViewData( + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || + !(notification.status?.actionableStatus?.sensitive ?: false), + isExpanded = statusDisplayOptions.value.openSpoiler, + isCollapsed = true + ) + } + } + } + + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + private fun getUiPrefs() = eventHub.events.asFlow() + .filterIsInstance() + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), + showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) + ) + + companion object { + private const val TAG = "NotificationsViewModel" + private const val DEBOUNCE_TIMEOUT_MS = 500L + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index cf1dd438..6745579e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -150,7 +150,7 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { - Notification.Type.asList.forEach { + Notification.Type.visibleTypes.forEach { put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context)) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt new file mode 100644 index 00000000..402f3872 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -0,0 +1,385 @@ +/* + * Copyright 2023 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.notifications + +import android.content.Context +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.InputFilter +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +import android.text.format.DateUtils +import android.text.style.StyleSpan +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import at.connyduck.sparkbutton.helpers.Utils +import com.bumptech.glide.Glide +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getRelativeTimeSpanString +import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.setClickableText +import com.keylesspalace.tusky.util.unicodeWrap +import com.keylesspalace.tusky.viewdata.NotificationViewData +import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Date + +/** + * View holder for a status with an activity to be notified about (posted, boosted, + * favourited, or edited, per [NotificationViewKind.from]). + * + * Shows a line with the activity, and who initiated the activity. Clicking this should + * go to the profile page for the initiator. + * + * Displays the original status below that. Clicking this should go to the original + * status in context. + */ +internal class StatusNotificationViewHolder( + private val binding: ItemStatusNotificationBinding, + private val statusActionListener: StatusActionListener, + private val notificationActionListener: NotificationActionListener, + private val absoluteTimeFormatter: AbsoluteTimeFormatter +) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) + private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_36dp + ) + private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_24dp + ) + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (payloads.isNullOrEmpty()) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + if (statusViewData == null) { + showNotificationContent(false) + } else { + showNotificationContent(true) + val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable + setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) + setUsername(account.username) + setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) + if (viewData.type == Notification.Type.STATUS || + viewData.type == Notification.Type.UPDATE + ) { + setAvatar( + account.avatar, + account.bot, + statusDisplayOptions.animateAvatars, + statusDisplayOptions.showBotOverlay + ) + } else { + setAvatars( + account.avatar, + viewData.account.avatar, + statusDisplayOptions.animateAvatars + ) + } + + binding.notificationContainer.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationContent.setOnClickListener { + notificationActionListener.onViewThreadForStatus(statusViewData.status) + } + binding.notificationTopText.setOnClickListener { + notificationActionListener.onViewAccount(viewData.account.id) + } + } + setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) + } else { + for (item in payloads) { + if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { + setCreatedAt( + statusViewData.status.actionableStatus.createdAt, + statusDisplayOptions.useAbsoluteTime + ) + } + } + } + } + + private fun showNotificationContent(show: Boolean) { + binding.statusNameBar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningDescription.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (show) View.VISIBLE else View.GONE + binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE + binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun setDisplayName(name: String, emojis: List?, animateEmojis: Boolean) { + val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) + binding.statusDisplayName.text = emojifiedName + } + + private fun setUsername(name: String) { + val context = binding.statusUsername.context + val format = context.getString(R.string.post_username_format) + val usernameText = String.format(format, name) + binding.statusUsername.text = usernameText + } + + private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) { + if (useAbsoluteTime) { + binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) + } else { + // This is the visible timestampInfo. + val readout: String + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + val readoutAloud: CharSequence + if (createdAt != null) { + val then = createdAt.time + val now = Date().time + readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) + readoutAloud = DateUtils.getRelativeTimeSpanString( + then, + now, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) + } else { + // unknown minutes~ + readout = "?m" + readoutAloud = "? minutes" + } + binding.statusMetaInfo.text = readout + binding.statusMetaInfo.contentDescription = readoutAloud + } + } + + private fun getIconWithColor( + context: Context, + @DrawableRes drawable: Int, + @ColorRes color: Int + ): Drawable? { + val icon = ContextCompat.getDrawable(context, drawable) + icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) + return icon + } + + private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { + binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius48dp, + animateAvatars + ) + if (showBotOverlay && isBot) { + binding.notificationNotificationAvatar.visibility = View.VISIBLE + Glide.with(binding.notificationNotificationAvatar) + .load(R.drawable.bot_badge) + .into(binding.notificationNotificationAvatar) + } else { + binding.notificationNotificationAvatar.visibility = View.GONE + } + } + + private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { + val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) + binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) + loadAvatar( + statusAvatarUrl, + binding.notificationStatusAvatar, + avatarRadius36dp, + animateAvatars + ) + binding.notificationNotificationAvatar.visibility = View.VISIBLE + loadAvatar( + notificationAvatarUrl, + binding.notificationNotificationAvatar, + avatarRadius24dp, + animateAvatars + ) + } + + fun setMessage( + notificationViewData: NotificationViewData, + listener: LinkListener, + animateEmojis: Boolean + ) { + val statusViewData = notificationViewData.statusViewData + val displayName = notificationViewData.account.name.unicodeWrap() + val type = notificationViewData.type + val context = binding.notificationTopText.context + val format: String + val icon: Drawable? + when (type) { + Notification.Type.FAVOURITE -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + Notification.Type.REBLOG -> { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_reblog_format) + } + Notification.Type.STATUS -> { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_subscription_format) + } + Notification.Type.UPDATE -> { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue) + format = context.getString(R.string.notification_update_format) + } + else -> { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) + format = context.getString(R.string.notification_favourite_format) + } + } + binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( + icon, + null, + null, + null + ) + val wholeMessage = String.format(format, displayName) + val str = SpannableStringBuilder(wholeMessage) + val displayNameIndex = format.indexOf("%s") + str.setSpan( + StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + val emojifiedText = str.emojify( + notificationViewData.account.emojis, + binding.notificationTopText, + animateEmojis + ) + binding.notificationTopText.text = emojifiedText + if (statusViewData != null) { + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + binding.notificationContentWarningDescription.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + binding.notificationContentWarningButton.visibility = + if (hasSpoiler) View.VISIBLE else View.GONE + if (statusViewData.isExpanded) { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_less + ) + } else { + binding.notificationContentWarningButton.setText( + R.string.post_content_warning_show_more + ) + } + binding.notificationContentWarningButton.setOnClickListener { + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange( + !statusViewData.isExpanded, + bindingAdapterPosition + ) + } + binding.notificationContent.visibility = + if (statusViewData.isExpanded) View.GONE else View.VISIBLE + } + setupContentAndSpoiler(listener, statusViewData, animateEmojis) + } + } + + private fun setupContentAndSpoiler( + listener: LinkListener, + statusViewData: StatusViewData.Concrete, + animateEmojis: Boolean + ) { + val shouldShowContentIfSpoiler = statusViewData.isExpanded + val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) + if (!shouldShowContentIfSpoiler && hasSpoiler) { + binding.notificationContent.visibility = View.GONE + } else { + binding.notificationContent.visibility = View.VISIBLE + } + val content = statusViewData.content + val emojis = statusViewData.actionable.emojis + if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { + binding.buttonToggleNotificationContent.setOnClickListener { + val position = bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + notificationActionListener.onNotificationContentCollapsedChange( + !statusViewData.isCollapsed, + position + ) + } + } + binding.buttonToggleNotificationContent.visibility = View.VISIBLE + if (statusViewData.isCollapsed) { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_more + ) + binding.notificationContent.filters = COLLAPSE_INPUT_FILTER + } else { + binding.buttonToggleNotificationContent.setText( + R.string.post_content_warning_show_less + ) + binding.notificationContent.filters = NO_INPUT_FILTER + } + } else { + binding.buttonToggleNotificationContent.visibility = View.GONE + binding.notificationContent.filters = NO_INPUT_FILTER + } + val emojifiedText = + content.emojify( + emojis, + binding.notificationContent, + animateEmojis + ) + setClickableText( + binding.notificationContent, + emojifiedText, + statusViewData.actionable.mentions, + statusViewData.actionable.tags, + listener + ) + val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( + statusViewData.actionable.emojis, + binding.notificationContentWarningDescription, + animateEmojis + ) + binding.notificationContentWarningDescription.text = emojifiedContentWarning + } + + companion object { + private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) + private val NO_INPUT_FILTER = arrayOfNulls(0) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt new file mode 100644 index 00000000..c719c084 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 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.notifications + +import com.keylesspalace.tusky.adapter.StatusViewHolder +import com.keylesspalace.tusky.databinding.ItemStatusBinding +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.viewdata.NotificationViewData + +internal class StatusViewHolder( + binding: ItemStatusBinding, + private val statusActionListener: StatusActionListener, + private val accountId: String +) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { + + override fun bind( + viewData: NotificationViewData, + payloads: List<*>?, + statusDisplayOptions: StatusDisplayOptions + ) { + val statusViewData = viewData.statusViewData + if (statusViewData == null) { + // Hide null statuses. Shouldn't happen according to the spec, but some servers + // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) + showStatusContent(false) + } else { + if (payloads.isNullOrEmpty()) { + showStatusContent(true) + } + setupWithStatus( + statusViewData, + statusActionListener, + statusDisplayOptions, + payloads?.firstOrNull() + ) + } + if (viewData.type == Notification.Type.POLL) { + setPollInfo(accountId == viewData.account.id) + } else { + hideStatusInfo() + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index f65e29c3..f15f1962 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -156,7 +156,9 @@ class ReportStatusesFragment : 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) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) 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 12aeaf81..1b3c39f9 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 @@ -48,6 +48,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention @@ -62,8 +63,11 @@ import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import javax.inject.Inject class SearchStatusesFragment : SearchFragment(), StatusActionListener { + @Inject + lateinit var accountManager: AccountManager override val data: Flow> get() = viewModel.statusesFlow @@ -83,7 +87,9 @@ class SearchStatusesFragment : SearchFragment(), Status 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) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 36d20e68..317d8df1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -191,7 +191,9 @@ class TimelineFragment : confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = TimelinePagingAdapter( statusDisplayOptions, @@ -226,16 +228,16 @@ class TimelineFragment : 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) + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) } } 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) + binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) + binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) } } is LoadState.Loading -> { 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 index 4baa0ff1..c780ffeb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -112,7 +112,9 @@ class ViewThreadFragment : 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) + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, + openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) adapter = ThreadAdapter(statusDisplayOptions, this) } 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 852088f3..418c77e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -64,6 +64,11 @@ data class AccountEntity( var alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ var alwaysOpenSpoiler: Boolean = false, + + /** + * True if the "Download media previews" preference is true. This implies + * that media previews are shown as well as downloaded. + */ var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", var activeNotifications: String = "[]", 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 3ad18fca..aee1feab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -21,6 +21,7 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment +import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment @@ -34,7 +35,6 @@ import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.trending.TrendingFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment -import com.keylesspalace.tusky.fragment.NotificationsFragment 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 f852d97b..e3ce3a3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -1,3 +1,20 @@ +/* + * Copyright 2023 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 . + */ + // from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 package com.keylesspalace.tusky.di @@ -13,6 +30,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel +import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel @@ -145,6 +163,11 @@ abstract class ViewModelModule { @ViewModelKey(ListsForAccountViewModel::class) internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(NotificationsViewModel::class) + internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(TrendingViewModel::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index b058c4c1..1bad6697 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -15,11 +15,13 @@ package com.keylesspalace.tusky.entity +import androidx.annotation.StringRes import com.google.gson.JsonDeserializationContext import com.google.gson.JsonDeserializer import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.annotations.JsonAdapter +import com.keylesspalace.tusky.R data class Notification( val type: Type, @@ -29,23 +31,42 @@ data class Notification( val report: Report?, ) { + /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonAdapter(NotificationTypeAdapter::class) - enum class Type(val presentation: String) { - UNKNOWN("unknown"), - MENTION("mention"), - REBLOG("reblog"), - FAVOURITE("favourite"), - FOLLOW("follow"), - FOLLOW_REQUEST("follow_request"), - POLL("poll"), - STATUS("status"), - SIGN_UP("admin.sign_up"), - UPDATE("update"), - REPORT("admin.report"), - ; + enum class Type(val presentation: String, @StringRes val uiString: Int) { + UNKNOWN("unknown", R.string.notification_unknown_name), + + /** Someone mentioned you */ + MENTION("mention", R.string.notification_mention_name), + + /** Someone boosted one of your statuses */ + REBLOG("reblog", R.string.notification_boost_name), + + /** Someone favourited one of your statuses */ + FAVOURITE("favourite", R.string.notification_favourite_name), + + /** Someone followed you */ + FOLLOW("follow", R.string.notification_follow_name), + + /** Someone requested to follow you */ + FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), + + /** A poll you have voted in or created has ended */ + POLL("poll", R.string.notification_poll_name), + + /** Someone you enabled notifications for has posted a status */ + STATUS("status", R.string.notification_subscription_name), + + /** Someone signed up (optionally sent to admins) */ + SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), + + /** A status you interacted with has been updated */ + UPDATE("update", R.string.notification_update_name), + + /** A new report has been filed */ + REPORT("admin.report", R.string.notification_report_name); companion object { - @JvmStatic fun byString(s: String): Type { values().forEach { @@ -54,7 +75,9 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) + + /** Notification types for UI display (omits UNKNOWN) */ + val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { @@ -86,9 +109,6 @@ data class Notification( } } - /** Helper for Java */ - fun copyWithStatus(status: Status?): Notification = copy(status = status) - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java deleted file mode 100644 index 24d026cc..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ /dev/null @@ -1,1273 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.fragment; - -import static com.keylesspalace.tusky.util.StringUtils.isLessThan; -import static autodispose2.AutoDispose.autoDisposable; -import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; - -import android.app.Activity; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.PopupWindow; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.arch.core.util.Function; -import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.util.Pair; -import androidx.core.view.MenuProvider; -import androidx.lifecycle.Lifecycle; -import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.AsyncDifferConfig; -import androidx.recyclerview.widget.AsyncListDiffer; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.ListUpdateCallback; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.adapter.NotificationsAdapter; -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; -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.PreferenceChangedEvent; -import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.components.notifications.NotificationHelper; -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; -import com.keylesspalace.tusky.db.AccountEntity; -import com.keylesspalace.tusky.db.AccountManager; -import com.keylesspalace.tusky.di.Injectable; -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Poll; -import com.keylesspalace.tusky.entity.Relationship; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.AccountActionListener; -import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.interfaces.ReselectableFragment; -import com.keylesspalace.tusky.interfaces.StatusActionListener; -import com.keylesspalace.tusky.settings.PrefKeys; -import com.keylesspalace.tusky.util.CardViewMode; -import com.keylesspalace.tusky.util.Either; -import com.keylesspalace.tusky.util.HttpHeaderLink; -import com.keylesspalace.tusky.util.LinkHelper; -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; -import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.NotificationTypeConverterKt; -import com.keylesspalace.tusky.util.PairedList; -import com.keylesspalace.tusky.util.StatusDisplayOptions; -import com.keylesspalace.tusky.util.ViewDataUtils; -import com.keylesspalace.tusky.view.EndlessOnScrollListener; -import com.keylesspalace.tusky.viewdata.AttachmentViewData; -import com.keylesspalace.tusky.viewdata.NotificationViewData; -import com.keylesspalace.tusky.viewdata.StatusViewData; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import javax.inject.Inject; - -import at.connyduck.sparkbutton.helpers.Utils; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import kotlin.Unit; -import kotlin.collections.CollectionsKt; -import kotlin.jvm.functions.Function1; - -public class NotificationsFragment extends SFragment implements - SwipeRefreshLayout.OnRefreshListener, - StatusActionListener, - NotificationsAdapter.NotificationActionListener, - AccountActionListener, - Injectable, - MenuProvider, - ReselectableFragment { - private static final String TAG = "NotificationF"; // logging tag - - private static final int LOAD_AT_ONCE = 30; - private int maxPlaceholderId = 0; - - private final Set notificationFilter = new HashSet<>(); - - private final CompositeDisposable disposables = new CompositeDisposable(); - - private enum FetchEnd { - TOP, - BOTTOM, - MIDDLE - } - - /** - * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor - * and reuse in different places as needed. - */ - private static final class Placeholder { - final long id; - - public static Placeholder getInstance(long id) { - return new Placeholder(id); - } - - private Placeholder(long id) { - this.id = id; - } - } - - @Inject - AccountManager accountManager; - @Inject - EventHub eventHub; - - private FragmentTimelineNotificationsBinding binding; - - private LinearLayoutManager layoutManager; - private EndlessOnScrollListener scrollListener; - private NotificationsAdapter adapter; - private boolean hideFab; - private boolean topLoading; - private boolean bottomLoading; - private String bottomId; - private boolean alwaysShowSensitiveMedia; - private boolean alwaysOpenSpoiler; - private boolean showNotificationsFilter; - private boolean showingError; - - // Each element is either a Notification for loading data or a Placeholder - private final PairedList, NotificationViewData> notifications - = new PairedList<>(new Function<>() { - @Override - public NotificationViewData apply(Either input) { - if (input.isRight()) { - Notification notification = input.asRight() - .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); - - boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); - - return ViewDataUtils.notificationToViewData( - notification, - alwaysShowSensitiveMedia || !sensitiveStatus, - alwaysOpenSpoiler, - true - ); - } else { - return new NotificationViewData.Placeholder(input.asLeft().id, false); - } - } - }); - - public static NotificationsFragment newInstance() { - NotificationsFragment fragment = new NotificationsFragment(); - Bundle arguments = new Bundle(); - fragment.setArguments(arguments); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); - - binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); - - @NonNull Context context = inflater.getContext(); // from inflater to silence warning - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - - boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); - // Clear notifications on filter visibility change to force refresh - if (showNotificationsFilterSetting != showNotificationsFilter) - notifications.clear(); - showNotificationsFilter = showNotificationsFilterSetting; - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this); - binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); - - loadNotificationsFilter(); - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true); - layoutManager = new LinearLayoutManager(context); - binding.recyclerView.setLayoutManager(layoutManager); - binding.recyclerView.setAccessibilityDelegateCompat( - new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { - NotificationViewData notification = notifications.getPairedItemOrNull(pos); - // We support replies only for now - if (notification instanceof NotificationViewData.Concrete) { - return ((NotificationViewData.Concrete) notification).getStatusViewData(); - } else { - return null; - } - })); - - binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); - - StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( - preferences.getBoolean("animateGifAvatars", false), - accountManager.getActiveAccount().getMediaPreviewEnabled(), - preferences.getBoolean("absoluteTimeView", false), - preferences.getBoolean("showBotOverlay", true), - preferences.getBoolean("useBlurhash", true), - 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 NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), - dataSource, statusDisplayOptions, this, this, this); - alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); - alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); - binding.recyclerView.setAdapter(adapter); - - topLoading = false; - bottomLoading = false; - bottomId = null; - - updateAdapter(); - - binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); - binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); - - if (notifications.isEmpty()) { - binding.swipeRefreshLayout.setEnabled(false); - sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); - } else { - binding.progressBar.setVisibility(View.GONE); - } - - ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); - - updateFilterVisibility(); - - return binding.getRoot(); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - binding = null; - } - - @Override - public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu); - } - - @Override - public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { - if (menuItem.getItemId() == R.id.action_refresh) { - binding.swipeRefreshLayout.setRefreshing(true); - onRefresh(); - return true; - } - - return false; - } - - private void updateFilterVisibility() { - CoordinatorLayout.LayoutParams params = - (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); - if (showNotificationsFilter && !showingError) { - binding.appBarOptions.setExpanded(true, false); - binding.appBarOptions.setVisibility(View.VISIBLE); - // Set content behaviour to hide filter on scroll - params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); - } else { - binding.appBarOptions.setExpanded(false, false); - binding.appBarOptions.setVisibility(View.GONE); - // Clear behaviour to hide app bar - params.setBehavior(null); - } - } - - private void confirmClearNotifications() { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - Activity activity = getActivity(); - if (activity == null) throw new AssertionError("Activity is null"); - - // This is delayed until onActivityCreated solely because MainActivity.composeButton - // isn't guaranteed to be set until then. - // Use a modified scroll listener that both loads more notificationsEnabled as it - // goes, and hides the compose button on down-scroll. - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity activity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = activity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // Hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // Shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { - NotificationsFragment.this.onLoadMore(); - } - }; - - binding.recyclerView.addOnScrollListener(scrollListener); - - eventHub.getEvents() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe(event -> { - if (event instanceof FavoriteEvent) { - setFavouriteForStatus(((FavoriteEvent) event).getStatusId(), ((FavoriteEvent) event).getFavourite()); - } else if (event instanceof BookmarkEvent) { - setBookmarkForStatus(((BookmarkEvent) event).getStatusId(), ((BookmarkEvent) event).getBookmark()); - } else if (event instanceof ReblogEvent) { - setReblogForStatus(((ReblogEvent) event).getStatusId(), ((ReblogEvent) event).getReblog()); - } else if (event instanceof PinEvent) { - setPinForStatus(((PinEvent) event).getStatusId(), ((PinEvent) event).getPinned()); - } else if (event instanceof BlockEvent) { - removeAllByAccountId(((BlockEvent) event).getAccountId()); - } else if (event instanceof PreferenceChangedEvent) { - onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); - } - }); - } - - @Override - public void onRefresh() { - binding.statusView.setVisibility(View.GONE); - this.showingError = false; - Either first = CollectionsKt.firstOrNull(this.notifications); - String topId; - if (first != null && first.isRight()) { - topId = first.asRight().getId(); - } else { - topId = null; - } - sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); - } - - @Override - public void onReply(int position) { - super.reply(notifications.get(position).asRight().getStatus()); - } - - @Override - public void onReblog(final boolean reblog, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - Objects.requireNonNull(status, "Reblog on notification without status"); - timelineCases.reblog(status.getId(), reblog) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setReblogForStatus(status.getId(), reblog), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to reblog status: " + status.getId(), t) - ); - } - - private void setReblogForStatus(String statusId, boolean reblog) { - updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); - } - - @Override - public void onFavourite(final boolean favourite, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.favourite(status.getId(), favourite) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setFavouriteForStatus(status.getId(), favourite), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to favourite status: " + status.getId(), t) - ); - } - - private void setFavouriteForStatus(String statusId, boolean favourite) { - updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); - } - - @Override - public void onBookmark(final boolean bookmark, final int position) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus(); - - timelineCases.bookmark(status.getActionableId(), bookmark) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), - (t) -> Log.d(getClass().getSimpleName(), - "Failed to bookmark status: " + status.getId(), t) - ); - } - - private void setBookmarkForStatus(String statusId, boolean bookmark) { - updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); - } - - public void onVoteInPoll(int position, @NonNull List choices) { - final Notification notification = notifications.get(position).asRight(); - final Status status = notification.getStatus().getActionableStatus(); - timelineCases.voteInPoll(status.getId(), status.getPoll().getId(), choices) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this))) - .subscribe( - (newPoll) -> setVoteForPoll(status, newPoll), - (t) -> Log.d(TAG, - "Failed to vote in poll: " + status.getId(), t) - ); - } - - private void setVoteForPoll(Status status, Poll poll) { - updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); - } - - @Override - public void onMore(@NonNull View view, int position) { - Notification notification = notifications.get(position).asRight(); - super.more(notification.getStatus(), view, position); - } - - @Override - public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { - Notification notification = notifications.get(position).asRightOrNull(); - if (notification == null || notification.getStatus() == null) return; - Status status = notification.getStatus(); - super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view); - } - - @Override - public void onViewThread(int position) { - Notification notification = notifications.get(position).asRight(); - Status status = notification.getStatus(); - if (status == null) return; - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - } - - @Override - public void onOpenReblog(int position) { - Notification notification = notifications.get(position).asRight(); - onViewAccount(notification.getAccount().getId()); - } - - @Override - public void onExpandedChange(boolean expanded, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); - } - - @Override - public void onContentHiddenChange(boolean isShowing, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); - } - - private void setPinForStatus(String statusId, boolean pinned) { - updateStatus(statusId, status -> status.copyWithPinned(pinned)); - } - - @Override - public void onLoadMore(int position) { - // Check bounds before accessing list, - if (notifications.size() >= position && position > 0) { - Notification previous = notifications.get(position - 1).asRightOrNull(); - Notification next = notifications.get(position + 1).asRightOrNull(); - if (previous == null || next == null) { - Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); - return; - } - sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData notificationViewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(position, notificationViewData); - updateAdapter(); - } else { - Log.d(TAG, "error loading more"); - } - } - - @Override - public void onContentCollapsedChange(boolean isCollapsed, int position) { - updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); - } - - private void updateStatus(String statusId, Function mapper) { - int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && - s.asRight().getStatus() != null && - s.asRight().getStatus().getId().equals(statusId)); - if (index == -1) return; - - // We have quite some graph here: - // - // Notification --------> Status - // ^ - // | - // StatusViewData - // ^ - // | - // NotificationViewData -----+ - // - // So if we have "new" status we need to update all references to be sure that data is - // up-to-date: - // 1. update status - // 2. update notification - // 3. update statusViewData - // 4. update notificationViewData - - Status oldStatus = notifications.get(index).asRight().getStatus(); - NotificationViewData.Concrete oldViewData = - (NotificationViewData.Concrete) this.notifications.getPairedItem(index); - Status newStatus = mapper.apply(oldStatus); - Notification newNotification = this.notifications.get(index).asRight() - .copyWithStatus(newStatus); - StatusViewData.Concrete newStatusViewData = - Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); - NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); - - notifications.set(index, new Either.Right<>(newNotification)); - notifications.setPairedItem(index, newViewData); - - updateAdapter(); - } - - private void updateViewDataAt(int position, - Function mapper) { - if (position < 0 || position >= notifications.size()) { - String message = String.format( - Locale.getDefault(), - "Tried to access out of bounds status position: %d of %d", - position, - notifications.size() - 1 - ); - Log.e(TAG, message); - return; - } - NotificationViewData someViewData = this.notifications.getPairedItem(position); - if (!(someViewData instanceof NotificationViewData.Concrete)) { - return; - } - NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; - StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); - if (oldStatusViewData == null) return; - - NotificationViewData.Concrete newViewData = - oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); - notifications.setPairedItem(position, newViewData); - - updateAdapter(); - } - - @Override - public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { - onContentCollapsedChange(isCollapsed, position); - } - - private void clearNotifications() { - // Cancel all ongoing requests - binding.swipeRefreshLayout.setRefreshing(false); - resetNotificationsLoad(); - - // Show friend elephant - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - updateFilterVisibility(); - - // Update adapter - updateAdapter(); - - // Execute clear notifications request - mastodonApi.clearNotifications() - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - // Nothing to do - }, - throwable -> { - // Reload notifications on failure - fullyRefreshWithProgressBar(true); - }); - } - - private void resetNotificationsLoad() { - disposables.clear(); - bottomLoading = false; - topLoading = false; - - // Disable load more - bottomId = null; - - // Clear exists notifications - notifications.clear(); - } - - - private void showFilterMenu() { - List notificationsList = Notification.Type.Companion.getAsList(); - List list = new ArrayList<>(); - for (Notification.Type type : notificationsList) { - list.add(getNotificationText(type)); - } - - ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); - PopupWindow window = new PopupWindow(getContext()); - View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); - final ListView listView = view.findViewById(R.id.listView); - view.findViewById(R.id.buttonApply) - .setOnClickListener(v -> { - SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); - Set excludes = new HashSet<>(); - for (int i = 0; i < notificationsList.size(); i++) { - if (!checkedItems.get(i, false)) - excludes.add(notificationsList.get(i)); - } - window.dismiss(); - applyFilterChanges(excludes); - - }); - - listView.setAdapter(adapter); - listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); - for (int i = 0; i < notificationsList.size(); i++) { - if (!notificationFilter.contains(notificationsList.get(i))) - listView.setItemChecked(i, true); - } - window.setContentView(view); - window.setFocusable(true); - window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); - window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); - window.showAsDropDown(binding.buttonFilter); - - } - - private String getNotificationText(Notification.Type type) { - switch (type) { - case MENTION: - return getString(R.string.notification_mention_name); - case FAVOURITE: - return getString(R.string.notification_favourite_name); - case REBLOG: - return getString(R.string.notification_boost_name); - case FOLLOW: - return getString(R.string.notification_follow_name); - case FOLLOW_REQUEST: - return getString(R.string.notification_follow_request_name); - case POLL: - return getString(R.string.notification_poll_name); - case STATUS: - return getString(R.string.notification_subscription_name); - case SIGN_UP: - return getString(R.string.notification_sign_up_name); - case UPDATE: - return getString(R.string.notification_update_name); - case REPORT: - return getString(R.string.notification_report_name); - default: - return "Unknown"; - } - } - - private void applyFilterChanges(Set newSet) { - List notifications = Notification.Type.Companion.getAsList(); - boolean isChanged = false; - for (Notification.Type type : notifications) { - if (notificationFilter.contains(type) && !newSet.contains(type)) { - notificationFilter.remove(type); - isChanged = true; - } else if (!notificationFilter.contains(type) && newSet.contains(type)) { - notificationFilter.add(type); - isChanged = true; - } - } - if (isChanged) { - saveNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - - } - - private void loadNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - notificationFilter.clear(); - notificationFilter.addAll(NotificationTypeConverterKt.deserialize( - account.getNotificationsFilter())); - } - } - - private void saveNotificationsFilter() { - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); - accountManager.saveAccount(account); - } - } - - @Override - public void onViewTag(@NonNull String tag) { - super.viewTag(tag); - } - - @Override - public void onViewAccount(@NonNull String id) { - super.viewAccount(id); - } - - @Override - public void onMute(boolean mute, String id, int position, boolean notifications) { - // No muting from notifications yet - } - - @Override - public void onBlock(boolean block, String id, int position) { - // No blocking from notifications yet - } - - @Override - public void onRespondToFollowRequest(boolean accept, String id, int position) { - Single request = accept ? - mastodonApi.authorizeFollowRequest(id) : - mastodonApi.rejectFollowRequest(id); - request.observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - (relationship) -> fullyRefreshWithProgressBar(true), - (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) - ); - } - - @Override - public void onViewStatusForNotificationId(String notificationId) { - for (Either either : notifications) { - Notification notification = either.asRightOrNull(); - if (notification != null && notification.getId().equals(notificationId)) { - Status status = notification.getStatus(); - if (status != null) { - super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); - return; - } - } - } - Log.w(TAG, "Didn't find a notification for ID: " + notificationId); - } - - @Override - public void onViewReport(String reportId) { - LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); - } - - private void onPreferenceChanged(String key) { - switch (key) { - case "fabHide": { - hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); - break; - } - case "mediaPreviewEnabled": { - boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); - if (enabled != adapter.isMediaPreviewEnabled()) { - adapter.setMediaPreviewEnabled(enabled); - fullyRefresh(); - } - break; - } - case "showNotificationsFilter": { - if (isAdded()) { - showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); - updateFilterVisibility(); - fullyRefreshWithProgressBar(true); - } - break; - } - } - } - - @Override - public void removeItem(int position) { - notifications.remove(position); - updateAdapter(); - } - - private void removeAllByAccountId(String accountId) { - // Using iterator to safely remove items while iterating - Iterator> iterator = notifications.iterator(); - while (iterator.hasNext()) { - Either notification = iterator.next(); - Notification maybeNotification = notification.asRightOrNull(); - if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { - iterator.remove(); - } - } - updateAdapter(); - } - - private void onLoadMore() { - if (bottomId == null) { - // Already loaded everything - return; - } - - // Check for out-of-bounds when loading - // This is required to allow full-timeline reloads of collapsible statuses when the settings - // change. - if (notifications.size() > 0) { - Either last = notifications.get(notifications.size() - 1); - if (last.isRight()) { - final Placeholder placeholder = newPlaceholder(); - notifications.add(new Either.Left<>(placeholder)); - NotificationViewData viewData = - new NotificationViewData.Placeholder(placeholder.id, true); - notifications.setPairedItem(notifications.size() - 1, viewData); - updateAdapter(); - } - } - - sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); - } - - private Placeholder newPlaceholder() { - Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); - maxPlaceholderId--; - return placeholder; - } - - private void jumpToTop() { - if (isAdded()) { - binding.appBarOptions.setExpanded(true, false); - layoutManager.scrollToPosition(0); - scrollListener.reset(); - } - } - - private void sendFetchNotificationsRequest(String fromId, String uptoId, - final FetchEnd fetchEnd, final int pos) { - // If there is a fetch already ongoing, record however many fetches are requested and - // fulfill them after it's complete. - if (fetchEnd == FetchEnd.TOP && topLoading) { - return; - } - if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { - return; - } - if (fetchEnd == FetchEnd.TOP) { - topLoading = true; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = true; - } - - Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) - .subscribe( - response -> { - if (response.isSuccessful()) { - String linkHeader = response.headers().get("Link"); - onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); - } else { - onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); - } - }, - throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos)); - disposables.add(notificationCall); - } - - private void onFetchNotificationsSuccess(List notifications, String linkHeader, - FetchEnd fetchEnd, int pos) { - List links = HttpHeaderLink.Companion.parse(linkHeader); - HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); - String fromId = null; - if (next != null) { - fromId = next.getUri().getQueryParameter("max_id"); - } - - switch (fetchEnd) { - case TOP: { - update(notifications, this.notifications.isEmpty() ? fromId : null); - break; - } - case MIDDLE: { - replacePlaceholderWithNotifications(notifications, pos); - break; - } - case BOTTOM: { - - if (!this.notifications.isEmpty() - && !this.notifications.get(this.notifications.size() - 1).isRight()) { - this.notifications.remove(this.notifications.size() - 1); - updateAdapter(); - } - - if (adapter.getItemCount() > 1) { - addItems(notifications, fromId); - } else { - update(notifications, fromId); - } - - break; - } - } - - saveNewestNotificationId(notifications); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - if (notifications.size() == 0 && adapter.getItemCount() == 0) { - binding.statusView.setVisibility(View.VISIBLE); - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); - } - - updateFilterVisibility(); - binding.swipeRefreshLayout.setEnabled(true); - binding.swipeRefreshLayout.setRefreshing(false); - binding.progressBar.setVisibility(View.GONE); - } - - private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { - binding.swipeRefreshLayout.setRefreshing(false); - if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { - Placeholder placeholder = notifications.get(position).asLeft(); - NotificationViewData placeholderVD = - new NotificationViewData.Placeholder(placeholder.id, false); - notifications.setPairedItem(position, placeholderVD); - updateAdapter(); - } else if (this.notifications.isEmpty()) { - binding.statusView.setVisibility(View.VISIBLE); - binding.swipeRefreshLayout.setEnabled(false); - this.showingError = true; - if (throwable instanceof IOException) { - binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } else { - binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> { - binding.progressBar.setVisibility(View.VISIBLE); - this.onRefresh(); - return Unit.INSTANCE; - }); - } - updateFilterVisibility(); - } - Log.e(TAG, "Fetch failure: " + throwable.getMessage()); - - if (fetchEnd == FetchEnd.TOP) { - topLoading = false; - } - if (fetchEnd == FetchEnd.BOTTOM) { - bottomLoading = false; - } - - binding.progressBar.setVisibility(View.GONE); - } - - private void saveNewestNotificationId(List notifications) { - - AccountEntity account = accountManager.getActiveAccount(); - if (account != null) { - String lastNotificationId = account.getLastNotificationId(); - - for (Notification noti : notifications) { - if (isLessThan(lastNotificationId, noti.getId())) { - lastNotificationId = noti.getId(); - } - } - - if (!account.getLastNotificationId().equals(lastNotificationId)) { - Log.d(TAG, "saving newest noti id: " + lastNotificationId); - account.setLastNotificationId(lastNotificationId); - accountManager.saveAccount(account); - } - } - } - - private void update(@Nullable List newNotifications, @Nullable String fromId) { - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - if (fromId != null) { - bottomId = fromId; - } - List> liftedNew = - liftNotificationList(newNotifications); - if (notifications.isEmpty()) { - notifications.addAll(liftedNew); - } else { - int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); - if (index > 0) { - notifications.subList(0, index).clear(); - } - - int newIndex = liftedNew.indexOf(notifications.get(0)); - if (newIndex == -1) { - if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - notifications.addAll(0, liftedNew); - } else { - notifications.addAll(0, liftedNew.subList(0, newIndex)); - } - } - updateAdapter(); - } - - private void addItems(List newNotifications, @Nullable String fromId) { - bottomId = fromId; - if (ListUtils.isEmpty(newNotifications)) { - return; - } - int end = notifications.size(); - List> liftedNew = liftNotificationList(newNotifications); - Either last = notifications.get(end - 1); - if (last != null && !liftedNew.contains(last)) { - notifications.addAll(liftedNew); - updateAdapter(); - } - } - - private void replacePlaceholderWithNotifications(List newNotifications, int pos) { - // Remove placeholder - notifications.remove(pos); - - if (ListUtils.isEmpty(newNotifications)) { - updateAdapter(); - return; - } - - List> liftedNew = liftNotificationList(newNotifications); - - // If we fetched less posts than in the limit, it means that the hole is not filled - // If we fetched at least as much it means that there are more posts to load and we should - // insert new placeholder - if (newNotifications.size() >= LOAD_AT_ONCE) { - liftedNew.add(new Either.Left<>(newPlaceholder())); - } - - notifications.addAll(pos, liftedNew); - updateAdapter(); - } - - private final Function1> notificationLifter = - Either.Right::new; - - private List> liftNotificationList(List list) { - return CollectionsKt.map(list, notificationLifter); - } - - private void fullyRefreshWithProgressBar(boolean isShow) { - resetNotificationsLoad(); - if (isShow) { - binding.progressBar.setVisibility(View.VISIBLE); - binding.statusView.setVisibility(View.GONE); - } - updateAdapter(); - sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); - } - - private void fullyRefresh() { - fullyRefreshWithProgressBar(false); - } - - @Nullable - private Pair findReplyPosition(@NonNull String statusId) { - for (int i = 0; i < notifications.size(); i++) { - Notification notification = notifications.get(i).asRightOrNull(); - if (notification != null - && notification.getStatus() != null - && notification.getType() == Notification.Type.MENTION - && (statusId.equals(notification.getStatus().getId()) - || (notification.getStatus().getReblog() != null - && statusId.equals(notification.getStatus().getReblog().getId())))) { - return new Pair<>(i, notification); - } - } - return null; - } - - private void updateAdapter() { - differ.submitList(notifications.getPairedCopy()); - } - - private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { - @Override - public void onInserted(int position, int count) { - if (isAdded()) { - adapter.notifyItemRangeInserted(position, count); - Context context = getContext(); - // scroll up when new items at the top are loaded while being at the start - // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 - if (position == 0 && context != null && adapter.getItemCount() != count) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); - } - } - } - - @Override - public void onRemoved(int position, int count) { - adapter.notifyItemRangeRemoved(position, count); - } - - @Override - public void onMoved(int fromPosition, int toPosition) { - adapter.notifyItemMoved(fromPosition, toPosition); - } - - @Override - public void onChanged(int position, int count, Object payload) { - adapter.notifyItemRangeChanged(position, count, payload); - } - }; - - private final AsyncListDiffer - differ = new AsyncListDiffer<>(listUpdateCallback, - new AsyncDifferConfig.Builder<>(diffCallback).build()); - - private final NotificationsAdapter.AdapterDataSource dataSource = - new NotificationsAdapter.AdapterDataSource<>() { - @Override - public int getItemCount() { - return differ.getCurrentList().size(); - } - - @Override - public NotificationViewData getItemAt(int pos) { - return differ.getCurrentList().get(pos); - } - }; - - private static final DiffUtil.ItemCallback diffCallback - = new DiffUtil.ItemCallback<>() { - - @Override - public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { - return oldItem.getViewDataId() == newItem.getViewDataId(); - } - - @Override - public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - return false; - } - - @Nullable - @Override - public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { - if (oldItem.deepEquals(newItem)) { - // If items are equal - update timestamp only - return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); - } else - // If items are different - update a whole view holder - return null; - } - }; - - @Override - public void onResume() { - super.onResume(); - - NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager); - - String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); - Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); - if (!notificationFilter.equals(accountNotificationFilter)) { - loadNotificationsFilter(); - fullyRefreshWithProgressBar(true); - } - startUpdateTimestamp(); - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private void startUpdateTimestamp() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) - .subscribe( - interval -> updateAdapter() - ); - } - - } - - @Override - public void onReselect() { - jumpToTop(); - } -} 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 6420216a..a94c8a35 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -123,12 +123,22 @@ interface MastodonApi { ): Response> @GET("api/v1/notifications") - fun notifications( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int?, - @Query("exclude_types[]") excludes: Set? - ): Single>> + suspend fun notifications( + /** Return results older than this ID */ + @Query("max_id") maxId: String? = null, + /** Return results immediately newer than this ID */ + @Query("min_id") minId: String? = null, + /** Maximum number of results to return. Defaults to 15, max is 30 */ + @Query("limit") limit: Int? = null, + /** Types to excludes from the results */ + @Query("exclude_types[]") excludes: Set? = null + ): Response> + + /** Fetch a single notification */ + @GET("api/v1/notifications/{id}") + suspend fun notification( + @Path("id") id: String + ): Response @GET("api/v1/markers") fun markersWithAuth( @@ -145,7 +155,7 @@ interface MastodonApi { ): Single> @POST("api/v1/notifications/clear") - fun clearNotifications(): Single + suspend fun clearNotifications(): Response @FormUrlEncoded @PUT("api/v1/media/{mediaId}") 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 45842f8e..6f102bfc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -31,6 +31,7 @@ import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getServerErrorMessage @@ -143,6 +144,14 @@ class TimelineCases @Inject constructor( } } + fun acceptFollowRequest(accountId: String): Single { + return mastodonApi.authorizeFollowRequest(accountId) + } + + fun rejectFollowRequest(accountId: String): Single { + return mastodonApi.rejectFollowRequest(accountId) + } + private fun convertError(e: Throwable): Single { return Single.error(TimelineError(e.getServerErrorMessage())) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index cb782107..7767accd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -1,5 +1,26 @@ +/* + * Copyright 2023 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 android.content.SharedPreferences +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.settings.PrefKeys + data class StatusDisplayOptions( @get:JvmName("animateAvatars") val animateAvatars: Boolean, @@ -20,5 +41,86 @@ data class StatusDisplayOptions( @get:JvmName("hideStats") val hideStats: Boolean, @get:JvmName("animateEmojis") - val animateEmojis: Boolean -) + val animateEmojis: Boolean, + @get:JvmName("showSensitiveMedia") + val showSensitiveMedia: Boolean, + @get:JvmName("openSpoiler") + val openSpoiler: Boolean +) { + + /** + * @return a new StatusDisplayOptions adapted to whichever preference changed. + */ + fun make( + preferences: SharedPreferences, + key: String, + account: AccountEntity + ) = when (key) { + PrefKeys.ANIMATE_GIF_AVATARS -> copy( + animateAvatars = preferences.getBoolean(key, false) + ) + PrefKeys.MEDIA_PREVIEW_ENABLED -> copy( + mediaPreviewEnabled = account.mediaPreviewEnabled + ) + PrefKeys.ABSOLUTE_TIME_VIEW -> copy( + useAbsoluteTime = preferences.getBoolean(key, false) + ) + PrefKeys.SHOW_BOT_OVERLAY -> copy( + showBotOverlay = preferences.getBoolean(key, true) + ) + PrefKeys.USE_BLURHASH -> copy( + useBlurhash = preferences.getBoolean(key, true) + ) + PrefKeys.CONFIRM_FAVOURITES -> copy( + confirmFavourites = preferences.getBoolean(key, false) + ) + PrefKeys.CONFIRM_REBLOGS -> copy( + confirmReblogs = preferences.getBoolean(key, true) + ) + PrefKeys.WELLBEING_HIDE_STATS_POSTS -> copy( + hideStats = preferences.getBoolean(key, false) + ) + PrefKeys.ANIMATE_CUSTOM_EMOJIS -> copy( + animateEmojis = preferences.getBoolean(key, false) + ) + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy( + showSensitiveMedia = account.alwaysShowSensitiveMedia + ) + PrefKeys.ALWAYS_OPEN_SPOILER -> copy( + openSpoiler = account.alwaysOpenSpoiler + ) + else -> { this } + } + + companion object { + /** Preference keys that, if changed, affect StatusDisplayOptions */ + val prefKeys = setOf( + PrefKeys.ABSOLUTE_TIME_VIEW, + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA, + PrefKeys.ALWAYS_OPEN_SPOILER, + PrefKeys.ANIMATE_CUSTOM_EMOJIS, + PrefKeys.ANIMATE_GIF_AVATARS, + PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.CONFIRM_REBLOGS, + PrefKeys.MEDIA_PREVIEW_ENABLED, + PrefKeys.SHOW_BOT_OVERLAY, + PrefKeys.USE_BLURHASH, + PrefKeys.WELLBEING_HIDE_STATS_POSTS + ) + + fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), + animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + mediaPreviewEnabled = account.mediaPreviewEnabled, + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = CardViewMode.NONE, + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), + hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + showSensitiveMedia = account.alwaysShowSensitiveMedia, + openSpoiler = account.alwaysOpenSpoiler + ) + } +} 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 51646512..37e0854b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -1,3 +1,20 @@ +/* + * Copyright 2023 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 . + */ + @file:JvmName("ViewDataUtils") /* Copyright 2017 Andrew Dawson @@ -44,8 +61,8 @@ fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData.Concrete { - return NotificationViewData.Concrete( +): NotificationViewData { + return NotificationViewData( this.type, this.id, this.account, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java deleted file mode 100644 index c70e2fc7..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ /dev/null @@ -1,138 +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.viewdata; - -import androidx.annotation.Nullable; - -import com.keylesspalace.tusky.entity.Notification; -import com.keylesspalace.tusky.entity.Report; -import com.keylesspalace.tusky.entity.TimelineAccount; - -import java.util.Objects; - -/** - * Created by charlag on 12/07/2017. - *

- * Class to represent data required to display either a notification or a placeholder. - * 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 preferable to - * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and - * more native. - */ -public abstract class NotificationViewData { - private NotificationViewData() { - } - - public abstract long getViewDataId(); - - public abstract boolean deepEquals(NotificationViewData other); - - public static final class Concrete extends NotificationViewData { - private final Notification.Type type; - private final String id; - private final TimelineAccount account; - @Nullable - private final StatusViewData.Concrete statusViewData; - @Nullable - private final Report report; - - public Concrete(Notification.Type type, String id, TimelineAccount account, - @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { - this.type = type; - this.id = id; - this.account = account; - this.statusViewData = statusViewData; - this.report = report; - } - - public Notification.Type getType() { - return type; - } - - public String getId() { - return id; - } - - public TimelineAccount getAccount() { - return account; - } - - @Nullable - public StatusViewData.Concrete getStatusViewData() { - return statusViewData; - } - - @Nullable - public Report getReport() { - return report; - } - - @Override - public long getViewDataId() { - return id.hashCode(); - } - - @Override - public boolean deepEquals(NotificationViewData o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Concrete concrete = (Concrete) o; - return type == concrete.type && - Objects.equals(id, concrete.id) && - account.getId().equals(concrete.account.getId()) && - (Objects.equals(statusViewData, concrete.statusViewData)) && - (Objects.equals(report, concrete.report)); - } - - @Override - public int hashCode() { - - return Objects.hash(type, id, account, statusViewData); - } - - public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { - return new Concrete(type, id, account, statusViewData, report); - } - } - - public static final class Placeholder extends NotificationViewData { - private final long id; - private final boolean isLoading; - - public Placeholder(long id, boolean isLoading) { - this.id = id; - this.isLoading = isLoading; - } - - public boolean isLoading() { - return isLoading; - } - - @Override - public long getViewDataId() { - return id; - } - - @Override - public boolean deepEquals(NotificationViewData other) { - if (!(other instanceof Placeholder)) return false; - Placeholder that = (Placeholder) other; - return isLoading == that.isLoading && id == that.id; - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt new file mode 100644 index 00000000..759d633e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 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 . + */ + +/* + * 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 com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Report +import com.keylesspalace.tusky.entity.TimelineAccount + +data class NotificationViewData( + val type: Notification.Type, + val id: String, + val account: TimelineAccount, + var statusViewData: StatusViewData.Concrete?, + val report: Report? +) 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 f7125dc5..07b7f3db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -90,21 +90,6 @@ sealed class StatusViewData { this.isCollapsible = shouldTrimStatus(this.content) } - /** Helper for Java */ - fun copyWithStatus(status: Status): Concrete { - return copy(status = status) - } - - /** Helper for Java */ - fun copyWithExpanded(isExpanded: Boolean): Concrete { - return copy(isExpanded = isExpanded) - } - - /** Helper for Java */ - fun copyWithShowingContent(isShowingContent: Boolean): Concrete { - return copy(isShowingContent = isShowingContent) - } - /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml index da5a204b..db912f96 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_timeline_notifications.xml b/app/src/main/res/layout/fragment_timeline_notifications.xml index 8609453f..5386f8ba 100644 --- a/app/src/main/res/layout/fragment_timeline_notifications.xml +++ b/app/src/main/res/layout/fragment_timeline_notifications.xml @@ -1,4 +1,21 @@ + + + + + + + +