diff --git a/.editorconfig b/.editorconfig index 2c00134c9..edbf56e5e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -40,14 +40,19 @@ ij_xml_text_wrap = normal ij_xml_use_custom_settings = true [{*.kt,*.kts,*.main.kts}] -ij_kotlin_align_in_columns_case_branch = true +# ktlint configuration +ktlint_code_style = ktlint_official +ktlint_function_naming_ignore_when_annotated_with = Composable +ij_continuation_indent_size = 4 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_align_in_columns_case_branch = false ij_kotlin_align_multiline_binary_operation = false ij_kotlin_align_multiline_extends_list = false ij_kotlin_align_multiline_method_parentheses = false ij_kotlin_align_multiline_parameters = true ij_kotlin_align_multiline_parameters_in_calls = false ij_kotlin_allow_trailing_comma = true -ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_assignment_wrap = normal ij_kotlin_blank_lines_after_class_header = 0 ij_kotlin_blank_lines_around_block_when_branches = 0 @@ -59,7 +64,6 @@ ij_kotlin_call_parameters_right_paren_on_new_line = true ij_kotlin_call_parameters_wrap = on_every_item ij_kotlin_catch_on_new_line = false ij_kotlin_class_annotation_wrap = split_into_lines -ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_kotlin_continuation_indent_for_chained_calls = false ij_kotlin_continuation_indent_for_expression_bodies = false ij_kotlin_continuation_indent_in_argument_lists = false @@ -70,7 +74,7 @@ ij_kotlin_continuation_indent_in_supertype_lists = false ij_kotlin_else_on_new_line = false ij_kotlin_enum_constants_wrap = off ij_kotlin_extends_list_wrap = normal -ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_field_annotation_wrap = off ij_kotlin_finally_on_new_line = false ij_kotlin_if_rparen_on_new_line = true ij_kotlin_import_nested_classes = false @@ -84,8 +88,8 @@ ij_kotlin_keep_indents_on_empty_lines = false ij_kotlin_keep_line_breaks = true ij_kotlin_lbrace_on_next_line = false ij_kotlin_line_break_after_multiline_when_entry = true -ij_kotlin_line_comment_add_space = false -ij_kotlin_line_comment_add_space_on_reformat = false +ij_kotlin_line_comment_add_space = true +ij_kotlin_line_comment_add_space_on_reformat = true ij_kotlin_line_comment_at_first_column = true ij_kotlin_method_annotation_wrap = split_into_lines ij_kotlin_method_call_chain_wrap = normal @@ -123,3 +127,4 @@ ij_kotlin_while_on_new_line = false ij_kotlin_wrap_elvis_expressions = 1 ij_kotlin_wrap_expression_body_functions = 1 ij_kotlin_wrap_first_method_in_call_chain = false +ktfmt_trailing_comma_management_strategy = complete diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8bf67899d..f6b604bd6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -33,19 +33,25 @@ jobs: steps: - uses: actions/checkout@v6 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v5 with: distribution: 'temurin' - java-version: '17' + java-version: '21' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Lint - run: bash ./gradlew lintVitalRelease + - name: Spotless check + run: bash ./gradlew :app:spotlessCheck + + - name: Detekt + run: bash ./gradlew :app:detektDebug + + - name: Android Lint + run: bash ./gradlew :app:lintDebug build: name: Generate apk @@ -117,6 +123,12 @@ jobs: fileDir: ${{ github.workspace }} encodedString: ${{ secrets.SIGNING_KEY }} + - name: Extract version name + id: version + run: | + base=$(grep 'versionName' app/build.gradle.kts | head -1 | sed 's/.*"\(.*\)".*/\1/') + echo "tag=v${base}-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + - name: Build signed release apk run: bash ./gradlew app:assembleRelease env: @@ -130,17 +142,11 @@ jobs: name: Signed release apk path: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk - - name: Update release tag - uses: richardsimko/update-tag@v1.1.6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: release - - - name: Update signed release + - name: Update release uses: ncipollo/release-action@v1.21.0 with: - tag: release - name: Signed release APK + tag: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} artifacts: ${{ github.workspace }}/app/build/outputs/apk/release/*.apk allowUpdates: true + makeLatest: true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 34ff1cfa8..3c6349967 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,20 +1,21 @@ -@file:Suppress("UnstableApiUsage") - +import com.android.build.api.artifact.ArtifactTransformationRequest +import com.android.build.api.artifact.SingleArtifact import com.android.build.gradle.internal.PropertiesValueSource -import com.android.build.gradle.internal.api.BaseVariantOutputImpl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.StringReader import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) - alias(libs.plugins.nav.safeargs.kotlin) alias(libs.plugins.ksp) + alias(libs.plugins.koin.compiler) alias(libs.plugins.about.libraries.android) + alias(libs.plugins.android.junit5) + alias(libs.plugins.spotless) + alias(libs.plugins.detekt) } android { @@ -23,15 +24,13 @@ android { defaultConfig { applicationId = "com.flxrs.dankchat" - minSdk = 23 + minSdk = 30 targetSdk = 35 - versionCode = 31111 - versionName = "3.11.11" + versionCode = 40022 + versionName = "4.0.22" } - androidResources { - generateLocaleConfig = true - } + androidResources { generateLocaleConfig = true } val localProperties = gradleLocalProperties(rootDir, providers) signingConfigs { @@ -43,11 +42,6 @@ android { } } - sourceSets { - getByName("main") { - java.srcDir("src/main/kotlin") - } - } buildFeatures { viewBinding = true buildConfig = true @@ -60,6 +54,8 @@ android { } } + testOptions { unitTests.isReturnDefaultValues = true } + buildTypes { getByName("release") { isMinifyEnabled = true @@ -73,52 +69,56 @@ android { manifestPlaceholders["applicationLabel"] = "@string/app_name" } create("dank") { - initWith(getByName("debug")) + initWith(getByName("release")) proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") manifestPlaceholders["applicationLabel"] = "@string/app_name_dank" applicationIdSuffix = ".dank" isDefault = true + signingConfig = signingConfigs.getByName("debug") } } - buildOutputs.all { - (this as? BaseVariantOutputImpl)?.apply { - val appName = "DankChat-${name}.apk" - outputFileName = appName - } + androidComponents.onVariants { variant -> + val renameTask = tasks.register("renameApk${variant.name.replaceFirstChar { it.uppercase() }}") { apkName.set("DankChat-${variant.name}.apk") } + val transformationRequest = + variant.artifacts + .use(renameTask) + .wiredWithDirectories(RenameApkTask::inputDirs, RenameApkTask::outputDirs) + .toTransformMany(SingleArtifact.APK) + renameTask.configure { this.transformationRequest = transformationRequest } } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } - //noinspection WrongGradleMethod - androidComponents { - beforeVariants { - sourceSets.named("main") { - java.srcDir(File("build/generated/ksp/${it.name}/kotlin")) - } - } + lint { + disable += "RestrictedApi" + disable += "UnusedResources" + disable += "ObsoleteSdkInt" + disable += "PictureInPictureIssue" + disable += "OldTargetApi" + disable += "GradleDependency" + disable += "NewerVersionAvailable" } } ksp { arg("room.schemaLocation", "${layout.projectDirectory}/schemas") - arg("KOIN_CONFIG_CHECK", "true") - arg("KOIN_DEFAULT_MODULE", "false") - arg("KOIN_USE_COMPOSE_VIEWMODEL", "true") } -tasks.withType { - useJUnitPlatform() +koinCompiler { + compileSafety = true } +tasks.withType { useJUnitPlatform() } + kotlin { - jvmToolchain(jdkVersion = 17) + jvmToolchain(jdkVersion = 21) compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) freeCompilerArgs.addAll( "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.FlowPreview", @@ -129,17 +129,19 @@ kotlin { "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", "-opt-in=kotlin.uuid.ExperimentalUuidApi", "-opt-in=kotlin.time.ExperimentalTime", - "-Xnon-local-break-continue", - "-Xwhen-guards", + "-opt-in=kotlin.concurrent.atomics.ExperimentalAtomicApi", ) } } dependencies { -// D8 desugaring + // Detekt plugins + detektPlugins(libs.detekt.compose.rules) + + // D8 desugaring coreLibraryDesugaring(libs.android.desugar.libs) -// Kotlin + // Kotlin implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) @@ -148,25 +150,18 @@ dependencies { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.immutable.collections) -// AndroidX + // AndroidX implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) - implementation(libs.androidx.constraintlayout) implementation(libs.androidx.emoji2) implementation(libs.androidx.exifinterface) - implementation(libs.androidx.fragment.ktx) - implementation(libs.androidx.transition.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.media) - implementation(libs.androidx.navigation.fragment.ktx) - implementation(libs.androidx.navigation.ui.ktx) implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.viewpager2) implementation(libs.androidx.webkit) implementation(libs.androidx.room.runtime) implementation(libs.androidx.datastore.core) @@ -174,7 +169,7 @@ dependencies { implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) -// Compose + // Compose implementation(libs.compose.animation) implementation(libs.compose.foundation) implementation(libs.compose.material3) @@ -186,60 +181,130 @@ dependencies { implementation(libs.compose.icons.core) implementation(libs.compose.icons.extended) implementation(libs.compose.unstyled) + implementation(libs.compose.material3.adaptive) -// Material - implementation(libs.android.material) + // Theme & splash + implementation(libs.appcompat) + implementation(libs.splashscreen) implementation(libs.android.flexbox) -// Dependency injection + // Dependency injection implementation(platform(libs.koin.bom)) implementation(libs.koin.core) implementation(libs.koin.android) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.annotations) - implementation(libs.koin.ksp.compiler) - ksp(libs.koin.ksp.compiler) -// Image loading + // Image loading implementation(libs.coil) implementation(libs.coil.gif) implementation(libs.coil.ktor) implementation(libs.coil.cache.control) implementation(libs.coil.compose) -// HTTP clients + // HTTP clients implementation(libs.okhttp) implementation(libs.okhttp.sse) implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.serialization.kotlinx.json) -// Other + // Other implementation(libs.colorpicker.android) + implementation(libs.materialkolor) implementation(libs.process.phoenix) + implementation(libs.logback.android) + implementation(libs.kotlin.logging) implementation(libs.autolinktext) implementation(libs.aboutlibraries.compose.m3) + implementation(libs.reorderable) -// Test + // Test testImplementation(libs.junit.jupiter.api) - testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.mockk) testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.ktor.client.okhttp) + testImplementation(libs.ktor.client.websockets) + testImplementation(libs.okhttp.mockwebserver) } -fun gradleLocalProperties(projectRootDir: File, providers: ProviderFactory): Properties { - val properties = Properties() - val propertiesContent = - providers.of(PropertiesValueSource::class.java) { - parameters.projectRoot.set(projectRootDir) - }.get() +junitPlatform { + filters { + if (!project.hasProperty("includeIntegration")) { + excludeTags("integration") + } + } +} - StringReader(propertiesContent).use { reader -> - properties.load(reader) +spotless { + kotlin { + target("src/**/*.kt") + targetExclude("${layout.buildDirectory}/**/*.kt") + ktlint(libs.versions.ktlint.get()) + .editorConfigOverride( + mapOf( + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + "ktlint_standard_backing-property-naming" to "disabled", + "ktlint_standard_filename" to "disabled", + "ktlint_standard_property-naming" to "disabled", + "ktlint_standard_multiline-expression-wrapping" to "disabled", + "ktlint_function_signature_body_expression_wrapping" to "default", + ), + ) + } + kotlinGradle { + target("*.gradle.kts") + ktlint(libs.versions.ktlint.get()) } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom("$projectDir/config/detekt.yml") + parallel = true +} + +tasks.withType().configureEach { + exclude { + it.file.absolutePath.contains("/build/generated/") + } +} + +fun gradleLocalProperties( + projectRootDir: File, + providers: ProviderFactory, +): Properties { + val properties = Properties() + val propertiesContent = providers.of(PropertiesValueSource::class.java) { parameters.projectRoot.set(projectRootDir) }.get() + + StringReader(propertiesContent).use { reader -> properties.load(reader) } return properties } + +abstract class RenameApkTask : DefaultTask() { + @get:InputDirectory abstract val inputDirs: DirectoryProperty + + @get:OutputDirectory abstract val outputDirs: DirectoryProperty + + @get:Input abstract val apkName: Property + + @get:Internal lateinit var transformationRequest: ArtifactTransformationRequest + + @TaskAction + fun taskAction() { + transformationRequest.submit(this) { builtArtifact -> + val inputFile = File(builtArtifact.outputFile) + val outputFile = File(outputDirs.get().asFile, apkName.get()) + inputFile.copyTo(outputFile, overwrite = true) + outputFile + } + } +} diff --git a/app/config/detekt.yml b/app/config/detekt.yml new file mode 100644 index 000000000..114233971 --- /dev/null +++ b/app/config/detekt.yml @@ -0,0 +1,66 @@ +# Detekt configuration — builds upon the default config. +# Only overrides are listed here; everything else uses defaults. +# See https://detekt.dev/docs/rules/overview for all rules. + +complexity: + LongMethod: + active: false + LongParameterList: + active: false + TooManyFunctions: + active: false + CyclomaticComplexMethod: + active: false + NestedBlockDepth: + active: false + LargeClass: + active: false + ComplexCondition: + allowedConditions: 4 + +naming: + FunctionNaming: + ignoreAnnotated: ['Composable'] + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9_]*' + MatchingDeclarationName: + active: false + +performance: + SpreadOperator: + active: false + +style: + MagicNumber: + active: false + MaxLineLength: + maxLineLength: 210 + excludeCommentStatements: true + excludeRawStrings: true + ReturnCount: + max: 8 + ForbiddenComment: + active: false + DestructuringDeclarationWithTooManyEntries: + maxDestructuringEntries: 5 + LoopWithTooManyJumpStatements: + maxJumpCount: 3 + +potential-bugs: + IgnoredReturnValue: + active: false + +exceptions: + TooGenericExceptionCaught: + active: false + +Compose: + ModifierMissing: + active: false + LambdaParameterInRestartableEffect: + active: false + CompositionLocalAllowlist: + allowedCompositionLocals: + - LocalEmoteAnimationCoordinator + - LocalAdaptiveColors + - LocalContentAlpha diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index dc17c2b80..6c9ed529e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,6 +11,11 @@ -dontwarn com.oracle.svm.core.annotate.TargetClass -dontwarn org.slf4j.impl.StaticLoggerBinder +# logback-android +-keep class ch.qos.logback.** { *; } +-keep class org.slf4j.** { *; } +-dontwarn ch.qos.logback.core.net.* + # Keep `Companion` object fields of serializable classes. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. -if @kotlinx.serialization.Serializable class ** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 483e2b67f..eb2c228f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,13 +30,15 @@ tools:ignore="AllowBackup,GoogleAppIndexingWarning" tools:targetApi="tiramisu"> + + + + + + + + + + + + + + + [%thread] %msg + + + %logger{23} + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${DATA_DIR}/logs/dankchat.%d{yyyy-MM-dd}.%i.log + 10MB + 3 + 30MB + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt index 03100d267..093b7e3e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatApplication.kt @@ -1,11 +1,7 @@ package com.flxrs.dankchat import android.app.Application -import android.app.UiModeManager -import android.content.res.Configuration -import android.os.Build import androidx.appcompat.app.AppCompatDelegate -import androidx.core.content.getSystemService import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader @@ -13,16 +9,19 @@ import coil3.annotation.ExperimentalCoilApi import coil3.disk.DiskCache import coil3.disk.directory import coil3.gif.AnimatedImageDecoder -import coil3.gif.GifDecoder import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.ktor3.KtorNetworkFetcherFactory import com.flxrs.dankchat.data.repo.HighlightsRepository import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.crash.CrashHandler +import com.flxrs.dankchat.data.repo.crash.CrashRepository import com.flxrs.dankchat.di.DankChatModule import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.domain.ConnectionCoordinator import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.appearance.ThemePreference.Dark import com.flxrs.dankchat.preferences.appearance.ThemePreference.System +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.tryClearEmptyFiles import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -31,30 +30,47 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin -import org.koin.ksp.generated.module +import org.koin.core.annotation.KoinApplication +import org.koin.dsl.module +import org.koin.plugin.module.dsl.startKoin -class DankChatApplication : Application(), SingletonImageLoader.Factory { +@KoinApplication(modules = [DankChatModule::class]) +object DankChatKoinApp +class DankChatApplication : + Application(), + SingletonImageLoader.Factory { private val dispatchersProvider: DispatchersProvider by inject() private val scope by lazy { CoroutineScope(SupervisorJob() + dispatchersProvider.main) } private val highlightsRepository: HighlightsRepository by inject() private val ignoresRepository: IgnoresRepository by inject() private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() + private val connectionCoordinator: ConnectionCoordinator by inject() + private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() override fun onCreate() { super.onCreate() - startKoin { + val crashHandler = CrashHandler( + context = this, + isCrashReportingEnabled = { developerSettingsDataStore.current().debugMode }, + ) + crashHandler.install() + startKoin { androidContext(this@DankChatApplication) - modules(DankChatModule().module) + modules( + module { + single { CrashRepository(crashHandler.dataStore) } + }, + ) } - scope.launch(dispatchersProvider.immediate) { - setupThemeMode() - } + connectionCoordinator.initialize() + + setupThemeMode() highlightsRepository.runMigrationsIfNeeded() ignoresRepository.runMigrationsIfNeeded() @@ -63,48 +79,38 @@ class DankChatApplication : Application(), SingletonImageLoader.Factory { } } - private suspend fun setupThemeMode() { - val uiModeManager = getSystemService() - val isTv = uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION - val theme = appearanceSettingsDataStore.settings.first().theme - - val supportsLightMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 || isTv - val supportsSystemDarkMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - - val nightMode = when { - // Force dark theme on < Android 8.1 because of statusbar/navigationbar issues, or if system dark mode is not supported - theme == Dark || !supportsLightMode || (theme == System && !supportsSystemDarkMode) -> AppCompatDelegate.MODE_NIGHT_YES - theme == System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - else -> AppCompatDelegate.MODE_NIGHT_NO + private fun setupThemeMode() { + val theme = runBlocking { appearanceSettingsDataStore.settings.first().theme } + val nightMode = when (theme) { + Dark -> AppCompatDelegate.MODE_NIGHT_YES + System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> AppCompatDelegate.MODE_NIGHT_NO } AppCompatDelegate.setDefaultNightMode(nightMode) } @OptIn(ExperimentalCoilApi::class) - override fun newImageLoader(context: PlatformContext): ImageLoader { - return ImageLoader.Builder(this) - .diskCache { - DiskCache.Builder() - .directory(context.cacheDir.resolve("image_cache")) - .build() - } - .components { - val decoder = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> AnimatedImageDecoder.Factory() - else -> GifDecoder.Factory() //GifDrawableDecoder.Factory() - } - add(decoder) - val client = HttpClient(OkHttp) { + override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader + .Builder(this) + .diskCache { + DiskCache + .Builder() + .directory(context.cacheDir.resolve("image_cache")) + .build() + }.components { + // minSdk 30 guarantees AnimatedImageDecoder support (API 28+) + add(AnimatedImageDecoder.Factory()) + val client = + HttpClient(OkHttp) { install(UserAgent) { agent = "dankchat/${BuildConfig.VERSION_NAME}" } } - val fetcher = KtorNetworkFetcherFactory( + val fetcher = + KtorNetworkFetcherFactory( httpClient = { client }, cacheStrategy = { CacheControlCacheStrategy() }, ) - add(fetcher) - } - .build() - } + add(fetcher) + }.build() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt index 13f2c5d74..6734166a8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/DankChatViewModel.kt @@ -1,103 +1,52 @@ package com.flxrs.dankchat -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.api.ApiException -import com.flxrs.dankchat.data.api.auth.AuthApiClient -import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.AuthStateCoordinator +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class DankChatViewModel( - private val chatRepository: ChatRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val authApiClient: AuthApiClient, + private val authDataStore: AuthDataStore, private val dataRepository: DataRepository, + private val chatChannelProvider: ChatChannelProvider, + private val authStateCoordinator: AuthStateCoordinator, + appearanceSettingsDataStore: AppearanceSettingsDataStore, ) : ViewModel() { - val serviceEvents = dataRepository.serviceEvents - private var started = false - - private val _validationResult = Channel(Channel.BUFFERED) - val validationResult get() = _validationResult.receiveAsFlow() - - val isTrueDarkModeEnabled get() = appearanceSettingsDataStore.current().trueDarkTheme - val keepScreenOn = appearanceSettingsDataStore.settings - .map { it.keepScreenOn } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = appearanceSettingsDataStore.current().keepScreenOn, - ) - - fun init(tryReconnect: Boolean) { - viewModelScope.launch { - if (tryReconnect && started) { - chatRepository.reconnectIfNecessary() - dataRepository.reconnectIfNecessary() - } else { - started = true - - if (dankChatPreferenceStore.isLoggedIn) { - validateUser() - } - - chatRepository.connectAndJoin() - } - } - } - - fun checkLogin() { - if (dankChatPreferenceStore.isLoggedIn && dankChatPreferenceStore.oAuthKey.isNullOrBlank()) { - dankChatPreferenceStore.clearLogin() - } - } - - private suspend fun validateUser() { - // no token = nothing to validate 4head - val token = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return - val result = authApiClient.validateUser(token) - .fold( - onSuccess = { result -> - dankChatPreferenceStore.userName = result.login - when { - authApiClient.validateScopes(result.scopes.orEmpty()) -> ValidationResult.User(result.login) - else -> ValidationResult.IncompleteScopes(result.login) - } - }, - onFailure = { it.handleValidationError() } + val activeChannel = chatChannelProvider.activeChannel + val isLoggedIn: Flow = + authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() + + val keepScreenOn = + appearanceSettingsDataStore.settings + .map { it.keepScreenOn } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = appearanceSettingsDataStore.current().keepScreenOn, ) - _validationResult.send(result) - } - - private fun Throwable.handleValidationError() = when { - this is ApiException && status == HttpStatusCode.Unauthorized -> { - dankChatPreferenceStore.clearLogin() - ValidationResult.TokenInvalid - } - else -> { - Log.e(TAG, "Failed to validate token: $message") - ValidationResult.Failure + fun checkLogin() { + if (authDataStore.isLoggedIn && authDataStore.oAuthKey.isNullOrBlank()) { + authStateCoordinator.logout() } } - companion object { - private val TAG = DankChatViewModel::class.java.simpleName + fun clearDataForLogout() { + authStateCoordinator.logout() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ValidationResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/ValidationResult.kt deleted file mode 100644 index f45f8b7a2..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/ValidationResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.flxrs.dankchat - -import com.flxrs.dankchat.data.UserName - -sealed interface ValidationResult { - data class User(val username: UserName) : ValidationResult - data class IncompleteScopes(val username: UserName) : ValidationResult - data object TokenInvalid : ValidationResult - data object Failure : ValidationResult -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogAdapter.kt deleted file mode 100644 index 3884925c3..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogAdapter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.flxrs.dankchat.changelog - -import android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.text.buildSpannedString -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.databinding.ChangelogItemBinding -import com.flxrs.dankchat.utils.extensions.px -import com.flxrs.dankchat.utils.span.ImprovedBulletSpan - -class ChangelogAdapter : ListAdapter(DetectDiff()) { - - inner class ViewHolder(val binding: ChangelogItemBinding) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(ChangelogItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.binding.changelogEntry.text = buildSpannedString { - val item = getItem(position) - append(item, ImprovedBulletSpan(gapWidth = 8.px, bulletRadius = 3.px), SPAN_INCLUSIVE_EXCLUSIVE) - } - } - - private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem - override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetFragment.kt deleted file mode 100644 index f37864c09..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetFragment.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.flxrs.dankchat.changelog - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.ChangelogBottomsheetBinding -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ChangelogSheetFragment : BottomSheetDialogFragment() { - - private val viewModel: ChangelogSheetViewModel by viewModel() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val adapter = ChangelogAdapter() - return ChangelogBottomsheetBinding.inflate(inflater, container, false).apply { - changelogEntries.adapter = adapter - when (val state = viewModel.state) { - null -> root.post { dialog?.dismiss() } - else -> { - changelogSubtitle.text = getString(R.string.changelog_sheet_subtitle, state.version) - val entries = state.changelog.split("\n") - adapter.submitList(entries) - } - } - }.root - } - - override fun onResume() { - super.onResume() - dialog?.takeIf { isLandscape }?.let { - with(it as BottomSheetDialog) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - behavior.skipCollapsed = true - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt deleted file mode 100644 index 723798f4d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogState.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.flxrs.dankchat.changelog - -data class ChangelogState(val version: String, val changelog: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatChangelog.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatChangelog.kt deleted file mode 100644 index b58577548..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatChangelog.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.flxrs.dankchat.changelog - -@Suppress("unused") -enum class DankChatChangelog(val version: DankChatVersion, val string: String) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatVersion.kt b/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatVersion.kt deleted file mode 100644 index bd8b5040e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/DankChatVersion.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.flxrs.dankchat.changelog - -import com.flxrs.dankchat.BuildConfig - -data class DankChatVersion(val major: Int, val minor: Int, val patch: Int) : Comparable { - - override fun compareTo(other: DankChatVersion): Int = COMPARATOR.compare(this, other) - - fun formattedString(): String = "$major.$minor.$patch" - - companion object { - private val CURRENT = fromString(BuildConfig.VERSION_NAME)!! - private val COMPARATOR = Comparator - .comparingInt(DankChatVersion::major) - .thenComparingInt(DankChatVersion::minor) - .thenComparingInt(DankChatVersion::patch) - - fun fromString(version: String): DankChatVersion? { - return version.split(".") - .mapNotNull(String::toIntOrNull) - .takeIf { it.size == 3 } - ?.let { (major, minor, patch) -> DankChatVersion(major, minor, patch) } - } - - val LATEST_CHANGELOG = DankChatChangelog.entries.findLast { CURRENT >= it.version } - val HAS_CHANGELOG = LATEST_CHANGELOG != null - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsAdapter.kt deleted file mode 100644 index e080b7b72..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsAdapter.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.flxrs.dankchat.channels - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.graphics.ColorUtils -import androidx.core.text.buildSpannedString -import androidx.core.text.color -import androidx.core.text.italic -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.ChannelsItemBinding -import com.flxrs.dankchat.preferences.model.ChannelWithRename -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ChannelsAdapter( - private val dankChatPreferences: DankChatPreferenceStore, - private val onEditChannel: (ChannelWithRename) -> Unit -) : ListAdapter(DetectDiff()) { - class ChannelViewHolder(val binding: ChannelsItemBinding) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChannelViewHolder { - return ChannelViewHolder(ChannelsItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun onBindViewHolder(holder: ChannelViewHolder, position: Int) = with(holder.binding) { - val (channel, rename) = getItem(position) - channelText.text = buildSpannedString { - append(rename?.value ?: channel.value) - rename - ?.takeIf { it != channel } - ?.let { - val channelColor = ColorUtils.setAlphaComponent(channelText.currentTextColor, 128) - color(channelColor) { italic { append(" $channel") } } - } - } - channelDelete.setOnClickListener { - MaterialAlertDialogBuilder(root.context) - .setTitle(R.string.confirm_channel_removal_title) - .setMessage(R.string.confirm_channel_removal_message) - .setPositiveButton(R.string.confirm_channel_removal_positive_button) { dialog, _ -> - dankChatPreferences.removeChannel(channel) - dialog.dismiss() - } - .setNegativeButton(R.string.dialog_cancel) { dialog, _ -> dialog.dismiss() } - .create().show() - } - channelEdit.setOnClickListener { - val editedChannel = currentList.getOrNull(holder.bindingAdapterPosition) ?: return@setOnClickListener - onEditChannel(editedChannel) - } - } -} - -private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ChannelWithRename, newItem: ChannelWithRename): Boolean = oldItem.channel == newItem.channel - override fun areContentsTheSame(oldItem: ChannelWithRename, newItem: ChannelWithRename): Boolean = oldItem == newItem -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsDialogFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsDialogFragment.kt deleted file mode 100644 index 333f3449d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/channels/ChannelsDialogFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.flxrs.dankchat.channels - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.ChannelsFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.model.ChannelWithRename -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.navigateSafe -import com.flxrs.dankchat.utils.extensions.swap -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import org.koin.android.ext.android.inject -import org.koin.java.KoinJavaComponent.inject - -class ChannelsDialogFragment : BottomSheetDialogFragment() { - - private val dankChatPreferences: DankChatPreferenceStore by inject() - - private var adapter: ChannelsAdapter? = null - private val navController: NavController by lazy { findNavController() } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - adapter = ChannelsAdapter(dankChatPreferences, ::openRenameChannelDialog).also { - it.registerAdapterDataObserver(dataObserver) - } - val binding = ChannelsFragmentBinding.inflate(inflater, container, false).apply { - channelsList.adapter = adapter - val helper = ItemTouchHelper(itemTouchHelperCallback) - helper.attachToRecyclerView(channelsList) - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - collectFlow(dankChatPreferences.getChannelsWithRenamesFlow()) { - adapter?.submitList(it) - } - } - - override fun onDestroyView() { - adapter?.unregisterAdapterDataObserver(dataObserver) - adapter = null - super.onDestroyView() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - adapter?.let { - navController - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.CHANNELS_REQUEST_KEY] = it.currentList.toTypedArray() - } - } - - private fun openRenameChannelDialog(channelWithRename: ChannelWithRename) { - val direction = ChannelsDialogFragmentDirections.actionChannelsFragmentToEditChannelDialogFragment(channelWithRename) - navigateSafe(direction) - } - - private val itemTouchHelperCallback = object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - val adapter = adapter ?: return false - adapter.currentList.toMutableList().let { - it.swap(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) - adapter.submitList(it) - } - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - val adapter = adapter ?: return - dankChatPreferences.channels = adapter.currentList.map(ChannelWithRename::channel) - } - } - - private val dataObserver = object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { - val adapter = adapter ?: return - if (adapter.currentList.isEmpty()) { - dismiss() - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt deleted file mode 100644 index 2b752969b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatAdapter.kt +++ /dev/null @@ -1,974 +0,0 @@ -package com.flxrs.dankchat.chat - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Context -import android.graphics.Color -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Rect -import android.graphics.Typeface -import android.graphics.drawable.Animatable -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.LayerDrawable -import android.graphics.drawable.RippleDrawable -import android.os.Build -import android.text.Spannable -import android.text.SpannableStringBuilder -import android.text.TextPaint -import android.text.style.ImageSpan -import android.text.style.RelativeSizeSpan -import android.text.style.StyleSpan -import android.text.style.TextAppearanceSpan -import android.text.style.TypefaceSpan -import android.text.style.URLSpan -import android.text.util.Linkify -import android.util.Log -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.annotation.Px -import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable -import androidx.core.net.toUri -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.clearSpans -import androidx.core.text.color -import androidx.core.text.getSpans -import androidx.core.text.inSpans -import androidx.core.text.set -import androidx.core.text.util.LinkifyCompat -import androidx.core.view.isVisible -import androidx.emoji2.text.EmojiCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import coil3.asDrawable -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.transformations -import coil3.transform.CircleCropTransformation -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.emote.EmoteRepository -import com.flxrs.dankchat.data.repo.emote.EmoteRepository.Companion.cacheKey -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.data.twitch.message.Highlight -import com.flxrs.dankchat.data.twitch.message.HighlightType -import com.flxrs.dankchat.data.twitch.message.ModerationMessage -import com.flxrs.dankchat.data.twitch.message.NoticeMessage -import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage -import com.flxrs.dankchat.data.twitch.message.PrivMessage -import com.flxrs.dankchat.data.twitch.message.SystemMessage -import com.flxrs.dankchat.data.twitch.message.SystemMessageType -import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage -import com.flxrs.dankchat.data.twitch.message.WhisperMessage -import com.flxrs.dankchat.data.twitch.message.aliasOrFormattedName -import com.flxrs.dankchat.data.twitch.message.customOrUserColorOn -import com.flxrs.dankchat.data.twitch.message.hasMention -import com.flxrs.dankchat.data.twitch.message.highestPriorityHighlight -import com.flxrs.dankchat.data.twitch.message.recipientAliasOrFormattedName -import com.flxrs.dankchat.data.twitch.message.recipientColorOnBackground -import com.flxrs.dankchat.data.twitch.message.senderAliasOrFormattedName -import com.flxrs.dankchat.data.twitch.message.senderColorOnBackground -import com.flxrs.dankchat.databinding.ChatItemBinding -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore -import com.flxrs.dankchat.utils.DateTimeUtils -import com.flxrs.dankchat.utils.extensions.forEachLayer -import com.flxrs.dankchat.utils.extensions.indexOfFirst -import com.flxrs.dankchat.utils.extensions.isEven -import com.flxrs.dankchat.utils.extensions.setRunning -import com.flxrs.dankchat.utils.showErrorDialog -import com.flxrs.dankchat.utils.span.LongClickLinkMovementMethod -import com.flxrs.dankchat.utils.span.LongClickableSpan -import com.google.android.material.color.MaterialColors -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlin.math.roundToInt - -class ChatAdapter( - private val emoteRepository: EmoteRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, - private val chatSettingsDataStore: ChatSettingsDataStore, - private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val onListChanged: (position: Int) -> Unit, - private val onUserClick: (targetUserId: UserId?, targetUsername: UserName, targetDisplayName: DisplayName, channelName: UserName?, badges: List, isLongPress: Boolean) -> Unit, - private val onMessageLongClick: (messageId: String, channel: UserName?, fullMessage: String) -> Unit, - private val onReplyClick: (messageId: String) -> Unit, - private val onEmoteClick: (emotes: List) -> Unit, -) : ListAdapter(DetectDiff()) { - // Using position.isEven for determining which background to use in checkered mode doesn't work, - // since the LayoutManager uses stackFromEnd and every new message will be even. Instead, keep count of new messages separately. - private var messageCount = 0 - get() = field++ - - companion object { - private val DISALLOWED_URL_CHARS = """<>\{}|^"`""".toSet() - private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 - private const val BASE_HEIGHT_CONSTANT = 1.173 - private const val MONOSPACE_FONT_PROPORTION = 0.95f // make monospace font a bit smaller to make looks same sized as normal text - private val MASK_FULL = Color.argb(255, 0, 0, 0).toDrawable() - private val MASK_NONE = Color.argb(0, 0, 0, 0).toDrawable() - private fun getBaseHeight(@Px textSize: Float): Int = (textSize * BASE_HEIGHT_CONSTANT).roundToInt() - } - - private val customTabsIntent = CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - - inner class ViewHolder(val binding: ChatItemBinding) : RecyclerView.ViewHolder(binding.root) { - val scope = CoroutineScope(Dispatchers.Main.immediate) - val coroutineHandler = CoroutineExceptionHandler { _, throwable -> binding.itemText.handleException(throwable) } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(ChatItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { - onListChanged(currentList.lastIndex) - } - - override fun onViewRecycled(holder: ViewHolder) { - holder.scope.coroutineContext.cancelChildren() - (holder.binding.itemText.text as? Spannable)?.clearSpans() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - emoteRepository.gifCallback.removeView(holder.binding.itemText) - } - - super.onViewRecycled(holder) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = getItem(position) - holder.scope.coroutineContext.cancelChildren() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - emoteRepository.gifCallback.removeView(holder.binding.itemText) - } - - holder.binding.replyGroup.isVisible = false - holder.binding.itemLayout.setBackgroundColor(Color.TRANSPARENT) - holder.binding.itemText.alpha = when (item.importance) { - ChatImportance.SYSTEM -> .75f - ChatImportance.DELETED -> .5f - ChatImportance.REGULAR -> 1f - } - - when (val message = item.message) { - is SystemMessage -> holder.binding.itemText.handleSystemMessage(message, holder) - is NoticeMessage -> holder.binding.itemText.handleNoticeMessage(message, holder) - is UserNoticeMessage -> holder.binding.itemText.handleUserNoticeMessage(message, holder) - is PrivMessage -> with(holder.binding) { - if (message.thread != null && !item.isInReplies) { - replyGroup.isVisible = true - val formatted = buildString { - append(itemReply.context.getString(R.string.reply_to)) - // add LTR mark if necessary - if (itemReply.layoutDirection == View.LAYOUT_DIRECTION_RTL) { - append('\u200E') - } - append(" @${message.thread.name}: ") - append(message.thread.message) - } - itemReply.text = formatted - itemReply.setOnClickListener { onReplyClick(message.thread.rootId) } - } - - itemText.handlePrivMessage(message, holder, item.isMentionTab) - } - - is ModerationMessage -> holder.binding.itemText.handleModerationMessage(message, holder) - is PointRedemptionMessage -> holder.binding.itemText.handlePointRedemptionMessage(message, holder) - is WhisperMessage -> holder.binding.itemText.handleWhisperMessage(message, holder) - } - } - - private val ViewHolder.isAlternateBackground - get() = when (bindingAdapterPosition) { - itemCount - 1 -> messageCount.isEven - else -> (bindingAdapterPosition - itemCount - 1).isEven - } - - private fun TextView.handleNoticeMessage(message: NoticeMessage, holder: ViewHolder) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val background = when { - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - this, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - holder.binding.itemLayout.setBackgroundColor(background) - setBackgroundColor(background) - - val withTime = when { - chatSettings.showTimestamps -> SpannableStringBuilder() - .timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(message.timestamp, chatSettings.formatter)) } - .append(message.message) - - else -> SpannableStringBuilder().append(message.message) - } - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - text = withTime - } - - private fun TextView.handleUserNoticeMessage(message: UserNoticeMessage, holder: ViewHolder) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val firstHighlightType = message.highlights.firstOrNull()?.type - val shouldHighlight = firstHighlightType == HighlightType.Subscription || firstHighlightType == HighlightType.Announcement - val background = when { - shouldHighlight -> message.highlights.toBackgroundColor(context) - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - this, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - holder.binding.itemLayout.setBackgroundColor(background) - setBackgroundColor(background) - - val withTime = when { - chatSettings.showTimestamps -> SpannableStringBuilder() - .timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(message.timestamp, chatSettings.formatter)) } - .append(message.message) - - else -> SpannableStringBuilder().append(message.message) - } - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - text = withTime - } - - private fun TextView.handleSystemMessage(message: SystemMessage, holder: ViewHolder) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val background = when { - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - this, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - holder.binding.itemLayout.setBackgroundColor(background) - setRippleBackground(background, enableRipple = false) - - val systemMessageText = when (message.type) { - is SystemMessageType.Disconnected -> context.getString(R.string.system_message_disconnected) - is SystemMessageType.NoHistoryLoaded -> context.getString(R.string.system_message_no_history) - is SystemMessageType.Connected -> context.getString(R.string.system_message_connected) - is SystemMessageType.Reconnected -> context.getString(R.string.system_message_reconnected) - is SystemMessageType.LoginExpired -> context.getString(R.string.login_expired) - is SystemMessageType.ChannelNonExistent -> context.getString(R.string.system_message_channel_non_existent) - is SystemMessageType.MessageHistoryIgnored -> context.getString(R.string.system_message_history_ignored) - is SystemMessageType.MessageHistoryIncomplete -> context.getString(R.string.system_message_history_recovering) - is SystemMessageType.ChannelBTTVEmotesFailed -> context.getString(R.string.system_message_bttv_emotes_failed, message.type.status) - is SystemMessageType.ChannelFFZEmotesFailed -> context.getString(R.string.system_message_ffz_emotes_failed, message.type.status) - is SystemMessageType.ChannelSevenTVEmotesFailed -> context.getString(R.string.system_message_7tv_emotes_failed, message.type.status) - is SystemMessageType.Custom -> message.type.message - is SystemMessageType.MessageHistoryUnavailable -> when (message.type.status) { - null -> context.getString(R.string.system_message_history_unavailable) - else -> context.getString(R.string.system_message_history_unavailable_detailed, message.type.status) - } - - is SystemMessageType.ChannelSevenTVEmoteAdded -> context.getString(R.string.system_message_7tv_emote_added, message.type.actorName, message.type.emoteName) - is SystemMessageType.ChannelSevenTVEmoteRemoved -> context.getString(R.string.system_message_7tv_emote_removed, message.type.actorName, message.type.emoteName) - is SystemMessageType.ChannelSevenTVEmoteRenamed -> context.getString( - R.string.system_message_7tv_emote_renamed, - message.type.actorName, - message.type.oldEmoteName, - message.type.emoteName - ) - - is SystemMessageType.ChannelSevenTVEmoteSetChanged -> context.getString(R.string.system_message_7tv_emote_set_changed, message.type.actorName, message.type.newEmoteSetName) - } - val withTime = when { - chatSettings.showTimestamps -> SpannableStringBuilder() - .timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(message.timestamp, chatSettings.formatter)) } - .append(systemMessageText) - - else -> SpannableStringBuilder().append(systemMessageText) - } - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - text = withTime - } - - private fun TextView.handleModerationMessage(message: ModerationMessage, holder: ViewHolder) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val background = when { - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - this, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - - holder.binding.itemLayout.setBackgroundColor(background) - setRippleBackground(background, enableRipple = false) - - val systemMessage = message.getSystemMessage(dankChatPreferenceStore.userName, chatSettings.showTimedOutMessages) - val withTime = when { - chatSettings.showTimestamps -> SpannableStringBuilder() - .timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(message.timestamp, chatSettings.formatter)) } - .append(systemMessage) - - else -> SpannableStringBuilder().append(systemMessage) - } - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - text = withTime - } - - private fun TextView.handlePointRedemptionMessage(message: PointRedemptionMessage, holder: ViewHolder) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val background = message.highlights.toBackgroundColor(context) - holder.binding.itemLayout.setBackgroundColor(background) - setRippleBackground(background, enableRipple = false) - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - val baseHeight = getBaseHeight(textSize) - - holder.scope.launch(holder.coroutineHandler) { - - val spannable = buildSpannedString { - if (chatSettings.showTimestamps) { - timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(message.timestamp, chatSettings.formatter)) } - } - - when { - message.requiresUserInput -> append("Redeemed ") - else -> { - bold { append(message.aliasOrFormattedName) } - append(" redeemed ") - } - } - - bold { append(message.title) } - append(" ") - append(" ${message.cost}") - } - setText(spannable, TextView.BufferType.SPANNABLE) - - val imageStart = spannable.lastIndexOf(' ') - 1 - context.imageLoader - .execute(message.rewardImageUrl.toRequest(context)) - .image - ?.asDrawable(resources) - ?.apply { - val width = (baseHeight * intrinsicWidth / intrinsicHeight.toFloat()).roundToInt() - setBounds(0, 0, width, baseHeight) - (text as Spannable)[imageStart..imageStart + 1] = ImageSpan(this, ImageSpan.ALIGN_BOTTOM) - } - } - } - - private fun TextView.handleWhisperMessage(whisperMessage: WhisperMessage, holder: ViewHolder) = with(whisperMessage) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val textView = this@handleWhisperMessage - isClickable = false - movementMethod = LongClickLinkMovementMethod - (text as? Spannable)?.clearSpans() - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - val textColor = MaterialColors.getColor(textView, R.attr.colorOnSurface) - setTextColor(textColor) - - val baseHeight = getBaseHeight(textSize) - val scaleFactor = baseHeight * SCALE_FACTOR_CONSTANT - val background = when { - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - textView, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - holder.binding.itemLayout.setBackgroundColor(background) - setRippleBackground(background, enableRipple = true) - - val allowedBadges = badges.filter { it.type in chatSettings.visibleBadgeTypes } - val badgesLength = allowedBadges.size * 2 - - val spannable = SpannableStringBuilder(StringBuilder()) - if (chatSettings.showTimestamps) { - spannable.timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter)) } - } - - val nameGroupLength = senderAliasOrFormattedName.length + 4 + recipientAliasOrFormattedName.length + 2 - val prefixLength = spannable.length + nameGroupLength - val badgePositions = allowedBadges.map { - spannable.append("⠀ ") - spannable.length - 2 to spannable.length - 1 - } - - val senderColor = senderColorOnBackground(background) - spannable.bold { color(senderColor) { append(senderAliasOrFormattedName) } } - spannable.append(" -> ") - - val recipientColor = recipientColorOnBackground(background) - spannable.bold { color(recipientColor) { append(recipientAliasOrFormattedName) } } - spannable.append(": ") - spannable.append(message) - - val userClickableSpan = object : LongClickableSpan() { - override fun onClick(v: View) = onUserClick(userId, name, displayName, null, badges, false) - override fun onLongClick(view: View) = onUserClick(userId, name, displayName, null, badges, true) - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - ds.color = senderColor - } - } - val userStart = prefixLength + badgesLength - nameGroupLength - val userEnd = userStart + senderAliasOrFormattedName.length - spannable[userStart..userEnd] = userClickableSpan - - val emojiCompat = EmojiCompat.get() - val messageStart = prefixLength + badgesLength - val messageEnd = messageStart + message.length - val spannableWithEmojis = when (emojiCompat.loadState) { - EmojiCompat.LOAD_STATE_SUCCEEDED -> emojiCompat.process(spannable, messageStart, messageEnd, Int.MAX_VALUE, EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT) - else -> spannable - } as SpannableStringBuilder - - val onWhisperMessageClick = { - onMessageLongClick(id, null, spannableWithEmojis.toString().replace("⠀ ", "")) - } - - addLinks(spannableWithEmojis, onWhisperMessageClick) - - // copying message - val messageClickableSpan = object : LongClickableSpan(checkBounds = false) { - override fun onClick(v: View) = Unit - override fun onLongClick(view: View) = onWhisperMessageClick() - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - - } - spannableWithEmojis[0..spannableWithEmojis.length] = messageClickableSpan - setText(spannableWithEmojis, TextView.BufferType.SPANNABLE) - - // todo extract common badges + emote handling - val animateGifs = chatSettings.animateGifs - var hasAnimatedEmoteOrBadge = false - holder.scope.launch(holder.coroutineHandler) { - allowedBadges.forEachIndexed { idx, badge -> - ensureActive() - try { - val (start, end) = badgePositions[idx] - val cacheKey = badge.cacheKey(baseHeight) - val cached = emoteRepository.badgeCache[cacheKey] - val drawable = when { - cached != null -> cached.also { - if (it is Animatable) { - it.setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true - } - } - - else -> context.imageLoader - .execute(badge.url.toRequest(context)) - .image - ?.asDrawable(resources) - ?.apply { - if (badge is Badge.FFZModBadge) { - val modColor = ContextCompat.getColor(context, R.color.color_ffz_mod) - colorFilter = PorterDuffColorFilter(modColor, PorterDuff.Mode.DST_OVER) - } - - val width = (baseHeight * intrinsicWidth / intrinsicHeight.toFloat()).roundToInt() - setBounds(0, 0, width, baseHeight) - if (this is Animatable) { - emoteRepository.badgeCache.put(cacheKey, this) - setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true - } - } - } - - if (drawable != null) { - val imageSpan = ImageSpan(drawable) - (text as Spannable)[start..end] = imageSpan - } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - handleException(t) - } - } - - // Remove message clickable span because emote spans have to be set before we add the span covering the full message - (text as Spannable).removeSpan(messageClickableSpan) - - val fullPrefix = prefixLength + badgesLength - try { - emotes - .groupBy { it.position } - .forEach { (_, emotes) -> - ensureActive() - val key = emotes.cacheKey(baseHeight) - // fast path, backed by lru cache - val layerDrawable = emoteRepository.layerCache[key] ?: calculateLayerDrawable(context, emotes, key, animateGifs, scaleFactor) - if (layerDrawable != null) { - layerDrawable.forEachLayer { animatable -> - hasAnimatedEmoteOrBadge = true - animatable.setRunning(animateGifs) - } - (text as Spannable).setEmoteSpans(emotes, fullPrefix, layerDrawable, onWhisperMessageClick) - } - } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - handleException(t) - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && animateGifs && hasAnimatedEmoteOrBadge) { - emoteRepository.gifCallback.addView(holder.binding.itemText) - } - - ensureActive() - (text as Spannable)[0..text.length] = messageClickableSpan - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun TextView.handlePrivMessage(privMessage: PrivMessage, holder: ViewHolder, isMentionTab: Boolean): Unit = with(privMessage) { - val appearanceSettings = appearanceSettingsDataStore.current() - val chatSettings = chatSettingsDataStore.current() - val textView = this@handlePrivMessage - isClickable = false - movementMethod = LongClickLinkMovementMethod - (text as? Spannable)?.clearSpans() - - setTextSize(TypedValue.COMPLEX_UNIT_SP, appearanceSettings.fontSize.toFloat()) - - val baseHeight = getBaseHeight(textSize) - val scaleFactor = baseHeight * SCALE_FACTOR_CONSTANT - val bgColor = when { - timedOut && !chatSettings.showTimedOutMessages -> ContextCompat.getColor(context, android.R.color.transparent) - highlights.isNotEmpty() -> highlights.toBackgroundColor(context) - appearanceSettings.checkeredMessages && holder.isAlternateBackground -> MaterialColors.layer( - textView, - android.R.attr.colorBackground, - R.attr.colorSurfaceInverse, - MaterialColors.ALPHA_DISABLED_LOW - ) - - else -> ContextCompat.getColor(context, android.R.color.transparent) - } - holder.binding.itemLayout.setBackgroundColor(bgColor) - setRippleBackground(bgColor, enableRipple = true) - - val textColor = MaterialColors.getColor(textView, R.attr.colorOnSurface) - setTextColor(textColor) - - if (timedOut && !chatSettings.showTimedOutMessages) { - text = when { - chatSettings.showTimestamps -> buildSpannedString { - timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter)) } - append(context.getString(R.string.timed_out_message)) - } - - else -> context.getString(R.string.timed_out_message) - } - return - } - - val fullDisplayName = when { - !chatSettings.showUsernames -> "" - isAction -> "$aliasOrFormattedName " - aliasOrFormattedName.isBlank() -> "" - else -> "$aliasOrFormattedName: " - } - - val allowedBadges = badges.filter { it.type in chatSettings.visibleBadgeTypes } - val badgesLength = allowedBadges.size * 2 - - val messageBuilder = SpannableStringBuilder() - if (isMentionTab && highlights.hasMention()) { - messageBuilder.bold { append("#$channel ") } - } - if (chatSettings.showTimestamps) { - messageBuilder.timestampFont(context) { append(DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter)) } - } - - val prefixLength = messageBuilder.length + fullDisplayName.length // spannable.length is timestamp's length (plus some extra length from extra methods call above) - - val badgePositions = allowedBadges.map { - messageBuilder.append("⠀ ") - messageBuilder.length - 2 to messageBuilder.length - 1 - } - - val nameColor = customOrUserColorOn(bgColor = bgColor) - messageBuilder.bold { color(nameColor) { append(fullDisplayName) } } - - when { - isAction -> messageBuilder.color(nameColor) { append(message) } - else -> messageBuilder.append(message) - } - - // clicking usernames - if (aliasOrFormattedName.isNotBlank()) { - val userClickableSpan = object : LongClickableSpan() { - override fun onClick(v: View) = onUserClick(userId, name, displayName, channel, badges, false) - override fun onLongClick(view: View) = onUserClick(userId, name, displayName, channel, badges, true) - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - ds.color = nameColor - } - } - val start = prefixLength - fullDisplayName.length + badgesLength - val end = prefixLength + badgesLength - messageBuilder[start..end] = userClickableSpan - } - - val emojiCompat = EmojiCompat.get() - val messageStart = prefixLength + badgesLength - val messageEnd = messageStart + message.length - val spannableWithEmojis = when (emojiCompat.loadState) { - EmojiCompat.LOAD_STATE_SUCCEEDED -> emojiCompat.process(messageBuilder, messageStart, messageEnd, Int.MAX_VALUE, EmojiCompat.REPLACE_STRATEGY_NON_EXISTENT) - else -> messageBuilder - } as SpannableStringBuilder - - val onMessageClick = { - onMessageLongClick(id, channel, spannableWithEmojis.toString().replace("⠀ ", "")) - } - - addLinks(spannableWithEmojis, onMessageClick) - if (thread != null) { - holder.binding.itemReply.setOnLongClickListener { - onMessageClick() - true - } - } - - // copying message - val messageClickableSpan = object : LongClickableSpan(checkBounds = false) { - override fun onClick(v: View) = Unit - override fun onLongClick(view: View) = onMessageClick() - override fun updateDrawState(ds: TextPaint) { - ds.isUnderlineText = false - } - } - spannableWithEmojis[0..spannableWithEmojis.length] = messageClickableSpan - setText(spannableWithEmojis, TextView.BufferType.SPANNABLE) - - val animateGifs = chatSettings.animateGifs - var hasAnimatedEmoteOrBadge = false - holder.scope.launch(holder.coroutineHandler) { - allowedBadges.forEachIndexed { idx, badge -> - try { - ensureActive() - val (start, end) = badgePositions[idx] - val cacheKey = badge.takeIf { it.url.isNotEmpty() }?.cacheKey(baseHeight) - val cached = cacheKey?.let { emoteRepository.badgeCache[it] } - val drawable = when { - cached != null -> cached.also { - if (it is Animatable) { - it.setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true - } - } - - else -> { - val request = when (badge) { - is Badge.SharedChatBadge if badge.url.isEmpty() -> { - ImageRequest.Builder(context) - .data(R.drawable.shared_chat) - .build() - } - - else -> badge.url.toRequest(context, circleCrop = badge is Badge.SharedChatBadge) - } - context.imageLoader - .execute(request) - .image - ?.asDrawable(resources) - ?.apply { - if (badge is Badge.FFZModBadge) { - val modColor = ContextCompat.getColor(context, R.color.color_ffz_mod) - colorFilter = PorterDuffColorFilter(modColor, PorterDuff.Mode.DST_OVER) - } - - val width = (baseHeight * intrinsicWidth / intrinsicHeight.toFloat()).roundToInt() - setBounds(0, 0, width, baseHeight) - if (this is Animatable && cacheKey != null) { - emoteRepository.badgeCache.put(cacheKey, this) - setRunning(animateGifs) - hasAnimatedEmoteOrBadge = true - } - } - } - } - - if (drawable != null) { - val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BASELINE) - (text as Spannable)[start..end] = imageSpan - } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - handleException(t) - } - } - - // Remove message clickable span because emote spans have to be set before we add the span covering the full message - (text as Spannable).removeSpan(messageClickableSpan) - - val fullPrefix = prefixLength + badgesLength - try { - emotes - .groupBy { it.position } - .forEach { (_, emotes) -> - ensureActive() - val key = emotes.cacheKey(baseHeight) - // fast path, backed by lru cache - val layerDrawable = emoteRepository.layerCache[key] ?: calculateLayerDrawable(context, emotes, key, animateGifs, scaleFactor) - if (layerDrawable != null) { - layerDrawable.forEachLayer { animatable -> - hasAnimatedEmoteOrBadge = true - animatable.setRunning(animateGifs) - } - (text as Spannable).setEmoteSpans(emotes, fullPrefix, layerDrawable, onMessageClick) - } - } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - handleException(t) - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && animateGifs && hasAnimatedEmoteOrBadge) { - emoteRepository.gifCallback.addView(holder.binding.itemText) - } - - ensureActive() - (text as Spannable)[0..text.length] = messageClickableSpan - } - } - - private suspend fun calculateLayerDrawable( - context: Context, - emotes: List, - cacheKey: String, - animateGifs: Boolean, - scaleFactor: Double, - ): LayerDrawable? { - val drawables = emotes.mapNotNull { - val request = it.url.toRequest(context) - context.imageLoader - .execute(request) - .image - ?.asDrawable(context.resources) - ?.transformEmoteDrawable(scaleFactor, it) - }.toTypedArray() - - val bounds = drawables.map { it.bounds } - if (bounds.isEmpty()) { - return null - } - - return drawables.toLayerDrawable(bounds, scaleFactor, emotes).also { layerDrawable -> - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && animateGifs && drawables.any { it is Animatable }) { - layerDrawable.callback = emoteRepository.gifCallback - } - emoteRepository.layerCache.put(cacheKey, layerDrawable) - } - } - - private fun Array.toLayerDrawable(bounds: List, scaleFactor: Double, emotes: List): LayerDrawable = LayerDrawable(this).apply { - val maxWidth = bounds.maxOf { it.width() } - val maxHeight = bounds.maxOf { it.height() } - setBounds(0, 0, maxWidth, maxHeight) - - // set bounds again but adjust by maximum width/height of stacked drawables - forEachIndexed { idx, dr -> dr.transformEmoteDrawable(scaleFactor, emotes[idx], maxWidth, maxHeight) } - } - - private fun Spannable.setEmoteSpans(emotes: List, prefix: Int, drawable: Drawable, onLongClick: () -> Unit) { - try { - val position = emotes.first().position - val start = position.first + prefix - val end = position.last + prefix - this[start..end] = ImageSpan(drawable) - this[start..end] = object : LongClickableSpan() { - override fun onLongClick(view: View) = onLongClick() - override fun onClick(widget: View) = onEmoteClick(emotes) - } - } catch (t: Throwable) { - val firstEmote = emotes.firstOrNull() - Log.e("ViewBinding", "$t $this ${firstEmote?.position} ${firstEmote?.code} $length") - } - } - - private fun Drawable.transformEmoteDrawable(scale: Double, emote: ChatMessageEmote, maxWidth: Int = 0, maxHeight: Int = 0): Drawable { - val ratio = intrinsicWidth / intrinsicHeight.toFloat() - val height = when { - intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() - intrinsicHeight in 55..111 && emote.isTwitch -> (112 * scale).roundToInt() - else -> (intrinsicHeight * scale).roundToInt() - } - val width = (height * ratio).roundToInt() - - val scaledWidth = width * emote.scale - val scaledHeight = height * emote.scale - - val left = if (maxWidth > 0) (maxWidth - scaledWidth).div(2).coerceAtLeast(0) else 0 - val top = (maxHeight - scaledHeight).coerceAtLeast(0) - - setBounds(left, top, scaledWidth + left, scaledHeight + top) - return this - } - - private fun String.toRequest(context: Context, circleCrop: Boolean = false): ImageRequest = ImageRequest.Builder(context) - .data(this) - .apply { if (circleCrop) transformations(CircleCropTransformation()) else this } - .build() - - /** make the font monospaced, also add an extra space after it */ - private inline fun SpannableStringBuilder.timestampFont( - context: Context, // this is required just because we need to retrieve the R.style stuff - builderAction: SpannableStringBuilder.() -> Unit - ): SpannableStringBuilder = inSpans( - TypefaceSpan("monospace"), - StyleSpan(Typeface.BOLD), - // style adjustments to make the monospaced text looks "same size" as the normal text - RelativeSizeSpan(MONOSPACE_FONT_PROPORTION), - TextAppearanceSpan(context, R.style.timestamp_and_whisper), // set letter spacing using this, can't set directly in code - builderAction = builderAction - ).append(" ") - - /** set background color, and enable/disable ripple (whether enable or disable should match the "clickability" of that message */ - private fun View.setRippleBackground(@ColorInt backgroundColor: Int, enableRipple: Boolean = false) { - val rippleBg = background as? RippleDrawable - if (rippleBg != null) { // background is expected set to RippleDrawable via XML layout - rippleBg.setDrawableByLayerId(R.id.ripple_color_layer, ColorDrawable(backgroundColor)) - val rippleMask = if (enableRipple) MASK_FULL else MASK_NONE - rippleBg.setDrawableByLayerId(android.R.id.mask, rippleMask) - } else { - // handle some unexpected case - setBackgroundColor(backgroundColor) - } - } - - @ColorInt - private fun Set.toBackgroundColor(context: Context): Int { - val highlight = highestPriorityHighlight() ?: return ContextCompat.getColor(context, android.R.color.transparent) - if (highlight.customColor != null) { - return highlight.customColor - } - return when (highlight.type) { - HighlightType.Subscription, HighlightType.Announcement -> ContextCompat.getColor(context, R.color.color_sub_highlight) - HighlightType.ChannelPointRedemption -> ContextCompat.getColor(context, R.color.color_redemption_highlight) - HighlightType.ElevatedMessage -> ContextCompat.getColor(context, R.color.color_elevated_message_highlight) - HighlightType.FirstMessage -> ContextCompat.getColor(context, R.color.color_first_message_highlight) - HighlightType.Username -> ContextCompat.getColor(context, R.color.color_mention_highlight) - HighlightType.Badge -> highlight.customColor ?: ContextCompat.getColor(context, R.color.color_mention_highlight) - HighlightType.Custom -> ContextCompat.getColor(context, R.color.color_mention_highlight) - HighlightType.Reply -> ContextCompat.getColor(context, R.color.color_mention_highlight) - HighlightType.Notification -> ContextCompat.getColor(context, R.color.color_mention_highlight) - } - } - - private fun TextView.addLinks(spannableWithEmojis: SpannableStringBuilder, onLongClick: () -> Unit) { - LinkifyCompat.addLinks(spannableWithEmojis, Linkify.WEB_URLS) - spannableWithEmojis.getSpans().forEach { urlSpan -> - val start = spannableWithEmojis.getSpanStart(urlSpan) - val end = spannableWithEmojis.getSpanEnd(urlSpan) - spannableWithEmojis.removeSpan(urlSpan) - - val fixedEnd = spannableWithEmojis - .indexOfFirst(startIndex = end) { it.isWhitespace() || it in DISALLOWED_URL_CHARS } - .takeIf { it != -1 } ?: end - val fixedUrl = when (fixedEnd) { - end -> urlSpan.url - else -> urlSpan.url + spannableWithEmojis.substring(end..fixedEnd) - } - - // skip partial link matches - val previousChar = spannableWithEmojis.getOrNull(index = start - 1) - if (previousChar != null && !previousChar.isWhitespace()) { - return@forEach - } - - val clickableSpan = object : LongClickableSpan() { - override fun onLongClick(view: View) = onLongClick() - override fun onClick(v: View) { - try { - customTabsIntent.launchUrl(context, fixedUrl.toUri()) - } catch (e: ActivityNotFoundException) { - Log.e("ViewBinding", Log.getStackTraceString(e)) - } - } - - override fun updateDrawState(ds: TextPaint) { - ds.color = ds.linkColor - ds.isUnderlineText = false - } - } - spannableWithEmojis[start..fixedEnd] = clickableSpan - } - } - - private fun TextView.handleException(throwable: Throwable) { - val trace = Log.getStackTraceString(throwable) - Log.e("DankChat-Rendering", trace) - - if (developerSettingsDataStore.current().debugMode) { - showErrorDialog(throwable, stackTraceString = trace) - } - } -} - -private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { - return oldItem.tag == newItem.tag && oldItem.message.id == newItem.message.id - } - - override fun areContentsTheSame(oldItem: ChatItem, newItem: ChatItem): Boolean { - return when { - newItem.message.highlights.hasMention() || (oldItem.importance != newItem.importance) -> false - else -> oldItem.message == newItem.message - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt deleted file mode 100644 index e7d235776..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatFragment.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.flxrs.dankchat.chat - -import android.graphics.drawable.Animatable -import android.graphics.drawable.LayerDrawable -import android.os.Build -import android.os.Bundle -import android.text.style.ImageSpan -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible -import androidx.core.view.postDelayed -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.emote.EmoteRepository -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.databinding.ChatFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.main.MainViewModel -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.forEachLayer -import com.flxrs.dankchat.utils.extensions.forEachSpan -import com.flxrs.dankchat.utils.extensions.forEachViewHolder -import com.flxrs.dankchat.utils.insets.TranslateDeferringInsetsAnimationCallback -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel - -open class ChatFragment : Fragment() { - private val viewModel: ChatViewModel by viewModel() - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private val emoteRepository: EmoteRepository by inject() - private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() - private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() - protected val chatSettingsDataStore: ChatSettingsDataStore by inject() - protected val dankChatPreferenceStore: DankChatPreferenceStore by inject() - - protected var bindingRef: ChatFragmentBinding? = null - protected val binding get() = bindingRef!! - protected open lateinit var adapter: ChatAdapter - protected open lateinit var manager: LinearLayoutManager - - // TODO move to viewmodel? - protected open var isAtBottom = true - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = ChatFragmentBinding.inflate(inflater, container, false).apply { - chatLayout.layoutTransition?.setAnimateParentHierarchy(false) - scrollBottom.setOnClickListener { - scrollBottom.visibility = View.GONE - mainViewModel.isScrolling(false) - isAtBottom = true - binding.chat.stopScroll() - scrollToPosition(position = adapter.itemCount - 1) - } - } - - collectFlow(viewModel.chat) { adapter.submitList(it) } - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val itemDecoration = DividerItemDecoration(view.context, LinearLayoutManager.VERTICAL) - manager = LinearLayoutManager(view.context, RecyclerView.VERTICAL, false).apply { stackFromEnd = true } - adapter = ChatAdapter( - emoteRepository = emoteRepository, - dankChatPreferenceStore = dankChatPreferenceStore, - chatSettingsDataStore = chatSettingsDataStore, - developerSettingsDataStore = developerSettingsDataStore, - appearanceSettingsDataStore = appearanceSettingsDataStore, - onListChanged = ::scrollToPosition, - onUserClick = ::onUserClick, - onMessageLongClick = ::onMessageClick, - onReplyClick = ::onReplyClick, - onEmoteClick = ::onEmoteClick, - ).apply { stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY } - binding.chat.setup(adapter, manager) - ViewCompat.setWindowInsetsAnimationCallback( - binding.chat, - TranslateDeferringInsetsAnimationCallback( - view = binding.chat, - persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), - deferredInsetTypes = WindowInsetsCompat.Type.ime(), - ) - ) - - collectFlow(appearanceSettingsDataStore.lineSeparator) { - when { - it && binding.chat.itemDecorationCount == 0 -> binding.chat.addItemDecoration(itemDecoration) - !it && binding.chat.itemDecorationCount == 1 -> binding.chat.removeItemDecoration(itemDecoration) - } - } - collectFlow(chatSettingsDataStore.restartChat) { - binding.chat.swapAdapter(adapter, false) - } - } - - override fun onStart() { - super.onStart() - - // Trigger a redraw of last 50 items to start gifs again - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && chatSettingsDataStore.current().animateGifs) { - binding.chat.postDelayed(MESSAGES_REDRAW_DELAY_MS) { - val start = (adapter.itemCount - MAX_MESSAGES_REDRAW_AMOUNT).coerceAtLeast(minimumValue = 0) - val itemCount = MAX_MESSAGES_REDRAW_AMOUNT.coerceAtMost(maximumValue = adapter.itemCount) - adapter.notifyItemRangeChanged(start, itemCount) - } - } - } - - override fun onDestroyView() { - binding.chat.adapter = null - binding.chat.layoutManager = null - - bindingRef = null - super.onDestroyView() - } - - override fun onStop() { - // Stop animated drawables and related invalidation callbacks - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && activity?.isChangingConfigurations == false && ::adapter.isInitialized) { - binding.chat.cleanupActiveDrawables(adapter.itemCount) - } - - super.onStop() - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - savedInstanceState?.let { - isAtBottom = it.getBoolean(AT_BOTTOM_STATE) - binding.scrollBottom.isVisible = !isAtBottom - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(AT_BOTTOM_STATE, isAtBottom) - } - - protected open fun onUserClick( - targetUserId: UserId?, - targetUserName: UserName, - targetDisplayName: DisplayName, - channel: UserName?, - badges: List, - isLongPress: Boolean - ) { - targetUserId ?: return - val shouldLongClickMention = chatSettingsDataStore.current().userLongClickBehavior == UserLongClickBehavior.MentionsUser - val shouldMention = (isLongPress && shouldLongClickMention) || (!isLongPress && !shouldLongClickMention) - - when { - shouldMention && dankChatPreferenceStore.isLoggedIn -> (parentFragment as? MainFragment)?.mentionUser(targetUserName, targetDisplayName) - else -> (parentFragment as? MainFragment)?.openUserPopup( - targetUserId = targetUserId, - targetUserName = targetUserName, - targetDisplayName = targetDisplayName, - channel = channel, - badges = badges, - isWhisperPopup = false - ) - } - } - - protected open fun onMessageClick(messageId: String, channel: UserName?, fullMessage: String) { - (parentFragment as? MainFragment)?.openMessageSheet(messageId, channel, fullMessage, canReply = true, canModerate = true) - } - - protected open fun onEmoteClick(emotes: List) { - (parentFragment as? MainFragment)?.openEmoteSheet(emotes) - } - - private fun onReplyClick(rootMessageId: String) { - (parentFragment as? MainFragment)?.openReplies(rootMessageId) - } - - protected open fun scrollToPosition(position: Int) { - bindingRef ?: return - if (position > 0 && isAtBottom) { - manager.scrollToPositionWithOffset(position, 0) - } - } - - private fun RecyclerView.setup(chatAdapter: ChatAdapter, manager: LinearLayoutManager) { - setItemViewCacheSize(OFFSCREEN_VIEW_CACHE_SIZE) - adapter = chatAdapter - layoutManager = manager - itemAnimator = null - isNestedScrollingEnabled = false - addOnScrollListener(ChatScrollListener()) - } - - private inner class ChatScrollListener : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - mainViewModel.isScrolling(newState != RecyclerView.SCROLL_STATE_IDLE) - } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (dy < 0) { - isAtBottom = false - bindingRef?.scrollBottom?.show() - } else if (dy > 0 && !isAtBottom && !recyclerView.canScrollVertically(1)) { - isAtBottom = true - bindingRef?.scrollBottom?.visibility = View.GONE - } - } - } - - private fun RecyclerView.cleanupActiveDrawables(itemCount: Int) = - forEachViewHolder(itemCount) { _, holder -> - holder.binding.itemText.forEachSpan { imageSpan -> - (imageSpan.drawable as? LayerDrawable)?.forEachLayer(Animatable::stop) - } - } - - companion object { - private const val AT_BOTTOM_STATE = "chat_at_bottom_state" - private const val MAX_MESSAGES_REDRAW_AMOUNT = 50 - private const val MESSAGES_REDRAW_DELAY_MS = 100L - private const val OFFSCREEN_VIEW_CACHE_SIZE = 10 - - fun newInstance(channel: UserName) = ChatFragment().apply { - arguments = ChatFragmentArgs(channel).toBundle() - } - - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt deleted file mode 100644 index 9742ca929..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatItem.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.flxrs.dankchat.chat - -import com.flxrs.dankchat.data.twitch.message.Message - -data class ChatItem(val message: Message, val tag: Int = 0, val isMentionTab: Boolean = false, val importance: ChatImportance = ChatImportance.REGULAR, val isInReplies: Boolean = false) - -fun List.toMentionTabItems(): List = map { it.copy(isMentionTab = true) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatTabAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatTabAdapter.kt deleted file mode 100644 index 5ed191a82..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatTabAdapter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.flxrs.dankchat.chat - -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.preferences.model.ChannelWithRename - -class ChatTabAdapter(parentFragment: Fragment) : FragmentStateAdapter(parentFragment) { - - private val _channels = mutableListOf() - - override fun createFragment(position: Int) = ChatFragment.newInstance(_channels[position].channel) - - override fun getItemCount(): Int = _channels.size - - override fun getItemId(position: Int): Long = when (position) { - in _channels.indices -> _channels[position].channel.hashCode().toLong() - else -> RecyclerView.NO_ID - } - - override fun containsItem(itemId: Long): Boolean = _channels.any { it.channel.hashCode().toLong() == itemId } - - fun indexOfChannel(channel: UserName): Int { - return _channels.indexOfFirst { it.channel == channel } - } - - operator fun get(position: Int): UserName? { - if (position !in _channels.indices) return null - return _channels[position].channel - } - - fun getFormattedChannel(position: Int): String { - val channel = _channels[position] - return channel.rename?.value ?: channel.channel.value - } - - fun addFragment(channel: UserName) { - _channels += ChannelWithRename(channel, rename = null) - notifyItemInserted(_channels.lastIndex) - } - - fun updateFragments(channels: List) { - val oldChannels = _channels.toList() - _channels.clear() - _channels.addAll(channels) - - val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { - override fun getOldListSize(): Int = oldChannels.size - override fun getNewListSize(): Int = channels.size - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldChannels[oldItemPosition].channel == channels[newItemPosition].channel - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldChannels[oldItemPosition] == channels[newItemPosition] - } - }) - result.dispatchUpdatesTo(this) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt deleted file mode 100644 index 19f43fcb4..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.flxrs.dankchat.chat - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.repo.chat.ChatRepository -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.stateIn -import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds - -@KoinViewModel -class ChatViewModel( - savedStateHandle: SavedStateHandle, - repository: ChatRepository, -) : ViewModel() { - - private val args = ChatFragmentArgs.fromSavedStateHandle(savedStateHandle) - val chat: StateFlow> = (args.channel?.let(repository::getChat) ?: emptyFlow()) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - - companion object { - private val TAG = ChatViewModel::class.java.simpleName - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/FullScreenSheetState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/FullScreenSheetState.kt deleted file mode 100644 index 68ff78573..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/FullScreenSheetState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flxrs.dankchat.chat - -sealed interface FullScreenSheetState { - object Closed : FullScreenSheetState - object Mention : FullScreenSheetState - object Whisper : FullScreenSheetState - data class Replies(val replyMessageId: String) : FullScreenSheetState - - val isOpen: Boolean get() = this != Closed - val isMentionSheet: Boolean get() = this == Mention || this == Whisper - val replyIdOrNull: String? get() = (this as? Replies)?.replyMessageId -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/InputSheetState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/InputSheetState.kt deleted file mode 100644 index aadb6cbe4..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/InputSheetState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flxrs.dankchat.chat - -import com.flxrs.dankchat.data.UserName - -sealed interface InputSheetState { - object Closed : InputSheetState - data class Emotes(val previousReply: Replying?) : InputSheetState - data class Replying(val replyMessageId: String, val replyName: UserName) : InputSheetState - - val isOpen: Boolean get() = this != Closed - val replyIdOrNull: String? get() = (this as? Replying)?.replyMessageId -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/MessageClickEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/MessageClickEvent.kt deleted file mode 100644 index 135b3b448..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/MessageClickEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.flxrs.dankchat.chat - -import com.flxrs.dankchat.data.UserName - -sealed interface MessageClickEvent { - data class Copy(val message: String) : MessageClickEvent - data class Reply(val replyMessageId: String, val replyName: UserName) : MessageClickEvent - data class ViewThread(val replyMessageId: String) : MessageClickEvent -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetAdapter.kt deleted file mode 100644 index 6d9cbb1a8..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetAdapter.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.flxrs.dankchat.chat.emote - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.EmoteBottomsheetItemBinding -import com.flxrs.dankchat.utils.extensions.loadImage - -class EmoteSheetAdapter( - private val onUseClick: (item: EmoteSheetItem) -> Unit, - private val onCopyClick: (item: EmoteSheetItem) -> Unit, - private val onOpenLinkClick: (item: EmoteSheetItem) -> Unit, - private val onImageClick: (item: EmoteSheetItem) -> Unit, -) : ListAdapter(DetectDiff()) { - - inner class ViewHolder(val binding: EmoteBottomsheetItemBinding) : RecyclerView.ViewHolder(binding.root) { - init { - binding.emoteUse.setOnClickListener { onUseClick(getItem(bindingAdapterPosition)) } - binding.emoteCopy.setOnClickListener { onCopyClick(getItem(bindingAdapterPosition)) } - binding.emoteOpenLink.setOnClickListener { onOpenLinkClick(getItem(bindingAdapterPosition)) } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = EmoteBottomsheetItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val emote = getItem(position) - with(holder.binding) { - emoteImage.loadImage(emote.imageUrl, placeholder = null, afterLoad = { emoteImageLoading.isVisible = false }) - emoteImage.setOnClickListener { onImageClick(emote) } - emoteName.text = emote.name - emoteType.text = buildString { - append(root.context.getString(emote.emoteType)) - if (emote.isZeroWidth) { - append(" ") - append(root.context.getString(R.string.emote_sheet_zero_width_emote)) - } - } - when (emote.baseName) { - null -> emoteBaseName.isVisible = false - else -> { - emoteBaseName.isVisible = true - emoteBaseName.text = root.context.getString(R.string.emote_sheet_alias_of, emote.baseName) - } - } - when (emote.creatorName) { - null -> emoteCreator.isVisible = false - else -> { - emoteCreator.isVisible = true - emoteCreator.text = root.context.getString(R.string.emote_sheet_created_by, emote.creatorName.value) - } - } - } - } - - private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: EmoteSheetItem, newItem: EmoteSheetItem): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: EmoteSheetItem, newItem: EmoteSheetItem): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetFragment.kt deleted file mode 100644 index 7f26221ca..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.flxrs.dankchat.chat.emote - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat.startActivity -import androidx.core.net.toUri -import androidx.core.view.isVisible -import androidx.navigation.fragment.findNavController -import androidx.viewpager2.widget.ViewPager2 -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.EmoteBottomsheetBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.utils.extensions.disableNestedScrolling -import com.flxrs.dankchat.utils.extensions.forEachViewHolder -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.flxrs.dankchat.utils.extensions.recyclerView -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.tabs.TabLayoutMediator -import org.koin.androidx.viewmodel.ext.android.viewModel - -class EmoteSheetFragment : BottomSheetDialogFragment() { - - private val viewModel: EmoteSheetViewModel by viewModel() - private var bindingRef: EmoteBottomsheetBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val adapter = EmoteSheetAdapter( - onUseClick = { sendResultAndDismiss(EmoteSheetResult.Use(it.name, it.id)) }, - onCopyClick = { sendResultAndDismiss(EmoteSheetResult.Copy(it.name)) }, - onOpenLinkClick = { emote -> - Intent(Intent.ACTION_VIEW).also { - it.data = emote.providerUrl.toUri() - startActivity(it) - } - }, - onImageClick = { emote -> - Intent(Intent.ACTION_VIEW).also { - it.data = emote.imageUrl.toUri() - startActivity(it) - } - }, - ) - val items = viewModel.items - adapter.submitList(items) - - bindingRef = EmoteBottomsheetBinding.inflate(inflater, container, false).apply { - emoteSheetViewPager.adapter = adapter - TabLayoutMediator(emoteSheetTabs, emoteSheetViewPager) { tab, pos -> - tab.text = items[pos].name - }.attach() - - emoteSheetTabs.isVisible = items.size > 1 - emoteSheetViewPager.isUserInputEnabled = items.size > 1 - - emoteSheetViewPager.disableNestedScrolling() - emoteSheetViewPager.registerOnPageChangeCallback(emoteSheetPageChangeCallback) - } - return binding.root - } - - override fun onResume() { - super.onResume() - dialog?.takeIf { isLandscape }?.let { - with(it as BottomSheetDialog) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - behavior.skipCollapsed = true - } - } - } - - override fun onDestroyView() { - binding.emoteSheetViewPager.unregisterOnPageChangeCallback(emoteSheetPageChangeCallback) - bindingRef = null - super.onDestroyView() - } - - private val emoteSheetPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - val adapter = binding.emoteSheetViewPager.adapter as? EmoteSheetAdapter ?: return - runCatching { - binding.emoteSheetViewPager.recyclerView?.forEachViewHolder(adapter.itemCount) { idx, viewHolder -> - viewHolder.binding.buttonsLayout.isNestedScrollingEnabled = idx == position - } - } - } - } - - private fun sendResultAndDismiss(result: EmoteSheetResult) { - findNavController() - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.EMOTE_SHEET_RESULT_KEY] = result - dialog?.dismiss() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetResult.kt deleted file mode 100644 index 09a9d6922..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.flxrs.dankchat.chat.emote - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed interface EmoteSheetResult : Parcelable { - - @Parcelize - data class Use(val emoteName: String, val id: String) : EmoteSheetResult - - @Parcelize - data class Copy(val emoteName: String) : EmoteSheetResult -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetViewModel.kt deleted file mode 100644 index e0df1f965..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.flxrs.dankchat.chat.emote - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType -import org.koin.android.annotation.KoinViewModel - -@KoinViewModel -class EmoteSheetViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { - - private val args = EmoteSheetFragmentArgs.fromSavedStateHandle(savedStateHandle) - - val items = args.emotes.map { emote -> - EmoteSheetItem( - id = emote.id, - name = emote.code, - imageUrl = emote.url, - baseName = emote.baseNameOrNull(), - creatorName = emote.creatorNameOrNull(), - providerUrl = emote.providerUrlOrNull(), - isZeroWidth = emote.isOverlayEmote, - emoteType = emote.emoteTypeOrNull(), - ) - } - - private fun ChatMessageEmote.baseNameOrNull(): String? { - return when (type) { - is ChatMessageEmoteType.GlobalSevenTVEmote -> type.baseName - is ChatMessageEmoteType.ChannelSevenTVEmote -> type.baseName - else -> null - } - } - - private fun ChatMessageEmote.creatorNameOrNull(): DisplayName? { - return when (type) { - is ChatMessageEmoteType.GlobalSevenTVEmote -> type.creator - is ChatMessageEmoteType.ChannelSevenTVEmote -> type.creator - is ChatMessageEmoteType.ChannelBTTVEmote -> type.creator - is ChatMessageEmoteType.ChannelFFZEmote -> type.creator - is ChatMessageEmoteType.GlobalFFZEmote -> type.creator - else -> null - } - } - - private fun ChatMessageEmote.providerUrlOrNull(): String { - return when (type) { - is ChatMessageEmoteType.GlobalSevenTVEmote, - is ChatMessageEmoteType.ChannelSevenTVEmote -> "$SEVEN_TV_BASE_LINK$id" - - is ChatMessageEmoteType.ChannelBTTVEmote, - is ChatMessageEmoteType.GlobalBTTVEmote -> "$BTTV_BASE_LINK$id" - - is ChatMessageEmoteType.ChannelFFZEmote, - is ChatMessageEmoteType.GlobalFFZEmote -> "$FFZ_BASE_LINK$id-$code" - - is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" - } - } - - private fun ChatMessageEmote.emoteTypeOrNull(): Int { - return when (type) { - is ChatMessageEmoteType.ChannelBTTVEmote -> if (type.isShared) R.string.emote_sheet_bttv_shared_emote else R.string.emote_sheet_bttv_channel_emote - is ChatMessageEmoteType.ChannelFFZEmote -> R.string.emote_sheet_ffz_channel_emote - is ChatMessageEmoteType.ChannelSevenTVEmote -> R.string.emote_sheet_seventv_channel_emote - ChatMessageEmoteType.GlobalBTTVEmote -> R.string.emote_sheet_bttv_global_emote - is ChatMessageEmoteType.GlobalFFZEmote -> R.string.emote_sheet_ffz_global_emote - is ChatMessageEmoteType.GlobalSevenTVEmote -> R.string.emote_sheet_seventv_global_emote - ChatMessageEmoteType.TwitchEmote -> R.string.emote_sheet_twitch_emote - } - } - - companion object { - private const val SEVEN_TV_BASE_LINK = "https://7tv.app/emotes/" - private const val FFZ_BASE_LINK = "https://www.frankerfacez.com/emoticon/" - private const val BTTV_BASE_LINK = "https://betterttv.com/emotes/" - private const val TWITCH_BASE_LINK = "https://chatvau.lt/emote/twitch/" - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteAdapter.kt deleted file mode 100644 index ff88a4de6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteAdapter.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.flxrs.dankchat.chat.emotemenu - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.widget.TooltipCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.data.twitch.emote.GenericEmote -import com.flxrs.dankchat.databinding.EmoteHeaderItemBinding -import com.flxrs.dankchat.databinding.EmoteItemBinding -import com.flxrs.dankchat.utils.extensions.loadImage -import java.util.Locale - -class EmoteAdapter(private val onEmoteClick: (emote: GenericEmote) -> Unit) : ListAdapter(DetectDiff()) { - - inner class ViewHolder(val binding: EmoteItemBinding) : RecyclerView.ViewHolder(binding.root) - inner class TextViewHolder(val binding: EmoteHeaderItemBinding) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - ITEM_VIEW_TYPE_HEADER -> TextViewHolder(EmoteHeaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - ITEM_VIEW_TYPE_ITEM -> ViewHolder(EmoteItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - else -> throw ClassCastException("Unknown viewType $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ViewHolder -> { - val emoteItem = getItem(position) as EmoteItem.Emote - val emote = emoteItem.emote - holder.binding.root.setOnClickListener { onEmoteClick(emote) } - with(holder.binding.emoteView) { - TooltipCompat.setTooltipText(this, emote.code) - contentDescription = emote.code - loadImage(emote.lowResUrl) - } - } - - is TextViewHolder -> { - val item = getItem(position) as EmoteItem.Header - holder.binding.text.text = item.title.replaceFirstChar { it.titlecase(Locale.getDefault()) } - } - } - } - - override fun getItemViewType(position: Int): Int { - return when (getItem(position)) { - is EmoteItem.Header -> ITEM_VIEW_TYPE_HEADER - is EmoteItem.Emote -> ITEM_VIEW_TYPE_ITEM - } - } - - private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: EmoteItem, newItem: EmoteItem): Boolean = oldItem == newItem - - override fun areContentsTheSame(oldItem: EmoteItem, newItem: EmoteItem): Boolean = oldItem == newItem - } - - companion object { - const val ITEM_VIEW_TYPE_HEADER = 0 - const val ITEM_VIEW_TYPE_ITEM = 1 - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt deleted file mode 100644 index 0a8d65c2f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteItem.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.flxrs.dankchat.chat.emotemenu - -import com.flxrs.dankchat.data.twitch.emote.GenericEmote - -sealed class EmoteItem { - data class Emote(val emote: GenericEmote) : EmoteItem(), Comparable { - override fun compareTo(other: Emote): Int { - return when (val byType = emote.emoteType.compareTo(other.emote.emoteType)) { - 0 -> other.emote.code.compareTo(other.emote.code) - else -> byType - } - } - } - - data class Header(val title: String) : EmoteItem() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return javaClass == other?.javaClass - } - - override fun hashCode(): Int = javaClass.hashCode() - operator fun plus(list: List): List = listOf(this) + list -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuAdapter.kt deleted file mode 100644 index 5f85baf10..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.flxrs.dankchat.chat.emotemenu - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.data.twitch.emote.GenericEmote -import com.flxrs.dankchat.databinding.MenuTabListBinding -import com.google.android.flexbox.FlexboxLayoutManager -import com.google.android.flexbox.JustifyContent - -class EmoteMenuAdapter(private val onEmoteClick: (emote: GenericEmote) -> Unit) : ListAdapter(DetectDiff()) { - - inner class ViewHolder(val adapter: EmoteAdapter, val binding: MenuTabListBinding) : RecyclerView.ViewHolder(binding.root) - - override fun getItemCount() = EmoteMenuTab.entries.size - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val emoteAdapter = EmoteAdapter(onEmoteClick) - return ViewHolder(emoteAdapter, MenuTabListBinding.inflate(LayoutInflater.from(parent.context), parent, false).apply { - tabList.apply { - layoutManager = FlexboxLayoutManager(parent.context).apply { - justifyContent = JustifyContent.CENTER - } - adapter = emoteAdapter - } - }) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val emotes = getItem(position).items - holder.adapter.submitList(emotes) - } - - private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: EmoteMenuTabItem, newItem: EmoteMenuTabItem): Boolean = oldItem.type == newItem.type - override fun areContentsTheSame(oldItem: EmoteMenuTabItem, newItem: EmoteMenuTabItem): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuFragment.kt deleted file mode 100644 index b5a2ec211..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuFragment.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.flxrs.dankchat.chat.emotemenu - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.Fragment -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.EmoteMenuFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.main.MainViewModel -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.google.android.material.tabs.TabLayoutMediator -import org.koin.androidx.viewmodel.ext.android.viewModel - -class EmoteMenuFragment : Fragment() { - - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private var bindingRef: EmoteMenuFragmentBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val adapter = EmoteMenuAdapter { - (parentFragment as? MainFragment)?.insertEmote(it.code, it.id) - } - bindingRef = EmoteMenuFragmentBinding.inflate(inflater, container, false).apply { - bottomSheetViewPager.adapter = adapter - bottomSheetViewPager.updateLayoutParams { - height = (resources.displayMetrics.heightPixels * HEIGHT_SCALE_FACTOR).toInt() - } - - TabLayoutMediator(bottomSheetTabs, bottomSheetViewPager) { tab, pos -> - val menuTab = EmoteMenuTab.entries[pos] - tab.text = when (menuTab) { - EmoteMenuTab.SUBS -> root.context.getString(R.string.emote_menu_tab_subs) - EmoteMenuTab.CHANNEL -> root.context.getString(R.string.emote_menu_tab_channel) - EmoteMenuTab.GLOBAL -> root.context.getString(R.string.emote_menu_tab_global) - EmoteMenuTab.RECENT -> root.context.getString(R.string.emote_menu_tab_recent) - } - }.attach() - } - - collectFlow(mainViewModel.emoteTabItems, adapter::submitList) - mainViewModel.setEmoteInputSheetState() - return binding.root - } - - override fun onDestroyView() { - bindingRef = null - super.onDestroyView() - } - - companion object { - private const val HEIGHT_SCALE_FACTOR = 0.4 - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt deleted file mode 100644 index 18549c19f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTabItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.flxrs.dankchat.chat.emotemenu - -data class EmoteMenuTabItem(val type: EmoteMenuTab, val items: List) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt deleted file mode 100644 index ff490b4e7..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionChatFragment.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.flxrs.dankchat.chat.mention - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.chat.ChatFragment -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.databinding.ChatFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.utils.extensions.collectFlow -import org.koin.androidx.viewmodel.ext.android.viewModel - -class MentionChatFragment : ChatFragment() { - private val args: MentionChatFragmentArgs by navArgs() - private val mentionViewModel: MentionViewModel by viewModel(ownerProducer = { requireParentFragment() }) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = ChatFragmentBinding.inflate(inflater, container, false).apply { - chatLayout.layoutTransition?.setAnimateParentHierarchy(false) - scrollBottom.setOnClickListener { - scrollBottom.visibility = View.GONE - isAtBottom = true - binding.chat.stopScroll() - super.scrollToPosition(adapter.itemCount - 1) - } - } - - when { - args.isWhisperTab -> collectFlow(mentionViewModel.whispers) { adapter.submitList(it) } - else -> collectFlow(mentionViewModel.mentions) { adapter.submitList(it) } - } - - return binding.root - } - - override fun onUserClick( - targetUserId: UserId?, - targetUserName: UserName, - targetDisplayName: DisplayName, - channel: UserName?, - badges: List, - isLongPress: Boolean - ) { - targetUserId ?: return - val shouldLongClickMention = chatSettingsDataStore.current().userLongClickBehavior == UserLongClickBehavior.MentionsUser - val shouldMention = (isLongPress && shouldLongClickMention) || (!isLongPress && !shouldLongClickMention) - - when { - shouldMention && dankChatPreferenceStore.isLoggedIn -> (parentFragment?.parentFragment as? MainFragment)?.whisperUser(targetUserName) - else -> (parentFragment?.parentFragment as? MainFragment)?.openUserPopup( - targetUserId = targetUserId, - targetUserName = targetUserName, - targetDisplayName = targetDisplayName, - channel = null, - badges = badges, - isWhisperPopup = true - ) - } - } - - override fun onMessageClick(messageId: String, channel: UserName?, fullMessage: String) { - (parentFragment?.parentFragment as? MainFragment)?.openMessageSheet(messageId, channel, fullMessage, canReply = false, canModerate = false) - } - - override fun onEmoteClick(emotes: List) { - (parentFragment?.parentFragment as? MainFragment)?.openEmoteSheet(emotes) - } - - companion object { - fun newInstance(isWhisperTab: Boolean = false) = MentionChatFragment().apply { - arguments = MentionChatFragmentArgs(isWhisperTab).toBundle() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionFragment.kt deleted file mode 100644 index 101c8cf7e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.flxrs.dankchat.chat.mention - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.navArgs -import androidx.viewpager2.widget.ViewPager2 -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.FullScreenSheetState -import com.flxrs.dankchat.data.twitch.message.WhisperMessage -import com.flxrs.dankchat.databinding.MentionFragmentBinding -import com.flxrs.dankchat.main.MainViewModel -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.google.android.material.tabs.TabLayoutMediator -import org.koin.androidx.viewmodel.ext.android.viewModel - -class MentionFragment : Fragment() { - - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private val mentionViewModel: MentionViewModel by viewModel() - private val args: MentionFragmentArgs by navArgs() - private var bindingRef: MentionFragmentBinding? = null - private val binding get() = bindingRef!! - private lateinit var tabAdapter: MentionTabAdapter - private lateinit var tabLayoutMediator: TabLayoutMediator - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - tabAdapter = MentionTabAdapter(this) - bindingRef = MentionFragmentBinding.inflate(inflater, container, false).apply { - mentionsToolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } - mentionViewpager.setup() - tabLayoutMediator = TabLayoutMediator(mentionTabs, mentionViewpager) { tab, position -> - tab.text = when (position) { - 0 -> root.context.getString(R.string.mentions) - else -> root.context.getString(R.string.whispers) - } - }.apply { attach() } - } - - mainViewModel.setSuggestionChannel(WhisperMessage.WHISPER_CHANNEL) - mainViewModel.setFullScreenSheetState(binding.mentionTabs.selectedTabPosition.pageIndexToSheetState()) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (savedInstanceState == null && args.openWhisperTab) { - view.post { - binding.mentionViewpager.setCurrentItem(1, true) - } - } - - mentionViewModel.apply { - collectFlow(hasMentions) { - when { - it -> if (binding.mentionTabs.selectedTabPosition != 0) { - binding.mentionTabs.getTabAt(0)?.apply { orCreateBadge } - } - - else -> binding.mentionTabs.getTabAt(0)?.removeBadge() - } - } - collectFlow(hasWhispers) { - if (it && binding.mentionTabs.selectedTabPosition != 1) { - binding.mentionTabs.getTabAt(1)?.apply { orCreateBadge } - } - } - } - } - - override fun onDestroyView() { - binding.mentionViewpager.adapter = null - tabLayoutMediator.detach() - bindingRef = null - super.onDestroyView() - } - - fun scrollToWhisperTab() { - binding.mentionViewpager.setCurrentItem(1, true) - } - - private fun ViewPager2.setup() { - adapter = tabAdapter - offscreenPageLimit = 1 - registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - mainViewModel.setFullScreenSheetState(position.pageIndexToSheetState()) - bindingRef?.mentionTabs?.getTabAt(position)?.removeBadge() - } - }) - } - - private fun Int.pageIndexToSheetState() = when (this) { - 0 -> FullScreenSheetState.Mention - else -> FullScreenSheetState.Whisper - } - - companion object { - private val TAG = MentionFragment::class.java.simpleName - - fun newInstance(openWhisperTab: Boolean = false) = MentionFragment().apply { - arguments = MentionFragmentArgs(openWhisperTab).toBundle() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionTabAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionTabAdapter.kt deleted file mode 100644 index 015ff602d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionTabAdapter.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.chat.mention - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter - -class MentionTabAdapter(parentFragment: Fragment) : FragmentStateAdapter(parentFragment) { - - override fun getItemCount(): Int = NUM_TABS - override fun createFragment(position: Int): Fragment = when (position) { - 0 -> MentionChatFragment.newInstance() - else -> MentionChatFragment.newInstance(isWhisperTab = true) - } - - companion object { - const val NUM_TABS = 2 - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt deleted file mode 100644 index 1f0b84447..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/mention/MentionViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.flxrs.dankchat.chat.mention - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.data.repo.chat.ChatRepository - -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.stateIn -import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds - -@KoinViewModel -class MentionViewModel(chatRepository: ChatRepository) : ViewModel() { - val mentions: StateFlow> = chatRepository.mentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - val whispers: StateFlow> = chatRepository.whispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), emptyList()) - - val hasMentions: StateFlow = chatRepository.hasMentions - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - val hasWhispers: StateFlow = chatRepository.hasWhispers - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetFragment.kt deleted file mode 100644 index dce24b193..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetFragment.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContentProviderCompat.requireContext -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.MessageBottomsheetBinding -import com.flxrs.dankchat.databinding.TimeoutDialogBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.flxrs.dankchat.utils.extensions.showShortSnackbar -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import org.koin.androidx.viewmodel.ext.android.viewModel - -class MessageSheetFragment : BottomSheetDialogFragment() { - - private val args: MessageSheetFragmentArgs by navArgs() - private val viewModel: MessageSheetViewModel by viewModel() - private var bindingRef: MessageBottomsheetBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = MessageBottomsheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - collectFlow(viewModel.state) { state -> - when (state) { - MessageSheetState.Default -> Unit - MessageSheetState.NotFound -> binding.root.showShortSnackbar(getString(R.string.message_not_found)) - is MessageSheetState.Found -> with(binding) { - moderationGroup.isVisible = state.canModerate - messageViewThread.isVisible = state.hasReplyThread - messageReply.isVisible = state.canReply - if (state.canModerate) { - userTimeout.setOnClickListener { showTimeoutDialog() } - userDelete.setOnClickListener { showDeleteDialog() } - userBan.setOnClickListener { showBanDialog() } - userUnban.setOnClickListener { - lifecycleScope.launch { - viewModel.unbanUser() - dialog?.dismiss() - } - } - } - if (state.canReply) { - messageReply.setOnClickListener { sendResultAndDismiss(MessageSheetResult.Reply(state.messageId, state.replyName)) } - } - if (state.hasReplyThread) { - messageViewThread.setOnClickListener { sendResultAndDismiss(MessageSheetResult.ViewThread(state.rootThreadId)) } - } - messageCopy.setOnClickListener { sendResultAndDismiss(MessageSheetResult.Copy(state.originalMessage)) } - messageMoreActions.setOnClickListener { sendResultAndDismiss(MessageSheetResult.OpenMoreActions(args.messageId, args.fullMessage)) } - } - } - } - } - - override fun onResume() { - super.onResume() - dialog?.takeIf { isLandscape }?.let { - val sheet = it as BottomSheetDialog - sheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED - sheet.behavior.skipCollapsed = true - } - } - - override fun onDestroyView() { - bindingRef = null - super.onDestroyView() - } - - private fun sendResultAndDismiss(result: MessageSheetResult) { - findNavController() - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.MESSAGE_SHEET_RESULT_KEY] = result - dialog?.dismiss() - } - - private fun showTimeoutDialog() { - var currentItem = 0 - val choices = resources.getStringArray(R.array.timeout_entries) - val dialogContent = TimeoutDialogBinding.inflate(LayoutInflater.from(requireContext()), null, false).apply { - timeoutSlider.setLabelFormatter { choices[it.toInt()] } - timeoutSlider.addOnChangeListener { _, value, _ -> - currentItem = value.toInt() - timeoutValue.text = choices[value.toInt()] - } - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_user_timeout_title) - .setView(dialogContent.root) - .setPositiveButton(R.string.confirm_user_timeout_positive_button) { _, _ -> - lifecycleScope.launch { - viewModel.timeoutUser(currentItem) - dialog?.dismiss() - } - } - .setNegativeButton(R.string.dialog_cancel) { d, _ -> d.dismiss() } - .show() - } - - private fun showBanDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_user_ban_title) - .setMessage(R.string.confirm_user_ban_message) - .setPositiveButton(R.string.confirm_user_ban_positive_button) { _, _ -> - lifecycleScope.launch { - viewModel.banUser() - dialog?.dismiss() - } - } - .setNegativeButton(R.string.dialog_cancel) { d, _ -> d.dismiss() } - .show() - } - - private fun showDeleteDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_user_delete_title) - .setMessage(R.string.confirm_user_delete_message) - .setPositiveButton(R.string.confirm_user_delete_positive_button) { _, _ -> - lifecycleScope.launch { - viewModel.deleteMessage() - dialog?.dismiss() - } - } - .setNegativeButton(R.string.dialog_cancel) { d, _ -> d.dismiss() } - .show() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetResult.kt deleted file mode 100644 index 715138e70..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetResult.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import android.os.Parcelable -import com.flxrs.dankchat.data.UserName -import kotlinx.parcelize.Parcelize - -sealed interface MessageSheetResult : Parcelable { - - @Parcelize - data class OpenMoreActions(val messageId: String, val fullMessage: String) : MessageSheetResult - - @Parcelize - data class Copy(val message: String) : MessageSheetResult - - @Parcelize - data class Reply(val replyMessageId: String, val replyName: UserName) : MessageSheetResult - - @Parcelize - data class ViewThread(val rootThreadId: String) : MessageSheetResult -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetState.kt deleted file mode 100644 index aa169bc0c..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetState.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import com.flxrs.dankchat.data.UserName - -sealed interface MessageSheetState { - object Default : MessageSheetState - object NotFound : MessageSheetState - data class Found( - val messageId: String, - val name: UserName, - val originalMessage: String, - val canModerate: Boolean, - val replyName: UserName, - val rootThreadId: String, - val hasReplyThread: Boolean, - val canReply: Boolean - ) : MessageSheetState -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetViewModel.kt deleted file mode 100644 index 77cc6286f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MessageSheetViewModel.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import android.util.Log -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.repo.RepliesRepository -import com.flxrs.dankchat.data.repo.channel.ChannelRepository -import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.repo.command.CommandRepository -import com.flxrs.dankchat.data.repo.command.CommandResult -import com.flxrs.dankchat.data.twitch.chat.ConnectionState -import com.flxrs.dankchat.data.twitch.message.PrivMessage -import com.flxrs.dankchat.data.twitch.message.WhisperMessage -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.stateIn -import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds - -@KoinViewModel -class MessageSheetViewModel( - savedStateHandle: SavedStateHandle, - repliesRepository: RepliesRepository, - private val chatRepository: ChatRepository, - private val channelRepository: ChannelRepository, - private val userStateRepository: UserStateRepository, - private val commandRepository: CommandRepository, -) : ViewModel() { - - private val args = MessageSheetFragmentArgs.fromSavedStateHandle(savedStateHandle) - - private val message = flowOf(chatRepository.findMessage(args.messageId, args.channel)) - private val connectionState = chatRepository.getConnectionState(args.channel ?: WhisperMessage.WHISPER_CHANNEL) - - val state = combine(userStateRepository.userState, connectionState, message) { userState, connectionState, message -> - when (message) { - null -> MessageSheetState.NotFound - else -> { - val asPrivMessage = message as? PrivMessage - val asWhisperMessage = message as? WhisperMessage - val rootId = asPrivMessage?.thread?.rootId - val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageSheetState.NotFound - val replyName = asPrivMessage?.thread?.name ?: name - val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage - MessageSheetState.Found( - messageId = message.id, - rootThreadId = rootId ?: message.id, - replyName = replyName, - name = name, - originalMessage = originalMessage.orEmpty(), - canModerate = args.canModerate && args.channel != null && args.channel in userState.moderationChannels, - hasReplyThread = args.canReply && rootId != null && repliesRepository.hasMessageThread(rootId), - canReply = connectionState == ConnectionState.CONNECTED && args.canReply - ) - } - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), MessageSheetState.Default) - - suspend fun timeoutUser(index: Int) { - val duration = TIMEOUT_MAP[index] ?: return - val name = (state.value as? MessageSheetState.Found)?.name ?: return - sendCommand(".timeout $name $duration") - } - - suspend fun banUser() { - val name = (state.value as? MessageSheetState.Found)?.name ?: return - sendCommand(".ban $name") - } - - suspend fun unbanUser() { - val name = (state.value as? MessageSheetState.Found)?.name ?: return - sendCommand(".unban $name") - } - - suspend fun deleteMessage() { - sendCommand(".delete ${args.messageId}") - } - - private suspend fun sendCommand(message: String) { - val channel = args.channel ?: return - val roomState = channelRepository.getRoomState(channel) ?: return - val userState = userStateRepository.userState.value - val result = runCatching { - commandRepository.checkForCommands(message, channel, roomState, userState) - }.getOrNull() ?: return - - when (result) { - is CommandResult.IrcCommand -> chatRepository.sendMessage(message) - is CommandResult.AcceptedTwitchCommand -> result.response?.let { chatRepository.makeAndPostCustomSystemMessage(it, channel) } - else -> Log.d(TAG, "Unhandled command result: $result") - } - } - - companion object { - private val TAG = MessageSheetViewModel::class.java.simpleName - private val TIMEOUT_MAP = mapOf( - 0 to "1", - 1 to "30", - 2 to "60", - 3 to "300", - 4 to "600", - 5 to "1800", - 6 to "3600", - 7 to "86400", - 8 to "604800", - ) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetFragment.kt deleted file mode 100644 index c82796269..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetFragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.MoreActionsMessageBottomsheetBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -class MoreActionsMessageSheetFragment : BottomSheetDialogFragment() { - - private val args: MoreActionsMessageSheetFragmentArgs by navArgs() - private var bindingRef: MoreActionsMessageBottomsheetBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = MoreActionsMessageBottomsheetBinding.inflate(inflater, container, false).apply { - messageCopyFull.setOnClickListener { sendResultAndDismiss(MoreActionsMessageSheetResult.Copy(args.fullMessage)) } - messageCopyId.setOnClickListener { sendResultAndDismiss(MoreActionsMessageSheetResult.CopyId(args.messageId)) } - } - return binding.root - } - - override fun onResume() { - super.onResume() - dialog?.takeIf { isLandscape }?.let { - with(it as BottomSheetDialog) { - behavior.state = BottomSheetBehavior.STATE_EXPANDED - behavior.skipCollapsed = true - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - bindingRef = null - } - - private fun sendResultAndDismiss(result: MoreActionsMessageSheetResult) { - findNavController() - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.COPY_MESSAGE_SHEET_RESULT_KEY] = result - dialog?.dismiss() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetResult.kt deleted file mode 100644 index 11b77865f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/message/MoreActionsMessageSheetResult.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flxrs.dankchat.chat.message - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed interface MoreActionsMessageSheetResult : Parcelable { - @Parcelize - data class Copy(val message: String) : MoreActionsMessageSheetResult - - @Parcelize - data class CopyId(val id: String) : MoreActionsMessageSheetResult -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt deleted file mode 100644 index 810e3bcc6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesChatFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.flxrs.dankchat.chat.replies - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.ChatFragment -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.databinding.ChatFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.showLongSnackbar -import org.koin.androidx.viewmodel.ext.android.viewModel - -class RepliesChatFragment : ChatFragment() { - private val repliesViewModel: RepliesViewModel by viewModel(ownerProducer = { requireParentFragment() }) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = ChatFragmentBinding.inflate(inflater, container, false).apply { - chatLayout.layoutTransition?.setAnimateParentHierarchy(false) - scrollBottom.setOnClickListener { - scrollBottom.visibility = View.GONE - isAtBottom = true - binding.chat.stopScroll() - super.scrollToPosition(adapter.itemCount - 1) - } - } - - collectFlow(repliesViewModel.state) { - when (it) { - is RepliesState.Found -> adapter.submitList(it.items) - is RepliesState.NotFound -> { - binding.root.showLongSnackbar(getString(R.string.reply_thread_not_found)) - } - } - } - - return binding.root - } - - override fun onUserClick(targetUserId: UserId?, targetUserName: UserName, targetDisplayName: DisplayName, channel: UserName?, badges: List, isLongPress: Boolean) { - targetUserId ?: return - val shouldLongClickMention = chatSettingsDataStore.current().userLongClickBehavior == UserLongClickBehavior.MentionsUser - val shouldMention = (isLongPress && shouldLongClickMention) || (!isLongPress && !shouldLongClickMention) - - when { - shouldMention && dankChatPreferenceStore.isLoggedIn -> (parentFragment?.parentFragment as? MainFragment)?.mentionUser(targetUserName, targetDisplayName) - else -> (parentFragment?.parentFragment as? MainFragment)?.openUserPopup( - targetUserId = targetUserId, - targetUserName = targetUserName, - targetDisplayName = targetDisplayName, - channel = channel, - badges = badges, - isWhisperPopup = false - ) - } - } - - override fun onMessageClick(messageId: String, channel: UserName?, fullMessage: String) { - (parentFragment?.parentFragment as? MainFragment)?.openMessageSheet(messageId, channel, fullMessage, canReply = false, canModerate = false) - } - - override fun onEmoteClick(emotes: List) { - (parentFragment?.parentFragment as? MainFragment)?.openEmoteSheet(emotes) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesFragment.kt deleted file mode 100644 index 51fb9fa74..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.flxrs.dankchat.chat.replies - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.commitNow -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.FullScreenSheetState -import com.flxrs.dankchat.databinding.RepliesFragmentBinding -import com.flxrs.dankchat.main.MainViewModel -import com.flxrs.dankchat.utils.extensions.collectFlow -import org.koin.androidx.viewmodel.ext.android.viewModel - -class RepliesFragment : Fragment() { - - private val args: RepliesFragmentArgs by navArgs() - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private val repliesViewModel: RepliesViewModel by viewModel() - - private var bindingRef: RepliesFragmentBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = RepliesFragmentBinding.inflate(inflater, container, false).apply { - repliesToolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } - childFragmentManager.commitNow { - replace(R.id.replies_chat_fragment, RepliesChatFragment()) - } - } - mainViewModel.setFullScreenSheetState(FullScreenSheetState.Replies(args.rootMessageId)) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - collectFlow(repliesViewModel.state) { state -> - if (state !is RepliesState.Found) return@collectFlow - val lastMessage = state.items.lastOrNull() ?: return@collectFlow - mainViewModel.setFullScreenSheetState(FullScreenSheetState.Replies(lastMessage.message.id)) - } - } - - override fun onDestroyView() { - bindingRef = null - super.onDestroyView() - } - - companion object { - fun newInstance(rootMessageId: String) = RepliesFragment().apply { - arguments = RepliesFragmentArgs(rootMessageId).toBundle() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt deleted file mode 100644 index c0e4bd99a..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flxrs.dankchat.chat.replies - -import com.flxrs.dankchat.chat.ChatItem - -sealed interface RepliesState { - object NotFound : RepliesState - data class Found(val items: List) : RepliesState -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt deleted file mode 100644 index 14bdf19ac..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/RepliesViewModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.flxrs.dankchat.chat.replies - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.repo.RepliesRepository -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import org.koin.android.annotation.KoinViewModel -import kotlin.time.Duration.Companion.seconds - -@KoinViewModel -class RepliesViewModel( - repliesRepository: RepliesRepository, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - - private val args = RepliesFragmentArgs.fromSavedStateHandle(savedStateHandle) - - val state = repliesRepository.getThreadItemsFlow(args.rootMessageId) - .map { - when { - it.isEmpty() -> RepliesState.NotFound - else -> RepliesState.Found(it) - } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), RepliesState.Found(emptyList())) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/ReplyInputSheetFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/ReplyInputSheetFragment.kt deleted file mode 100644 index b0cf99861..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/replies/ReplyInputSheetFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.flxrs.dankchat.chat.replies - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.databinding.ReplySheetFragmentBinding -import com.flxrs.dankchat.main.MainViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel - -class ReplyInputSheetFragment : Fragment() { - - private val args: ReplyInputSheetFragmentArgs by navArgs() - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val binding = ReplySheetFragmentBinding.inflate(inflater, container, false).apply { - replyHeader.text = getString(R.string.reply_header, args.replyUser.value) - } - mainViewModel.setReplyingInputSheetState(args.replyMessageId, args.replyUser) - return binding.root - } - - companion object { - fun newInstance(replyMessageId: String, replyUser: UserName) = ReplyInputSheetFragment().apply { - arguments = ReplyInputSheetFragmentArgs(replyMessageId, replyUser).toBundle() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SpaceTokenizer.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SpaceTokenizer.kt deleted file mode 100644 index 42cd409fc..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SpaceTokenizer.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.flxrs.dankchat.chat.suggestion - -import android.text.SpannableString -import android.text.Spanned -import android.text.TextUtils -import android.widget.MultiAutoCompleteTextView - -class SpaceTokenizer : MultiAutoCompleteTextView.Tokenizer { - private val separator: Char = ' ' - - override fun findTokenEnd(text: CharSequence?, cursor: Int): Int { - if (text.isNullOrBlank()) return 0 - - var i = cursor - while (i < text.length) if (text[i] == separator) return i else i++ - - return text.length - } - - override fun findTokenStart(text: CharSequence?, cursor: Int): Int { - if (text.isNullOrBlank()) return 0 - - var i = cursor - while (i > 0 && text[i - 1] != separator) i-- - while (i < cursor && text[i] == separator) i++ - - return i - } - - override fun terminateToken(text: CharSequence): CharSequence { - var i = text.length - while (i > 0 && text[i - 1] == separator) i-- - - return when { - i > 0 && text[i - 1] == separator -> text - text is Spanned -> SpannableString(text.toString() + separator.toString()).apply { - TextUtils.copySpansFrom(text, 0, text.length, Any::class.java, this, 0) - } - - else -> text.toString() + separator - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt deleted file mode 100644 index 3106a2bd4..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/Suggestion.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.flxrs.dankchat.chat.suggestion - -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.twitch.emote.GenericEmote - -sealed interface Suggestion { - data class EmoteSuggestion(val emote: GenericEmote) : Suggestion { - override fun toString() = emote.toString() - } - - data class UserSuggestion(val name: DisplayName, val withLeadingAt: Boolean = false) : Suggestion { - override fun toString() = if (withLeadingAt) "@$name" else name.toString() - } - - data class CommandSuggestion(val command: String) : Suggestion { - override fun toString() = command - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionArrayAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionArrayAdapter.kt deleted file mode 100644 index 1a79e275c..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/suggestion/SuggestionArrayAdapter.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.flxrs.dankchat.chat.suggestion - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.Filter -import android.widget.ImageView -import android.widget.TextView -import coil3.dispose -import coil3.size.Scale -import com.flxrs.dankchat.R -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.extensions.getDrawableAndSetSurfaceTint -import com.flxrs.dankchat.utils.extensions.loadImage -import com.flxrs.dankchat.utils.extensions.replaceAll - -class SuggestionsArrayAdapter( - context: Context, - private val chatSettingsDataStore: ChatSettingsDataStore, - private val onCount: (count: Int) -> Unit -) : ArrayAdapter(context, R.layout.emote_suggestion_item, R.id.suggestion_text) { - private val emotes = mutableListOf() - private val users = mutableListOf() - private val commands = mutableListOf() - private val lock = Object() - - fun setSuggestions(suggestions: Triple, List, List>) { - synchronized(lock) { - users.replaceAll(suggestions.first) - emotes.replaceAll(suggestions.second) - commands.replaceAll(suggestions.third) - val all = suggestions.first + suggestions.second + suggestions.third - replaceAll(all) - } - } - - override fun getCount(): Int = super.getCount().also { onCount(it) } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getView(position, convertView, parent) - val textView = view.findViewById(R.id.suggestion_text) - val imageView = view.findViewById(R.id.suggestion_image) - - imageView.dispose() - imageView.setImageDrawable(null) - getItem(position)?.let { suggestion: Suggestion -> - when (suggestion) { - is Suggestion.EmoteSuggestion -> imageView.loadImage(suggestion.emote.url) { - scale(Scale.FIT) - size(textView.lineHeight * 2) - } - - is Suggestion.UserSuggestion -> { - textView.text = suggestion.name.value - imageView.setImageDrawable(context.getDrawableAndSetSurfaceTint(R.drawable.ic_notification_icon)) - } - - is Suggestion.CommandSuggestion -> imageView.setImageDrawable(context.getDrawableAndSetSurfaceTint(R.drawable.ic_android)) - } - } - - return view - } - - override fun getFilter(): Filter = filter - - private val filter = object : Filter() { - override fun performFiltering(constraint: CharSequence?): FilterResults { - constraint ?: return FilterResults() - val constraintString = constraint.toString() - val userSuggestions = users.filterUsers(constraintString) - val emoteSuggestions = emotes.filterEmotes(constraintString) - val commandsSuggestions = commands.filterCommands(constraintString) - - val suggestions = when { - chatSettingsDataStore.current().preferEmoteSuggestions -> emoteSuggestions + userSuggestions + commandsSuggestions - else -> userSuggestions + emoteSuggestions + commandsSuggestions - } - - return FilterResults().apply { - values = suggestions - count = suggestions.size - } - } - - @Suppress("UNCHECKED_CAST") - override fun publishResults(constraint: CharSequence?, results: FilterResults?) { - val resultList = (results?.values as? Collection).orEmpty() - synchronized(lock) { - replaceAll(resultList) - } - notifyDataSetChanged() - } - } - - private fun List.filterUsers(constraint: String): List = mapNotNull { suggestion -> - when { - constraint.startsWith('@') -> suggestion.copy(withLeadingAt = true) - else -> suggestion - }.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } - } - - private fun List.filterEmotes(constraint: String): List { - val exactSuggestions = filter { it.emote.code.contains(constraint) } - val caseInsensitiveSuggestions = (this - exactSuggestions.toSet()).filter { - it.emote.code.contains(constraint, ignoreCase = true) - } - return exactSuggestions + caseInsensitiveSuggestions - } - - private fun List.filterCommands(constraint: String): List { - return filter { it.command.startsWith(constraint, ignoreCase = true) } - } - - private fun replaceAll(list: Collection) { - clear() - addAll(list) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupBadgeAdapter.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupBadgeAdapter.kt deleted file mode 100644 index abe8db427..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupBadgeAdapter.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.flxrs.dankchat.chat.user - -import android.content.Context -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.widget.TooltipCompat -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.databinding.UserPopupBadgeItemBinding -import com.flxrs.dankchat.utils.extensions.loadImage - -class UserPopupBadgeAdapter : ListAdapter(DetectDiff()) { - - class BadgeViewHolder(val binding: UserPopupBadgeItemBinding) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BadgeViewHolder { - return BadgeViewHolder(UserPopupBadgeItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)) - } - - override fun onBindViewHolder(holder: BadgeViewHolder, position: Int) { - val badge = getItem(position) - val tooltip = badge.getTooltip(holder.binding.root.context) - with(holder.binding.badgeImage) { - if (badge is Badge.FFZModBadge) { - val modColor = ContextCompat.getColor(context, R.color.color_ffz_mod) - colorFilter = PorterDuffColorFilter(modColor, PorterDuff.Mode.DST_OVER) - } - - TooltipCompat.setTooltipText(this, tooltip) - contentDescription = tooltip - val data = when (badge) { - is Badge.SharedChatBadge if badge.url.isEmpty() -> R.drawable.shared_chat - else -> badge.url - } - loadImage(data) - } - } - - private fun Badge.getTooltip(context: Context): String { - if (this is Badge.DankChatBadge) { - return title.orEmpty() - } - - val tag = badgeTag ?: return title.orEmpty() - val key = tag.substringBefore('/').ifBlank { return title.orEmpty() } - val value = tag.substringAfter('/').ifBlank { return title.orEmpty() } - return when (key) { - "bits" -> context.getString(R.string.badge_tooltip_bits, value) - "moderator" -> context.getString(R.string.badge_tooltip_moderator) - "lead_moderator" -> context.getString(R.string.badge_tooltip_lead_moderator) - "vip" -> context.getString(R.string.badge_tooltip_vip) - "predictions" -> { - val info = badgeInfo ?: return title.orEmpty() - context.getString(R.string.badge_tooltip_predictions, info.replace("⸝", ",")) - } - - "subscriber", "founder" -> { - val info = badgeInfo ?: return title.orEmpty() - val subTier = if (value.length > 3) value.first() else "1" - val months = info.toIntOrNull()?.let { - context.resources.getQuantityString(R.plurals.months, it, it) - } ?: return title.orEmpty() - - buildString { - append(title) - append(" (") - if (subTier != "1") { - val tier = context.getString(R.string.badge_tooltip_sub_tier, subTier) - append(tier) - append(", ") - } - append(months) - append(")") - } - } - - else -> title.orEmpty() - } - } -} - -private class DetectDiff : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Badge, newItem: Badge): Boolean = oldItem.url == newItem.url - override fun areContentsTheSame(oldItem: Badge, newItem: Badge): Boolean = oldItem.url == newItem.url -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupDialogFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupDialogFragment.kt deleted file mode 100644 index ebd0d36f6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupDialogFragment.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.flxrs.dankchat.chat.user - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContentProviderCompat.requireContext -import androidx.core.content.ContextCompat.startActivity -import androidx.core.net.toUri -import androidx.core.view.isVisible -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import coil3.load -import coil3.size.Scale -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.UserPopupBottomsheetBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.flxrs.dankchat.utils.extensions.loadImage -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koin.androidx.viewmodel.ext.android.viewModel - -class UserPopupDialogFragment : BottomSheetDialogFragment() { - - private val args: UserPopupDialogFragmentArgs by navArgs() - private val viewModel: UserPopupViewModel by viewModel() - private var bindingRef: UserPopupBottomsheetBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = UserPopupBottomsheetBinding.inflate(inflater, container, false).apply { - userMention.setOnClickListener { - setResultAndDismiss(UserPopupResult.Mention(viewModel.userName, viewModel.displayName)) - } - userWhisper.setOnClickListener { - setResultAndDismiss(UserPopupResult.Whisper(viewModel.userName)) - } - - userBlock.setOnClickListener { - when { - viewModel.isBlocked -> viewModel.unblockUser() - else -> MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_user_block_title) - .setMessage(R.string.confirm_user_block_message) - .setPositiveButton(R.string.confirm_user_block_positive_button) { _, _ -> viewModel.blockUser() } - .setNegativeButton(R.string.dialog_cancel) { d, _ -> d.dismiss() } - .show() - } - } - - userAvatarCard.setOnClickListener { - val userName = viewModel.userName - val url = "https://twitch.tv/$userName" - Intent(Intent.ACTION_VIEW).also { - it.data = url.toUri() - startActivity(it) - } - } - userReport.setOnClickListener { - val userName = viewModel.userName - val url = "https://twitch.tv/$userName/report" - Intent(Intent.ACTION_VIEW).also { - it.data = url.toUri() - startActivity(it) - } - } - userBadges.apply { - layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - val adapter = UserPopupBadgeAdapter().also { adapter = it } - adapter.submitList(args.badges.toList()) - } - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - collectFlow(viewModel.userPopupState) { - when (it) { - is UserPopupState.Loading -> binding.showLoadingState(it) - is UserPopupState.NotLoggedIn -> binding.showNotLoggedInState(it) - is UserPopupState.Success -> binding.updateUserData(it) - is UserPopupState.Error -> setResultAndDismiss(UserPopupResult.Error(it.throwable)) - } - } - } - - override fun onResume() { - super.onResume() - dialog?.takeIf { isLandscape }?.let { - val sheet = it as BottomSheetDialog - sheet.behavior.state = BottomSheetBehavior.STATE_EXPANDED - sheet.behavior.skipCollapsed = true - } - } - - override fun onDestroyView() { - super.onDestroyView() - bindingRef = null - } - - private fun UserPopupBottomsheetBinding.showLoadingState(state: UserPopupState.Loading) { - userGroup.isVisible = false - userLoading.isVisible = true - userBlock.isEnabled = false - userName.text = state.userName.formatWithDisplayName(state.displayName) - } - - private fun UserPopupBottomsheetBinding.updateUserData(userState: UserPopupState.Success) { - userAvatar.loadImage(userState.avatarUrl, placeholder = null, afterLoad = { userAvatarLoading.isVisible = false }) - userLoading.isVisible = false - userGroup.isVisible = true - userBlock.isEnabled = true - userName.text = userState.userName.formatWithDisplayName(userState.displayName) - userCreated.text = getString(R.string.user_popup_created, userState.created) - userFollowage.isVisible = userState.showFollowingSince - userFollowage.text = userState.followingSince?.let { - getString(R.string.user_popup_following_since, it) - } ?: getString(R.string.user_popup_not_following) - userBlock.text = when { - userState.isBlocked -> getString(R.string.user_popup_unblock) - else -> getString(R.string.user_popup_block) - } - } - - private fun UserPopupBottomsheetBinding.showNotLoggedInState(state: UserPopupState.NotLoggedIn) { - userLoading.isVisible = false - userGroup.isVisible = true - userAvatarLoading.isVisible = false - userAvatar.load(R.drawable.ic_person) { - scale(Scale.FIT) - } - - userName.text = state.userName.formatWithDisplayName(state.displayName) - - userMention.isEnabled = false - userWhisper.isEnabled = false - userBlock.isEnabled = false - } - - private fun setResultAndDismiss(result: UserPopupResult) { - findNavController() - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.USER_POPUP_RESULT_KEY] = result - dialog?.dismiss() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupResult.kt deleted file mode 100644 index c84ea62bf..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.chat.user - -import android.os.Parcelable -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserName -import kotlinx.parcelize.Parcelize - -sealed interface UserPopupResult : Parcelable { - @Parcelize - data class Error(val throwable: Throwable?) : UserPopupResult - - @Parcelize - data class Whisper(val targetUser: UserName) : UserPopupResult - - @Parcelize - data class Mention(val targetUser: UserName, val targetDisplayName: DisplayName) : UserPopupResult -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt index 0ceae27a0..6826cd970 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/DisplayName.kt @@ -7,9 +7,12 @@ import kotlinx.serialization.Serializable @JvmInline @Serializable @Parcelize -value class DisplayName(val value: String) : Parcelable { +value class DisplayName( + val value: String, +) : Parcelable { override fun toString() = value } fun DisplayName.toUserName() = UserName(value) -fun String.toDisplayName() = DisplayName(this) \ No newline at end of file + +fun String.toDisplayName() = DisplayName(this) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt index 512be398d..1fe235b0c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserId.kt @@ -7,11 +7,12 @@ import kotlinx.serialization.Serializable @JvmInline @Serializable @Parcelize -value class UserId(val value: String) : Parcelable { +value class UserId( + val value: String, +) : Parcelable { override fun toString() = value } fun String.toUserId() = UserId(this) -inline fun UserId.ifBlank(default: () -> UserId?): UserId? { - return if (value.isBlank()) default() else this -} \ No newline at end of file + +inline fun UserId.ifBlank(default: () -> UserId?): UserId? = if (value.isBlank()) default() else this diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt index 10a257f33..08ddd2782 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/UserName.kt @@ -7,25 +7,32 @@ import kotlinx.serialization.Serializable @JvmInline @Serializable @Parcelize -value class UserName(val value: String) : Parcelable { - +value class UserName( + val value: String, +) : Parcelable { override fun toString() = value fun lowercase() = UserName(value.lowercase()) fun formatWithDisplayName(displayName: DisplayName): String = when { matches(displayName) -> displayName.value - else -> "$this($displayName)" + else -> "$this($displayName)" } fun valueOrDisplayName(displayName: DisplayName): String = when { matches(displayName) -> displayName.value - else -> this.value + else -> this.value } - fun matches(other: String, ignoreCase: Boolean = true) = value.equals(other, ignoreCase) + fun matches( + other: String, + ignoreCase: Boolean = true, + ) = value.equals(other, ignoreCase) + fun matches(other: UserName) = value.equals(other.value, ignoreCase = true) + fun matches(other: DisplayName) = value.equals(other.value, ignoreCase = true) + fun matches(regex: Regex) = value.matches(regex) companion object { @@ -34,8 +41,9 @@ value class UserName(val value: String) : Parcelable { } fun UserName.toDisplayName() = DisplayName(value) + fun String.toUserName() = UserName(this) + fun Collection.toUserNames() = map(String::toUserName) -inline fun UserName.ifBlank(default: () -> UserName?): UserName? { - return if (value.isBlank()) default() else this -} + +inline fun UserName.ifBlank(default: () -> UserName?): UserName? = if (value.isBlank()) default() else this diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ApiException.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ApiException.kt index 0b1469416..9c031690b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ApiException.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ApiException.kt @@ -15,12 +15,9 @@ open class ApiException( open val status: HttpStatusCode, open val url: Url?, override val message: String?, - override val cause: Throwable? = null + override val cause: Throwable? = null, ) : Throwable(message, cause) { - - override fun toString(): String { - return "ApiException(status=$status, url=$url, message=$message, cause=$cause)" - } + override fun toString(): String = "ApiException(status=$status, url=$url, message=$message, cause=$cause)" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -46,7 +43,7 @@ open class ApiException( fun Result.recoverNotFoundWith(default: R): Result = recoverCatching { when { it is ApiException && it.status == HttpStatusCode.NotFound -> default - else -> throw it + else -> throw it } } @@ -64,4 +61,6 @@ suspend fun HttpResponse.throwApiErrorOnFailure(json: Json): HttpResponse { @Keep @Serializable -private data class GenericError(val message: String) +private data class GenericError( + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApi.kt index c3c267029..0bc0c7e0e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApi.kt @@ -2,11 +2,26 @@ package com.flxrs.dankchat.data.api.auth import io.ktor.client.HttpClient import io.ktor.client.request.bearerAuth +import io.ktor.client.request.forms.submitForm import io.ktor.client.request.get +import io.ktor.http.parameters -class AuthApi(private val ktorClient: HttpClient) { - +class AuthApi( + private val ktorClient: HttpClient, +) { suspend fun validateUser(token: String) = ktorClient.get("validate") { bearerAuth(token) } -} \ No newline at end of file + + suspend fun revokeToken( + token: String, + clientId: String, + ) = ktorClient.submitForm( + url = "revoke", + formParameters = + parameters { + append("client_id", clientId) + append("token", token) + }, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApiClient.kt index 1eea9dce4..789e8b819 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/AuthApiClient.kt @@ -3,7 +3,7 @@ package com.flxrs.dankchat.data.api.auth import com.flxrs.dankchat.data.api.ApiException import com.flxrs.dankchat.data.api.auth.dto.ValidateDto import com.flxrs.dankchat.data.api.auth.dto.ValidateErrorDto -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.auth.AuthSettings import com.flxrs.dankchat.utils.extensions.decodeOrNull import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText @@ -13,61 +13,78 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class AuthApiClient(private val authApi: AuthApi, private val json: Json) { - +class AuthApiClient( + private val authApi: AuthApi, + private val json: Json, +) { suspend fun validateUser(token: String): Result = runCatching { val response = authApi.validateUser(token) when { - response.status.isSuccess() -> response.body() - else -> { + response.status.isSuccess() -> { + response.body() + } + + else -> { val error = json.decodeOrNull(response.bodyAsText()) throw ApiException(status = response.status, response.request.url, error?.message) } } } + suspend fun revokeToken( + token: String, + clientId: String, + ): Result = runCatching { + authApi.revokeToken(token, clientId) + } + fun validateScopes(scopes: List): Boolean = scopes.containsAll(SCOPES) + fun missingScopes(scopes: List): Set = SCOPES - scopes.toSet() companion object { private const val BASE_LOGIN_URL = "https://id.twitch.tv/oauth2/authorize?response_type=token" private const val REDIRECT_URL = "https://flxrs.com/dankchat" - val SCOPES = setOf( - "channel:edit:commercial", - "channel:manage:broadcast", - "channel:manage:moderators", - "channel:manage:polls", - "channel:manage:predictions", - "channel:manage:raids", - "channel:manage:vips", - "channel:moderate", - "channel:read:polls", - "channel:read:predictions", - "channel:read:redemptions", - "chat:edit", - "chat:read", - "moderator:manage:announcements", - "moderator:manage:automod", - "moderator:manage:automod_settings", - "moderator:manage:banned_users", - "moderator:manage:blocked_terms", - "moderator:manage:chat_messages", - "moderator:manage:chat_settings", - "moderator:manage:shield_mode", - "moderator:manage:shoutouts", - "moderator:manage:unban_requests", - "moderator:manage:warnings", - "moderator:read:chatters", - "moderator:read:followers", - "moderator:read:moderators", - "moderator:read:vips", - "user:manage:blocked_users", - "user:manage:chat_color", - "user:manage:whispers", - "user:read:blocked_users", - "whispers:edit", - "whispers:read", - ) - val LOGIN_URL = "$BASE_LOGIN_URL&client_id=${DankChatPreferenceStore.DEFAULT_CLIENT_ID}&redirect_uri=$REDIRECT_URL&scope=${SCOPES.joinToString(separator = "+")}" + val SCOPES = + setOf( + "channel:edit:commercial", + "channel:manage:broadcast", + "channel:manage:moderators", + "channel:manage:polls", + "channel:manage:predictions", + "channel:manage:raids", + "channel:manage:vips", + "channel:moderate", + "channel:read:polls", + "channel:read:predictions", + "channel:read:redemptions", + "chat:edit", + "chat:read", + "moderator:manage:announcements", + "moderator:manage:automod", + "moderator:manage:automod_settings", + "moderator:manage:banned_users", + "moderator:manage:blocked_terms", + "moderator:manage:chat_messages", + "moderator:manage:chat_settings", + "moderator:manage:shield_mode", + "moderator:manage:shoutouts", + "moderator:manage:unban_requests", + "moderator:manage:warnings", + "moderator:read:chatters", + "moderator:read:followers", + "moderator:read:moderators", + "moderator:read:vips", + "user:manage:blocked_users", + "user:manage:chat_color", + "user:manage:whispers", + "user:read:blocked_users", + "user:read:chat", + "user:read:emotes", + "user:write:chat", + "whispers:edit", + "whispers:read", + ) + val LOGIN_URL = "$BASE_LOGIN_URL&client_id=${AuthSettings.DEFAULT_CLIENT_ID}&redirect_uri=$REDIRECT_URL&scope=${SCOPES.joinToString(separator = "+")}" } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateDto.kt index f40ef0b6a..1d530404a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateDto.kt @@ -12,6 +12,5 @@ data class ValidateDto( @SerialName(value = "client_id") val clientId: String, @SerialName(value = "login") val login: UserName, @SerialName(value = "scopes") val scopes: List?, - @SerialName(value = "user_id") val userId: UserId + @SerialName(value = "user_id") val userId: UserId, ) - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateErrorDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateErrorDto.kt index e11cdb685..71aacc1f4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateErrorDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/auth/dto/ValidateErrorDto.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable @Serializable data class ValidateErrorDto( val status: Int, - val message: String -) \ No newline at end of file + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApi.kt index d3874af7b..30611fa90 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApi.kt @@ -4,9 +4,10 @@ import com.flxrs.dankchat.data.UserId import io.ktor.client.HttpClient import io.ktor.client.request.get -class BadgesApi(private val ktorClient: HttpClient) { - +class BadgesApi( + private val ktorClient: HttpClient, +) { suspend fun getGlobalBadges() = ktorClient.get("global/display") suspend fun getChannelBadges(channelId: UserId) = ktorClient.get("channels/$channelId/display") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApiClient.kt index d3b759bc4..af863c15b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/BadgesApiClient.kt @@ -9,16 +9,20 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class BadgesApiClient(private val badgesApi: BadgesApi, private val json: Json) { - +class BadgesApiClient( + private val badgesApi: BadgesApi, + private val json: Json, +) { suspend fun getChannelBadges(channelId: UserId): Result = runCatching { - badgesApi.getChannelBadges(channelId) + badgesApi + .getChannelBadges(channelId) .throwApiErrorOnFailure(json) .body() }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) suspend fun getGlobalBadges(): Result = runCatching { - badgesApi.getGlobalBadges() + badgesApi + .getGlobalBadges() .throwApiErrorOnFailure(json) .body() }.recoverNotFoundWith(TwitchBadgeSetsDto(sets = emptyMap())) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeDto.kt index 5ad49681e..69a474c47 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeDto.kt @@ -11,4 +11,4 @@ data class TwitchBadgeDto( @SerialName(value = "image_url_2x") val imageUrlMedium: String, @SerialName(value = "image_url_4x") val imageUrlHigh: String, @SerialName(value = "title") val title: String, -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetDto.kt index 53d6a7be0..0c3c5fc64 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class TwitchBadgeSetDto(@SerialName(value = "versions") val versions: Map) \ No newline at end of file +data class TwitchBadgeSetDto( + @SerialName(value = "versions") val versions: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetsDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetsDto.kt index 789d4a0f7..3eeefba22 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetsDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/badges/dto/TwitchBadgeSetsDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class TwitchBadgeSetsDto(@SerialName(value = "badge_sets") val sets: Map) \ No newline at end of file +data class TwitchBadgeSetsDto( + @SerialName(value = "badge_sets") val sets: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApi.kt index 05dfef840..3b7340ad2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApi.kt @@ -4,9 +4,10 @@ import com.flxrs.dankchat.data.UserId import io.ktor.client.HttpClient import io.ktor.client.request.get -class BTTVApi(private val ktorClient: HttpClient) { - +class BTTVApi( + private val ktorClient: HttpClient, +) { suspend fun getChannelEmotes(channelId: UserId) = ktorClient.get("users/twitch/$channelId") suspend fun getGlobalEmotes() = ktorClient.get("emotes/global") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApiClient.kt index 9311283e4..60950924d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/BTTVApiClient.kt @@ -10,16 +10,20 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class BTTVApiClient(private val bttvApi: BTTVApi, private val json: Json) { - +class BTTVApiClient( + private val bttvApi: BTTVApi, + private val json: Json, +) { suspend fun getBTTVChannelEmotes(channelId: UserId): Result = runCatching { - bttvApi.getChannelEmotes(channelId) + bttvApi + .getChannelEmotes(channelId) .throwApiErrorOnFailure(json) .body() }.recoverNotFoundWith(null) suspend fun getBTTVGlobalEmotes(): Result> = runCatching { - bttvApi.getGlobalEmotes() + bttvApi + .getGlobalEmotes() .throwApiErrorOnFailure(json) .body() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVChannelDto.kt index e07dc375d..1b693d1ca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVChannelDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVChannelDto.kt @@ -10,5 +10,5 @@ data class BTTVChannelDto( @SerialName(value = "id") val id: String, @SerialName(value = "bots") val bots: List, @SerialName(value = "channelEmotes") val emotes: List, - @SerialName(value = "sharedEmotes") val sharedEmotes: List -) \ No newline at end of file + @SerialName(value = "sharedEmotes") val sharedEmotes: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteDto.kt index c1341d4ec..bb5d87c30 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteDto.kt @@ -10,4 +10,3 @@ data class BTTVEmoteDto( val code: String, val user: BTTVEmoteUserDto?, ) - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteUserDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteUserDto.kt index 7edc2859e..37a4e5d3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteUserDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVEmoteUserDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BTTVEmoteUserDto(val displayName: DisplayName?) +data class BTTVEmoteUserDto( + val displayName: DisplayName?, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVGlobalEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVGlobalEmoteDto.kt index 1b71e6f6f..15781970d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVGlobalEmoteDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/bttv/dto/BTTVGlobalEmoteDto.kt @@ -9,4 +9,4 @@ import kotlinx.serialization.Serializable data class BTTVGlobalEmoteDto( @SerialName(value = "id") val id: String, @SerialName(value = "code") val code: String, -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApi.kt index 717f78f05..08d664729 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApi.kt @@ -2,13 +2,9 @@ package com.flxrs.dankchat.data.api.dankchat import io.ktor.client.HttpClient import io.ktor.client.request.get -import io.ktor.client.request.parameter - -class DankChatApi(private val ktorClient: HttpClient) { - - suspend fun getSets(ids: String) = ktorClient.get("sets") { - parameter("id", ids) - } +class DankChatApi( + private val ktorClient: HttpClient, +) { suspend fun getDankChatBadges() = ktorClient.get("badges") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApiClient.kt index 61a9b5a22..e6f4b0540 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/DankChatApiClient.kt @@ -1,23 +1,19 @@ package com.flxrs.dankchat.data.api.dankchat import com.flxrs.dankchat.data.api.dankchat.dto.DankChatBadgeDto -import com.flxrs.dankchat.data.api.dankchat.dto.DankChatEmoteSetDto import com.flxrs.dankchat.data.api.throwApiErrorOnFailure import io.ktor.client.call.body import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class DankChatApiClient(private val dankChatApi: DankChatApi, private val json: Json) { - - suspend fun getUserSets(sets: List): Result> = runCatching { - dankChatApi.getSets(sets.joinToString(separator = ",")) - .throwApiErrorOnFailure(json) - .body() - } - +class DankChatApiClient( + private val dankChatApi: DankChatApi, + private val json: Json, +) { suspend fun getDankChatBadges(): Result> = runCatching { - dankChatApi.getDankChatBadges() + dankChatApi + .getDankChatBadges() .throwApiErrorOnFailure(json) .body() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatBadgeDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatBadgeDto.kt index 2cfedf09f..f901d5ed8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatBadgeDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatBadgeDto.kt @@ -10,5 +10,5 @@ import kotlinx.serialization.Serializable data class DankChatBadgeDto( @SerialName(value = "type") val type: String, @SerialName(value = "url") val url: String, - @SerialName(value = "users") val users: List -) \ No newline at end of file + @SerialName(value = "users") val users: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteDto.kt deleted file mode 100644 index 41c42c73e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteDto.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.flxrs.dankchat.data.api.dankchat.dto - -import androidx.annotation.Keep -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class DankChatEmoteDto( - @SerialName(value = "code") val name: String, - @SerialName(value = "id") val id: String, - @SerialName(value = "type") val type: String?, - @SerialName(value = "assetType") val assetType: String?, -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteSetDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteSetDto.kt deleted file mode 100644 index 611bfafa1..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/dankchat/dto/DankChatEmoteSetDto.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.data.api.dankchat.dto - -import androidx.annotation.Keep -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class DankChatEmoteSetDto( - @SerialName(value = "set_id") val id: String, - @SerialName(value = "channel_name") val channelName: UserName, - @SerialName(value = "channel_id") val channelId: UserId, - @SerialName(value = "tier") val tier: Int, - @SerialName(value = "emotes") val emotes: List? -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClient.kt index c14fd5e8e..af5aa1eac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClient.kt @@ -1,15 +1,19 @@ package com.flxrs.dankchat.data.api.eventapi -import android.util.Log import com.flxrs.dankchat.data.api.eventapi.dto.messages.EventSubMessageDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.KeepAliveMessageDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.NotificationMessageDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.ReconnectMessageDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.RevocationMessageDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.WelcomeMessageDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodMessageHoldDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodMessageUpdateDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelChatUserMessageHoldDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelChatUserMessageUpdateDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateDto import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.di.DispatchersProvider +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.WebSockets @@ -47,6 +51,8 @@ import kotlin.random.Random import kotlin.random.nextLong import kotlin.time.Duration.Companion.seconds +private val logger = KotlinLogging.logger("EventSubClient") + @OptIn(DelicateCoroutinesApi::class) @Single class EventSubClient( @@ -55,7 +61,6 @@ class EventSubClient( httpClient: HttpClient, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.io) private var session: DefaultClientWebSocketSession? = null private var previousSession: DefaultClientWebSocketSession? = null @@ -66,17 +71,21 @@ class EventSubClient( private val eventsChannel = Channel(Channel.UNLIMITED) - private val client = httpClient.config { - install(WebSockets) - } + private val client = + httpClient.config { + install(WebSockets) + } val connected get() = session?.isActive == true && session?.incoming?.isClosedForReceive == false val state = _state.asStateFlow() val topics = subscriptions.asStateFlow() val events = eventsChannel.receiveAsFlow().shareIn(scope = scope, started = SharingStarted.Eagerly) - fun connect(url: String = DEFAULT_URL, twitchReconnect: Boolean = false) { - Log.i(TAG, "[EventSub] starting connection, twitchReconnect=$twitchReconnect") + fun connect( + url: String = DEFAULT_URL, + twitchReconnect: Boolean = false, + ) { + logger.info { "[EventSub] starting connection, twitchReconnect=$twitchReconnect" } emitSystemMessage(message = "[EventSub] connecting, twitchReconnect=$twitchReconnect") if (!twitchReconnect) { @@ -92,39 +101,43 @@ class EventSubClient( session = this while (isActive) { val result = incoming.receiveCatching() - val raw = when (val element = result.getOrNull()) { - null -> { - val cause = result.exceptionOrNull() - if (cause == null) { - // websocket likely received a close frame, no need to reconnect - return@webSocket + val raw = + when (val element = result.getOrNull()) { + null -> { + val cause = + result.exceptionOrNull() ?: // websocket likely received a close frame, no need to reconnect + return@webSocket + + // rethrow to trigger reconnect logic + throw cause } - // rethrow to trigger reconnect logic - throw cause + else -> { + (element as? Frame.Text)?.readText() ?: continue + } } - else -> (element as? Frame.Text)?.readText() ?: continue - } - - //Log.v(TAG, "[EventSub] Received raw message: $raw") + // logger.trace { "[EventSub] Received raw message: $raw" } - val jsonObject = json - .parseToJsonElement(raw) - .fixDiscriminators() + val jsonObject = + json + .parseToJsonElement(raw) + .fixDiscriminators() - val message = runCatching { json.decodeFromJsonElement(jsonObject) } - .getOrElse { - Log.e(TAG, "[EventSub] failed to parse message: $it") - emitSystemMessage(message = "[EventSub] failed to parse message: $it") - continue - } + val message = + runCatching { json.decodeFromJsonElement(jsonObject) } + .getOrElse { + logger.error { "[EventSub] failed to parse message: $it" } + logger.error { "[EventSub] raw JSON: $jsonObject" } + emitSystemMessage(message = "[EventSub] failed to parse message: $it") + continue + } when (message) { - is WelcomeMessageDto -> { + is WelcomeMessageDto -> { retryCount = 0 sessionId = message.payload.session.id - Log.i(TAG, "[EventSub]($sessionId) received welcome message, status=${message.payload.session.status}") + logger.info { "[EventSub]($sessionId) received welcome message, status=${message.payload.session.status}" } emitSystemMessage(message = "[EventSub]($sessionId) received welcome message, status=${message.payload.session.status}") _state.update { EventSubClientState.Connected(message.payload.session.id) } @@ -144,22 +157,32 @@ class EventSubClient( } } - is ReconnectMessageDto -> handleReconnect(message) - is RevocationMessageDto -> handleRevocation(message) - is NotificationMessageDto -> handleNotification(message) - is KeepAliveMessageDto -> Unit + is ReconnectMessageDto -> { + handleReconnect(message) + } + + is RevocationMessageDto -> { + handleRevocation(message) + } + + is NotificationMessageDto -> { + handleNotification(message) + } + + is KeepAliveMessageDto -> { + Unit + } } } } - Log.i(TAG, "[EventSub]($sessionId) connection closed") + logger.info { "[EventSub]($sessionId) connection closed" } emitSystemMessage(message = "[EventSub]($sessionId) connection closed") shouldDiscardSession(sessionId) return@launch - } catch (t: Throwable) { - Log.e(TAG, "[EventSub]($sessionId) connection failed: $t") + logger.error { "[EventSub]($sessionId) connection failed: $t" } emitSystemMessage(message = "[EventSub]($sessionId) connection failed: $t") if (shouldDiscardSession(sessionId)) { return@launch @@ -169,12 +192,12 @@ class EventSubClient( val reconnectDelay = RECONNECT_BASE_DELAY * (1 shl (retryCount - 1)) delay(reconnectDelay + jitter) retryCount = (retryCount + 1).coerceAtMost(RECONNECT_MAX_ATTEMPTS) - Log.i(TAG, "[EventSub] attempting to reconnect #$retryCount..") + logger.info { "[EventSub] attempting to reconnect #$retryCount.." } emitSystemMessage(message = "[EventSub] attempting to reconnect #$retryCount..") } } - Log.e(TAG, "[EventSub] connection failed after $retryCount retries, cleaning up..") + logger.error { "[EventSub] connection failed after $retryCount retries, cleaning up.." } emitSystemMessage(message = "[EventSub] connection failed after $retryCount retries, cleaning up..") _state.update { EventSubClientState.Failed } subscriptions.update { emptySet() } @@ -192,45 +215,49 @@ class EventSubClient( // check state, if we are not connected, we need to start a connection val current = state.value if (current is EventSubClientState.Disconnected || current is EventSubClientState.Failed) { - Log.d(TAG, "[EventSub] is not connected, connecting") + logger.debug { "[EventSub] is not connected, connecting" } connect() } - val connectedState = withTimeoutOrNull(SUBSCRIPTION_TIMEOUT) { - state.filterIsInstance().first() - } ?: return@withLock + val connectedState = + withTimeoutOrNull(SUBSCRIPTION_TIMEOUT) { + state.filterIsInstance().first() + } ?: return@withLock val request = topic.createRequest(connectedState.sessionId) - val response = helixApiClient.postEventSubSubscription(request) - .getOrElse { - // TODO: handle errors, maybe retry? - Log.e(TAG, "[EventSub] failed to subscribe: $it") - emitSystemMessage(message = "[EventSub] failed to subscribe: $it") - return@withLock - } + val response = + helixApiClient + .postEventSubSubscription(request) + .getOrElse { + // TODO: handle errors, maybe retry? + logger.error { "[EventSub] failed to subscribe: $it" } + emitSystemMessage(message = "[EventSub] failed to subscribe: $it") + return@withLock + } val subscription = response.data.firstOrNull()?.id if (subscription == null) { - Log.e(TAG, "[EventSub] subscription response did not include subscription id: $response") + logger.error { "[EventSub] subscription response did not include subscription id: $response" } return@withLock } - Log.d(TAG, "[EventSub] subscribed to $topic") + logger.debug { "[EventSub] subscribed to $topic" } emitSystemMessage(message = "[EventSub] subscribed to ${topic.shortFormatted()}") subscriptions.update { it + SubscribedTopic(subscription, topic) } } suspend fun unsubscribe(topic: SubscribedTopic) { wantedSubscriptions -= topic.topic - helixApiClient.deleteEventSubSubscription(topic.id) + helixApiClient + .deleteEventSubSubscription(topic.id) .getOrElse { // TODO: handle errors, maybe retry? - Log.e(TAG, "[EventSub] failed to unsubscribe: $it") + logger.error { "[EventSub] failed to unsubscribe: $it" } emitSystemMessage(message = "[EventSub] failed to unsubscribe: $it") return@getOrElse } - Log.d(TAG, "[EventSub] unsubscribed from $topic") + logger.debug { "[EventSub] unsubscribed from $topic" } emitSystemMessage(message = "[EventSub] unsubscribed from ${topic.topic.shortFormatted()}") subscriptions.update { it - topic } } @@ -257,31 +284,75 @@ class EventSubClient( } private fun handleNotification(message: NotificationMessageDto) { - Log.d(TAG, "[EventSub] received notification message: $message") - val event = message.payload.event - val message = when (event) { - is ChannelModerateDto -> ModerationAction( - id = message.metadata.messageId, - timestamp = message.metadata.messageTimestamp, - channelName = event.broadcasterUserLogin, - data = event, - ) - } - eventsChannel.trySend(message) + logger.debug { "[EventSub] received notification message: $message" } + val eventSubMessage = + when (val event = message.payload.event) { + is ChannelModerateDto -> { + ModerationAction( + id = message.metadata.messageId, + timestamp = message.metadata.messageTimestamp, + channelName = event.broadcasterUserLogin, + data = event, + ) + } + + is AutomodMessageHoldDto -> { + AutomodHeld( + id = message.metadata.messageId, + timestamp = message.metadata.messageTimestamp, + channelName = event.broadcasterUserLogin, + data = event, + ) + } + + is AutomodMessageUpdateDto -> { + AutomodUpdate( + id = message.metadata.messageId, + timestamp = message.metadata.messageTimestamp, + channelName = event.broadcasterUserLogin, + data = event, + ) + } + + is ChannelChatUserMessageHoldDto -> { + UserMessageHeld( + id = message.metadata.messageId, + timestamp = message.metadata.messageTimestamp, + channelName = event.broadcasterUserLogin, + data = event, + ) + } + + is ChannelChatUserMessageUpdateDto -> { + UserMessageUpdated( + id = message.metadata.messageId, + timestamp = message.metadata.messageTimestamp, + channelName = event.broadcasterUserLogin, + data = event, + ) + } + } + eventsChannel.trySend(eventSubMessage) } private fun handleRevocation(message: RevocationMessageDto) { - Log.i(TAG, "[EventSub] received revocation message for subscription: ${message.payload.subscription}") + logger.info { "[EventSub] received revocation message for subscription: ${message.payload.subscription}" } emitSystemMessage(message = "[EventSub] received revocation message for subscription: ${message.payload.subscription}") - subscriptions.update { it.filterTo(mutableSetOf()) { it.id == message.payload.subscription.id } } + subscriptions.update { it.filterTo(mutableSetOf()) { sub -> sub.id == message.payload.subscription.id } } } private fun DefaultClientWebSocketSession.handleReconnect(message: ReconnectMessageDto) { - Log.i(TAG, "[EventSub] received request to reconnect: ${message.payload.session.reconnectUrl}") + logger.info { "[EventSub] received request to reconnect: ${message.payload.session.reconnectUrl}" } emitSystemMessage(message = "[EventSub] received request to reconnect") - val url = message.payload.session.reconnectUrl?.replaceFirst("ws://", "wss://") - when (url) { - null -> reconnect() + when ( + val url = + message.payload.session.reconnectUrl + ?.replaceFirst("ws://", "wss://") + ) { + null -> { + reconnect() + } + else -> { previousSession = this connect(url = url, twitchReconnect = true) @@ -304,12 +375,12 @@ class EventSubClient( when (current) { // this session got closed but we are already connected to a new one, don't update the state is EventSubClientState.Connected if sessionId != current.sessionId -> { - Log.d(TAG, "[EventSub]($sessionId) Discarding session as we are already connected to a new one (${current.sessionId})") + logger.debug { "[EventSub]($sessionId) Discarding session as we are already connected to a new one (${current.sessionId})" } emitSystemMessage(message = "[EventSub]($sessionId) Discarding session as we are already connected to a new one (${current.sessionId})") return true } - else -> { + else -> { subscriptions.update { emptySet() } EventSubClientState.Disconnected } @@ -358,6 +429,5 @@ class EventSubClient( const val RECONNECT_BASE_DELAY = 1_000L const val RECONNECT_MAX_ATTEMPTS = 6 val SUBSCRIPTION_TIMEOUT = 5.seconds - val TAG = EventSubClient::class.simpleName } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClientState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClientState.kt index 41209bd2d..44e88693b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClientState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubClientState.kt @@ -2,7 +2,12 @@ package com.flxrs.dankchat.data.api.eventapi sealed interface EventSubClientState { data object Disconnected : EventSubClientState + data object Failed : EventSubClientState + data object Connecting : EventSubClientState - data class Connected(val sessionId: String) : EventSubClientState + + data class Connected( + val sessionId: String, + ) : EventSubClientState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubManager.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubManager.kt index a64d33a4d..516c1c866 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubManager.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubManager.kt @@ -1,14 +1,16 @@ package com.flxrs.dankchat.data.api.eventapi import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider import com.flxrs.dankchat.data.repo.chat.UserStateRepository import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.Single @@ -17,8 +19,9 @@ import org.koin.core.annotation.Single class EventSubManager( private val eventSubClient: EventSubClient, private val channelRepository: ChannelRepository, + private val chatChannelProvider: ChatChannelProvider, private val userStateRepository: UserStateRepository, - private val preferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, developerSettingsDataStore: DeveloperSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { @@ -26,8 +29,9 @@ class EventSubManager( private val isEnabled = developerSettingsDataStore.current().shouldUseEventSub private var debugOutput = developerSettingsDataStore.current().eventSubDebugOutput - val events = eventSubClient.events - .filter { it !is SystemMessage || debugOutput } + val events = + eventSubClient.events + .filter { it !is SystemMessage || debugOutput } init { scope.launch { @@ -36,11 +40,27 @@ class EventSubManager( } userStateRepository.userState.map { it.moderationChannels }.collect { - val userId = preferenceStore.userIdString ?: return@collect + val userId = authDataStore.userIdString ?: return@collect val channels = channelRepository.getChannels(it) - channels.forEach { - val topic = EventSubTopic.ChannelModerate(channel = it.name, broadcasterId = it.id, moderatorId = userId) - eventSubClient.subscribe(topic) + channels.forEach { channel -> + eventSubClient.subscribe(EventSubTopic.ChannelModerate(channel = channel.name, broadcasterId = channel.id, moderatorId = userId)) + eventSubClient.subscribe(EventSubTopic.AutomodMessageHold(channel = channel.name, broadcasterId = channel.id, moderatorId = userId)) + eventSubClient.subscribe(EventSubTopic.AutomodMessageUpdate(channel = channel.name, broadcasterId = channel.id, moderatorId = userId)) + } + } + } + + scope.launch { + if (!isEnabled) { + return@launch + } + + chatChannelProvider.channels.filterNotNull().collect { channels -> + val userId = authDataStore.userIdString ?: return@collect + val resolved = channelRepository.getChannels(channels) + resolved.forEach { + eventSubClient.subscribe(EventSubTopic.UserMessageHold(channel = it.name, broadcasterId = it.id, userId = userId)) + eventSubClient.subscribe(EventSubTopic.UserMessageUpdate(channel = it.name, broadcasterId = it.id, userId = userId)) } } } @@ -58,21 +78,35 @@ class EventSubManager( fun connectedAndHasModerateTopic(channel: UserName): Boolean { val topics = eventSubClient.topics.value - return eventSubClient.connected && topics.isNotEmpty() && topics.any { - it.topic is EventSubTopic.ChannelModerate && it.topic.channel == channel - } + return eventSubClient.connected && topics.isNotEmpty() && + topics.any { + it.topic is EventSubTopic.ChannelModerate && it.topic.channel == channel + } } + val connectedAndHasUserMessageTopic: Boolean + get() { + val topics = eventSubClient.topics.value + return eventSubClient.connected && topics.any { it.topic is EventSubTopic.UserMessageHold } + } + fun removeChannel(channel: UserName) { if (!isEnabled) { return } scope.launch { - val topic = eventSubClient.topics.value - .find { it.topic is EventSubTopic.ChannelModerate && it.topic.channel == channel } - ?: return@launch - eventSubClient.unsubscribe(topic) + val topics = + eventSubClient.topics.value.filter { subscribedTopic -> + when (val topic = subscribedTopic.topic) { + is EventSubTopic.ChannelModerate -> topic.channel == channel + is EventSubTopic.AutomodMessageHold -> topic.channel == channel + is EventSubTopic.AutomodMessageUpdate -> topic.channel == channel + is EventSubTopic.UserMessageHold -> topic.channel == channel + is EventSubTopic.UserMessageUpdate -> topic.channel == channel + } + } + topics.forEach { eventSubClient.unsubscribe(it) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubMessage.kt index 1b9c34949..fb7d62fb3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubMessage.kt @@ -1,12 +1,18 @@ package com.flxrs.dankchat.data.api.eventapi import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodMessageHoldDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodMessageUpdateDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelChatUserMessageHoldDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelChatUserMessageUpdateDto import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateDto import kotlin.time.Instant sealed interface EventSubMessage -data class SystemMessage(val message: String) : EventSubMessage +data class SystemMessage( + val message: String, +) : EventSubMessage data class ModerationAction( val id: String, @@ -14,3 +20,31 @@ data class ModerationAction( val channelName: UserName, val data: ChannelModerateDto, ) : EventSubMessage + +data class AutomodHeld( + val id: String, + val timestamp: Instant, + val channelName: UserName, + val data: AutomodMessageHoldDto, +) : EventSubMessage + +data class AutomodUpdate( + val id: String, + val timestamp: Instant, + val channelName: UserName, + val data: AutomodMessageUpdateDto, +) : EventSubMessage + +data class UserMessageHeld( + val id: String, + val timestamp: Instant, + val channelName: UserName, + val data: ChannelChatUserMessageHoldDto, +) : EventSubMessage + +data class UserMessageUpdated( + val id: String, + val timestamp: Instant, + val channelName: UserName, + val data: ChannelChatUserMessageUpdateDto, +) : EventSubMessage diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubTopic.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubTopic.kt index 7d529c6a6..2478a9928 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubTopic.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/EventSubTopic.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.data.api.eventapi import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.eventapi.dto.EventSubBroadcasterUserConditionDto import com.flxrs.dankchat.data.api.eventapi.dto.EventSubMethod import com.flxrs.dankchat.data.api.eventapi.dto.EventSubModeratorConditionDto import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionRequestDto @@ -10,6 +11,7 @@ import com.flxrs.dankchat.data.api.eventapi.dto.EventSubTransportDto sealed interface EventSubTopic { fun createRequest(sessionId: String): EventSubSubscriptionRequestDto + fun shortFormatted(): String data class ChannelModerate( @@ -20,18 +22,115 @@ sealed interface EventSubTopic { override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( type = EventSubSubscriptionType.ChannelModerate, version = "2", - condition = EventSubModeratorConditionDto( - broadcasterUserId = broadcasterId, - moderatorUserId = moderatorId, - ), - transport = EventSubTransportDto( - sessionId = sessionId, - method = EventSubMethod.Websocket, - ), + condition = + EventSubModeratorConditionDto( + broadcasterUserId = broadcasterId, + moderatorUserId = moderatorId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), ) override fun shortFormatted(): String = "ChannelModerate($channel)" } + + data class AutomodMessageHold( + val channel: UserName, + val broadcasterId: UserId, + val moderatorId: UserId, + ) : EventSubTopic { + override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( + type = EventSubSubscriptionType.AutomodMessageHold, + version = "2", + condition = + EventSubModeratorConditionDto( + broadcasterUserId = broadcasterId, + moderatorUserId = moderatorId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), + ) + + override fun shortFormatted(): String = "AutomodMessageHold($channel)" + } + + data class AutomodMessageUpdate( + val channel: UserName, + val broadcasterId: UserId, + val moderatorId: UserId, + ) : EventSubTopic { + override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( + type = EventSubSubscriptionType.AutomodMessageUpdate, + version = "2", + condition = + EventSubModeratorConditionDto( + broadcasterUserId = broadcasterId, + moderatorUserId = moderatorId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), + ) + + override fun shortFormatted(): String = "AutomodMessageUpdate($channel)" + } + + data class UserMessageHold( + val channel: UserName, + val broadcasterId: UserId, + val userId: UserId, + ) : EventSubTopic { + override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( + type = EventSubSubscriptionType.ChannelChatUserMessageHold, + version = "1", + condition = + EventSubBroadcasterUserConditionDto( + broadcasterUserId = broadcasterId, + userId = userId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), + ) + + override fun shortFormatted(): String = "UserMessageHold($channel)" + } + + data class UserMessageUpdate( + val channel: UserName, + val broadcasterId: UserId, + val userId: UserId, + ) : EventSubTopic { + override fun createRequest(sessionId: String) = EventSubSubscriptionRequestDto( + type = EventSubSubscriptionType.ChannelChatUserMessageUpdate, + version = "1", + condition = + EventSubBroadcasterUserConditionDto( + broadcasterUserId = broadcasterId, + userId = userId, + ), + transport = + EventSubTransportDto( + sessionId = sessionId, + method = EventSubMethod.Websocket, + ), + ) + + override fun shortFormatted(): String = "UserMessageUpdate($channel)" + } } -data class SubscribedTopic(val id: String, val topic: EventSubTopic) +data class SubscribedTopic( + val id: String, + val topic: EventSubTopic, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubConditionDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubConditionDto.kt index 0da5ca401..7a942b849 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubConditionDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubConditionDto.kt @@ -20,3 +20,11 @@ data class EventSubModeratorConditionDto( @SerialName("moderator_user_id") val moderatorUserId: UserId, ) : EventSubConditionDto + +@Serializable +data class EventSubBroadcasterUserConditionDto( + @SerialName("broadcaster_user_id") + val broadcasterUserId: UserId, + @SerialName("user_id") + val userId: UserId, +) : EventSubConditionDto diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionResponseDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionResponseDto.kt index 7df7a41ec..d7aee5c5b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionResponseDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionResponseDto.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.data.api.eventapi.dto -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable data class EventSubSubscriptionResponseListDto( @@ -31,10 +31,13 @@ data class EventSubSubscriptionResponseDto( enum class EventSubSubscriptionStatus { @SerialName("enabled") Enabled, + @SerialName("authorization_revoked") AuthorizationRevoked, + @SerialName("user_removed") UserRemoved, + @SerialName("version_removed") VersionRemoved, Unknown, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionType.kt index 571694384..446998ad3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/EventSubSubscriptionType.kt @@ -7,5 +7,18 @@ import kotlinx.serialization.Serializable enum class EventSubSubscriptionType { @SerialName("channel.moderate") ChannelModerate, + + @SerialName("automod.message.hold") + AutomodMessageHold, + + @SerialName("automod.message.update") + AutomodMessageUpdate, + + @SerialName("channel.chat.user_message_hold") + ChannelChatUserMessageHold, + + @SerialName("channel.chat.user_message_update") + ChannelChatUserMessageUpdate, + Unknown, } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/EventSubMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/EventSubMessageDto.kt index 56183f566..53959637d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/EventSubMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/EventSubMessageDto.kt @@ -1,10 +1,10 @@ package com.flxrs.dankchat.data.api.eventapi.dto.messages import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionType -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator +import kotlin.time.Instant @Serializable @JsonClassDiscriminator("message_type") @@ -48,12 +48,16 @@ data class EventSubSubscriptionMetadataDto( enum class EventSubMessageType { @SerialName("session_welcome") Welcome, + @SerialName("session_keepalive") KeepAlive, + @SerialName("notification") Notification, + @SerialName("revocation") Revocation, + @SerialName("reconnect") Reconnect, Unknown, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/NotificationMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/NotificationMessageDto.kt index 7f4ded940..1a75e1ada 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/NotificationMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/NotificationMessageDto.kt @@ -3,15 +3,15 @@ package com.flxrs.dankchat.data.api.eventapi.dto.messages import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionStatus import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionType import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.NotificationEventDto -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable @SerialName("notification") data class NotificationMessageDto( override val metadata: EventSubSubscriptionMetadataDto, - override val payload: NotificationMessagePayload + override val payload: NotificationMessagePayload, ) : EventSubMessageDto @Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/WelcomeMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/WelcomeMessageDto.kt index 05ea08be1..ac9d60d94 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/WelcomeMessageDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/WelcomeMessageDto.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.data.api.eventapi.dto.messages -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable @SerialName("session_welcome") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt new file mode 100644 index 000000000..02ca9343a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/AutomodMessageDto.kt @@ -0,0 +1,183 @@ +package com.flxrs.dankchat.data.api.eventapi.dto.messages.notification + +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * EventSub automod.message.hold v2 payload. + * Fired when AutoMod catches a message for review. + */ +@Serializable +@SerialName("automod.message.hold") +data class AutomodMessageHoldDto( + @SerialName("broadcaster_user_id") + val broadcasterUserId: UserId, + @SerialName("broadcaster_user_login") + val broadcasterUserLogin: UserName, + @SerialName("broadcaster_user_name") + val broadcasterUserName: DisplayName, + @SerialName("user_id") + val userId: UserId, + @SerialName("user_login") + val userLogin: UserName, + @SerialName("user_name") + val userName: DisplayName, + @SerialName("message_id") + val messageId: String, + val message: AutomodHeldMessageDto, + @SerialName("held_at") + val heldAt: String, + // Discriminator string: "automod" or "blocked_term" + val reason: String, + // Present when reason == "automod" + val automod: AutomodReasonDto? = null, + // Present when reason == "blocked_term" + @SerialName("blocked_term") + val blockedTerm: BlockedTermReasonDto? = null, +) : NotificationEventDto + +/** + * EventSub automod.message.update v2 payload. + * Fired when a held message is approved, denied, or expires. + */ +@Serializable +@SerialName("automod.message.update") +data class AutomodMessageUpdateDto( + @SerialName("broadcaster_user_id") + val broadcasterUserId: UserId, + @SerialName("broadcaster_user_login") + val broadcasterUserLogin: UserName, + @SerialName("broadcaster_user_name") + val broadcasterUserName: DisplayName, + @SerialName("user_id") + val userId: UserId, + @SerialName("user_login") + val userLogin: UserName, + @SerialName("user_name") + val userName: DisplayName, + @SerialName("moderator_user_id") + val moderatorUserId: String? = null, + @SerialName("moderator_user_login") + val moderatorUserLogin: String? = null, + @SerialName("moderator_user_name") + val moderatorUserName: String? = null, + @SerialName("message_id") + val messageId: String, + val message: AutomodHeldMessageDto, + val status: AutomodMessageStatus, + @SerialName("held_at") + val heldAt: String, + val reason: String, + val automod: AutomodReasonDto? = null, + @SerialName("blocked_term") + val blockedTerm: BlockedTermReasonDto? = null, +) : NotificationEventDto + +/** + * EventSub channel.chat.user_message_hold v1 payload. + * Fired when the logged-in user's message is caught by AutoMod. + */ +@Serializable +@SerialName("channel.chat.user_message_hold") +data class ChannelChatUserMessageHoldDto( + @SerialName("broadcaster_user_id") + val broadcasterUserId: UserId, + @SerialName("broadcaster_user_login") + val broadcasterUserLogin: UserName, + @SerialName("broadcaster_user_name") + val broadcasterUserName: DisplayName, + @SerialName("user_id") + val userId: UserId, + @SerialName("user_login") + val userLogin: UserName, + @SerialName("user_name") + val userName: DisplayName, + @SerialName("message_id") + val messageId: String, + val message: AutomodHeldMessageDto, +) : NotificationEventDto + +/** + * EventSub channel.chat.user_message_update v1 payload. + * Fired when the logged-in user's held message is accepted or denied. + */ +@Serializable +@SerialName("channel.chat.user_message_update") +data class ChannelChatUserMessageUpdateDto( + @SerialName("broadcaster_user_id") + val broadcasterUserId: UserId, + @SerialName("broadcaster_user_login") + val broadcasterUserLogin: UserName, + @SerialName("broadcaster_user_name") + val broadcasterUserName: DisplayName, + @SerialName("user_id") + val userId: UserId, + @SerialName("user_login") + val userLogin: UserName, + @SerialName("user_name") + val userName: DisplayName, + val status: AutomodMessageStatus, + @SerialName("message_id") + val messageId: String, + val message: AutomodHeldMessageDto, +) : NotificationEventDto + +@Serializable +data class AutomodHeldMessageDto( + val text: String, + val fragments: List = emptyList(), +) + +@Serializable +data class AutomodMessageFragmentDto( + val text: String, + val type: String, // "text", "emote", "cheermote" +) + +@Serializable +data class AutomodReasonDto( + val category: String, + val level: Int, +) + +@Serializable +data class BlockedTermReasonDto( + @SerialName("terms_found") + val termsFound: List, +) + +@Serializable +data class BlockedTermFoundDto( + @SerialName("term_id") + val termId: String, + val boundary: AutomodBoundaryDto, + @SerialName("owner_broadcaster_user_id") + val ownerBroadcasterUserId: String? = null, + @SerialName("owner_broadcaster_user_login") + val ownerBroadcasterUserLogin: String? = null, + @SerialName("owner_broadcaster_user_name") + val ownerBroadcasterUserName: String? = null, +) + +@Serializable +data class AutomodBoundaryDto( + @SerialName("start_pos") + val startPos: Int, + @SerialName("end_pos") + val endPos: Int, +) + +@Serializable +enum class AutomodMessageStatus { + @SerialName("approved") + Approved, + + @SerialName("denied") + Denied, + + @SerialName("expired") + Expired, +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/ChannelModerateDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/ChannelModerateDto.kt index aa6f853ac..f0aab939b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/ChannelModerateDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/eventapi/dto/messages/notification/ChannelModerateDto.kt @@ -3,9 +3,9 @@ package com.flxrs.dankchat.data.api.eventapi.dto.messages.notification import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable @SerialName("channel.moderate") @@ -78,70 +78,103 @@ data class ChannelModerateDto( enum class ChannelModerateAction { @SerialName("ban") Ban, + @SerialName("timeout") Timeout, + @SerialName("unban") Unban, + @SerialName("untimeout") Untimeout, + @SerialName("clear") Clear, + @SerialName("emoteonly") EmoteOnly, + @SerialName("emoteonlyoff") EmoteOnlyOff, + @SerialName("followers") Followers, + @SerialName("followersoff") FollowersOff, + @SerialName("uniquechat") UniqueChat, + @SerialName("uniquechatoff") UniqueChatOff, + @SerialName("slow") Slow, + @SerialName("slowoff") SlowOff, + @SerialName("subscribers") Subscribers, + @SerialName("subscribersoff") SubscribersOff, + @SerialName("unraid") Unraid, + @SerialName("delete") Delete, + @SerialName("unvip") Unvip, + @SerialName("vip") Vip, + @SerialName("raid") Raid, + @SerialName("add_blocked_term") AddBlockedTerm, + @SerialName("add_permitted_term") AddPermittedTerm, + @SerialName("remove_blocked_term") RemoveBlockedTerm, + @SerialName("remove_permitted_term") RemovePermittedTerm, + @SerialName("mod") Mod, + @SerialName("unmod") Unmod, + @SerialName("approve_unban_request") ApproveUnbanRequest, + @SerialName("deny_unban_request") DenyUnbanRequest, + @SerialName("warn") Warn, + @SerialName("shared_chat_ban") SharedChatBan, + @SerialName("shared_chat_timeout") SharedChatTimeout, + @SerialName("shared_chat_unban") SharedChatUnban, + @SerialName("shared_chat_untimeout") SharedChatUntimeout, + @SerialName("shared_chat_delete") SharedChatDelete, Unknown, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApi.kt index addd35501..32ea92918 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApi.kt @@ -4,9 +4,10 @@ import com.flxrs.dankchat.data.UserId import io.ktor.client.HttpClient import io.ktor.client.request.get -class FFZApi(private val ktorClient: HttpClient) { - +class FFZApi( + private val ktorClient: HttpClient, +) { suspend fun getChannelEmotes(channelId: UserId) = ktorClient.get("room/id/$channelId") suspend fun getGlobalEmotes() = ktorClient.get("set/global") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApiClient.kt index 5dbe53ff2..59871582a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/FFZApiClient.kt @@ -10,16 +10,20 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class FFZApiClient(private val ffzApi: FFZApi, private val json: Json) { - +class FFZApiClient( + private val ffzApi: FFZApi, + private val json: Json, +) { suspend fun getFFZChannelEmotes(channelId: UserId): Result = runCatching { - ffzApi.getChannelEmotes(channelId) + ffzApi + .getChannelEmotes(channelId) .throwApiErrorOnFailure(json) .body() }.recoverNotFoundWith(null) suspend fun getFFZGlobalEmotes(): Result = runCatching { - ffzApi.getGlobalEmotes() + ffzApi + .getGlobalEmotes() .throwApiErrorOnFailure(json) .body() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZChannelDto.kt index 942078a4e..00500d542 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZChannelDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZChannelDto.kt @@ -8,5 +8,5 @@ import kotlinx.serialization.Serializable @Serializable data class FFZChannelDto( @SerialName(value = "room") val room: FFZRoomDto, - @SerialName(value = "sets") val sets: Map -) \ No newline at end of file + @SerialName(value = "sets") val sets: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteOwnerDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteOwnerDto.kt index 9b1ec13ac..eaa7d9091 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteOwnerDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteOwnerDto.kt @@ -7,4 +7,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZEmoteOwnerDto(@SerialName(value = "display_name") val displayName: DisplayName?) +data class FFZEmoteOwnerDto( + @SerialName(value = "display_name") val displayName: DisplayName?, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteSetDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteSetDto.kt index 58867a08c..c5c19bbf5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteSetDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZEmoteSetDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class FFZEmoteSetDto(@SerialName(value = "emoticons") val emotes: List) \ No newline at end of file +data class FFZEmoteSetDto( + @SerialName(value = "emoticons") val emotes: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZGlobalDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZGlobalDto.kt index d4f50c629..3c7a635ee 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZGlobalDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZGlobalDto.kt @@ -8,5 +8,5 @@ import kotlinx.serialization.Serializable @Serializable data class FFZGlobalDto( @SerialName(value = "default_sets") val defaultSets: List, - @SerialName(value = "sets") val sets: Map -) \ No newline at end of file + @SerialName(value = "sets") val sets: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZRoomDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZRoomDto.kt index 60268acbd..00f50da47 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZRoomDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/ffz/dto/FFZRoomDto.kt @@ -9,4 +9,4 @@ import kotlinx.serialization.Serializable data class FFZRoomDto( @SerialName(value = "mod_urls") val modBadgeUrls: Map?, @SerialName(value = "vip_badge") val vipBadgeUrls: Map?, -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt index 7ad43318e..b203b71f2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApi.kt @@ -7,10 +7,13 @@ import com.flxrs.dankchat.data.api.helix.dto.AnnouncementRequestDto import com.flxrs.dankchat.data.api.helix.dto.BanRequestDto import com.flxrs.dankchat.data.api.helix.dto.ChatSettingsRequestDto import com.flxrs.dankchat.data.api.helix.dto.CommercialRequestDto +import com.flxrs.dankchat.data.api.helix.dto.ManageAutomodMessageRequestDto import com.flxrs.dankchat.data.api.helix.dto.MarkerRequestDto +import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageRequestDto import com.flxrs.dankchat.data.api.helix.dto.ShieldModeRequestDto import com.flxrs.dankchat.data.api.helix.dto.WhisperRequestDto -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix import io.ktor.client.HttpClient import io.ktor.client.request.bearerAuth @@ -25,10 +28,18 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType -class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenceStore: DankChatPreferenceStore) { +class HelixApi( + private val ktorClient: HttpClient, + private val authDataStore: AuthDataStore, + private val startupValidationHolder: StartupValidationHolder, +) { + private fun getValidToken(): String? { + if (!startupValidationHolder.isAuthAvailable) return null + return authDataStore.oAuthKey?.withoutOAuthPrefix + } suspend fun getUsersByName(logins: List): HttpResponse? = ktorClient.get("users") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) logins.forEach { parameter("login", it) @@ -36,15 +47,20 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getUsersByIds(ids: List): HttpResponse? = ktorClient.get("users") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) ids.forEach { parameter("id", it) } } - suspend fun getChannelFollowers(broadcasterUserId: UserId, targetUserId: UserId? = null, first: Int? = null, after: String? = null): HttpResponse? = ktorClient.get("channels/followers") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getChannelFollowers( + broadcasterUserId: UserId, + targetUserId: UserId? = null, + first: Int? = null, + after: String? = null, + ): HttpResponse? = ktorClient.get("channels/followers") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) if (targetUserId != null) { @@ -59,15 +75,19 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getStreams(channels: List): HttpResponse? = ktorClient.get("streams") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) channels.forEach { parameter("user_login", it) } } - suspend fun getUserBlocks(userId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("users/blocks") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getUserBlocks( + userId: UserId, + first: Int, + after: String? = null, + ): HttpResponse? = ktorClient.get("users/blocks") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", userId) parameter("first", first) @@ -77,19 +97,23 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun putUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.put("users/blocks") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("target_user_id", targetUserId) } suspend fun deleteUserBlock(targetUserId: UserId): HttpResponse? = ktorClient.delete("users/blocks") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("target_user_id", targetUserId) } - suspend fun postAnnouncement(broadcasterUserId: UserId, moderatorUserId: UserId, request: AnnouncementRequestDto): HttpResponse? = ktorClient.post("chat/announcements") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postAnnouncement( + broadcasterUserId: UserId, + moderatorUserId: UserId, + request: AnnouncementRequestDto, + ): HttpResponse? = ktorClient.post("chat/announcements") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -97,8 +121,12 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc setBody(request) } - suspend fun getModerators(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("moderation/moderators") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getModerators( + broadcasterUserId: UserId, + first: Int, + after: String? = null, + ): HttpResponse? = ktorClient.get("moderation/moderators") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -107,22 +135,32 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } } - suspend fun postModerator(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.post("moderation/moderators") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postModerator( + broadcasterUserId: UserId, + userId: UserId, + ): HttpResponse? = ktorClient.post("moderation/moderators") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("user_id", userId) } - suspend fun deleteModerator(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.delete("moderation/moderators") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun deleteModerator( + broadcasterUserId: UserId, + userId: UserId, + ): HttpResponse? = ktorClient.delete("moderation/moderators") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("user_id", userId) } - suspend fun postWhisper(fromUserId: UserId, toUserId: UserId, request: WhisperRequestDto): HttpResponse? = ktorClient.post("whispers") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postWhisper( + fromUserId: UserId, + toUserId: UserId, + request: WhisperRequestDto, + ): HttpResponse? = ktorClient.post("whispers") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("from_user_id", fromUserId) parameter("to_user_id", toUserId) @@ -130,8 +168,12 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc setBody(request) } - suspend fun getVips(broadcasterUserId: UserId, first: Int, after: String? = null): HttpResponse? = ktorClient.get("channels/vips") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getVips( + broadcasterUserId: UserId, + first: Int, + after: String? = null, + ): HttpResponse? = ktorClient.get("channels/vips") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("first", first) @@ -140,22 +182,32 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } } - suspend fun postVip(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.post("channels/vips") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postVip( + broadcasterUserId: UserId, + userId: UserId, + ): HttpResponse? = ktorClient.post("channels/vips") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("user_id", userId) } - suspend fun deleteVip(broadcasterUserId: UserId, userId: UserId): HttpResponse? = ktorClient.delete("channels/vips") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun deleteVip( + broadcasterUserId: UserId, + userId: UserId, + ): HttpResponse? = ktorClient.delete("channels/vips") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("user_id", userId) } - suspend fun postBan(broadcasterUserId: UserId, moderatorUserId: UserId, request: BanRequestDto): HttpResponse? = ktorClient.post("moderation/bans") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postBan( + broadcasterUserId: UserId, + moderatorUserId: UserId, + request: BanRequestDto, + ): HttpResponse? = ktorClient.post("moderation/bans") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -163,16 +215,24 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc setBody(request) } - suspend fun deleteBan(broadcasterUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): HttpResponse? = ktorClient.delete("moderation/bans") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun deleteBan( + broadcasterUserId: UserId, + moderatorUserId: UserId, + targetUserId: UserId, + ): HttpResponse? = ktorClient.delete("moderation/bans") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) parameter("user_id", targetUserId) } - suspend fun deleteMessages(broadcasterUserId: UserId, moderatorUserId: UserId, messageId: String?): HttpResponse? = ktorClient.delete("moderation/chat") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun deleteMessages( + broadcasterUserId: UserId, + moderatorUserId: UserId, + messageId: String?, + ): HttpResponse? = ktorClient.delete("moderation/chat") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -181,42 +241,52 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } } - suspend fun putUserChatColor(targetUserId: UserId, color: String): HttpResponse? = ktorClient.put("chat/color") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun putUserChatColor( + targetUserId: UserId, + color: String, + ): HttpResponse? = ktorClient.put("chat/color") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("user_id", targetUserId) parameter("color", color) } suspend fun postMarker(request: MarkerRequestDto): HttpResponse? = ktorClient.post("streams/markers") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) contentType(ContentType.Application.Json) setBody(request) } suspend fun postCommercial(request: CommercialRequestDto): HttpResponse? = ktorClient.post("channels/commercial") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) contentType(ContentType.Application.Json) setBody(request) } - suspend fun postRaid(broadcasterUserId: UserId, targetUserId: UserId): HttpResponse? = ktorClient.post("raids") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun postRaid( + broadcasterUserId: UserId, + targetUserId: UserId, + ): HttpResponse? = ktorClient.post("raids") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("from_broadcaster_id", broadcasterUserId) parameter("to_broadcaster_id", targetUserId) } suspend fun deleteRaid(broadcasterUserId: UserId): HttpResponse? = ktorClient.delete("raids") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) } - suspend fun patchChatSettings(broadcasterUserId: UserId, moderatorUserId: UserId, request: ChatSettingsRequestDto): HttpResponse? = ktorClient.patch("chat/settings") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun patchChatSettings( + broadcasterUserId: UserId, + moderatorUserId: UserId, + request: ChatSettingsRequestDto, + ): HttpResponse? = ktorClient.patch("chat/settings") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -225,20 +295,38 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun getGlobalBadges(): HttpResponse? = ktorClient.get("chat/badges/global") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) contentType(ContentType.Application.Json) } suspend fun getChannelBadges(broadcasterUserId: UserId): HttpResponse? = ktorClient.get("chat/badges") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) contentType(ContentType.Application.Json) } - suspend fun postShoutout(broadcasterUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): HttpResponse? = ktorClient.post("chat/shoutouts") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getCheermotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("bits/cheermotes") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterId) + contentType(ContentType.Application.Json) + } + + suspend fun postManageAutomodMessage(request: ManageAutomodMessageRequestDto): HttpResponse? = ktorClient.post("moderation/automod/message") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + contentType(ContentType.Application.Json) + setBody(request) + } + + suspend fun postShoutout( + broadcasterUserId: UserId, + targetUserId: UserId, + moderatorUserId: UserId, + ): HttpResponse? = ktorClient.post("chat/shoutouts") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("from_broadcaster_id", broadcasterUserId) parameter("to_broadcaster_id", targetUserId) @@ -246,8 +334,22 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc contentType(ContentType.Application.Json) } - suspend fun putShieldMode(broadcasterUserId: UserId, moderatorUserId: UserId, request: ShieldModeRequestDto): HttpResponse? = ktorClient.put("moderation/shield_mode") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + suspend fun getShieldMode( + broadcasterUserId: UserId, + moderatorUserId: UserId, + ): HttpResponse? = ktorClient.get("moderation/shield_mode") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterUserId) + parameter("moderator_id", moderatorUserId) + } + + suspend fun putShieldMode( + broadcasterUserId: UserId, + moderatorUserId: UserId, + request: ShieldModeRequestDto, + ): HttpResponse? = ktorClient.put("moderation/shield_mode") { + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("broadcaster_id", broadcasterUserId) parameter("moderator_id", moderatorUserId) @@ -256,15 +358,40 @@ class HelixApi(private val ktorClient: HttpClient, private val dankChatPreferenc } suspend fun postEventSubSubscription(eventSubSubscriptionRequestDto: EventSubSubscriptionRequestDto): HttpResponse? = ktorClient.post("eventsub/subscriptions") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) contentType(ContentType.Application.Json) setBody(eventSubSubscriptionRequestDto) } suspend fun deleteEventSubSubscription(id: String): HttpResponse? = ktorClient.delete("eventsub/subscriptions") { - val oAuth = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix ?: return null + val oAuth = getValidToken() ?: return null bearerAuth(oAuth) parameter("id", id) } + + suspend fun getUserEmotes( + userId: UserId, + after: String? = null, + ): HttpResponse? = ktorClient.get("chat/emotes/user") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("user_id", userId) + if (after != null) { + parameter("after", after) + } + } + + suspend fun getChannelEmotes(broadcasterId: UserId): HttpResponse? = ktorClient.get("chat/emotes") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + parameter("broadcaster_id", broadcasterId) + } + + suspend fun postChatMessage(request: SendChatMessageRequestDto): HttpResponse? = ktorClient.post("chat/messages") { + val oAuth = getValidToken() ?: return null + bearerAuth(oAuth) + contentType(ContentType.Application.Json) + setBody(request) + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiClient.kt index fac284c4d..6217b1b6c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiClient.kt @@ -7,22 +7,28 @@ import com.flxrs.dankchat.data.api.eventapi.dto.EventSubSubscriptionResponseList import com.flxrs.dankchat.data.api.helix.dto.AnnouncementRequestDto import com.flxrs.dankchat.data.api.helix.dto.BadgeSetDto import com.flxrs.dankchat.data.api.helix.dto.BanRequestDto +import com.flxrs.dankchat.data.api.helix.dto.ChannelEmoteDto import com.flxrs.dankchat.data.api.helix.dto.ChatSettingsDto import com.flxrs.dankchat.data.api.helix.dto.ChatSettingsRequestDto +import com.flxrs.dankchat.data.api.helix.dto.CheermoteSetDto import com.flxrs.dankchat.data.api.helix.dto.CommercialDto import com.flxrs.dankchat.data.api.helix.dto.CommercialRequestDto import com.flxrs.dankchat.data.api.helix.dto.DataListDto import com.flxrs.dankchat.data.api.helix.dto.HelixErrorDto +import com.flxrs.dankchat.data.api.helix.dto.ManageAutomodMessageRequestDto import com.flxrs.dankchat.data.api.helix.dto.MarkerDto import com.flxrs.dankchat.data.api.helix.dto.MarkerRequestDto import com.flxrs.dankchat.data.api.helix.dto.ModVipDto import com.flxrs.dankchat.data.api.helix.dto.PagedDto import com.flxrs.dankchat.data.api.helix.dto.RaidDto +import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageRequestDto +import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageResponseDto import com.flxrs.dankchat.data.api.helix.dto.ShieldModeRequestDto import com.flxrs.dankchat.data.api.helix.dto.ShieldModeStatusDto import com.flxrs.dankchat.data.api.helix.dto.StreamDto import com.flxrs.dankchat.data.api.helix.dto.UserBlockDto import com.flxrs.dankchat.data.api.helix.dto.UserDto +import com.flxrs.dankchat.data.api.helix.dto.UserEmoteDto import com.flxrs.dankchat.data.api.helix.dto.UserFollowsDto import com.flxrs.dankchat.data.api.helix.dto.WhisperRequestDto import com.flxrs.dankchat.utils.extensions.decodeOrNull @@ -32,15 +38,20 @@ import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.request import io.ktor.http.HttpStatusCode import io.ktor.http.isSuccess +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { - +class HelixApiClient( + private val helixApi: HelixApi, + private val json: Json, +) { suspend fun getUsersByNames(names: List): Result> = runCatching { names.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi.getUsersByName(it) + helixApi + .getUsersByName(it) .throwHelixApiErrorOnFailure() .body>() .data @@ -49,7 +60,8 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { suspend fun getUsersByIds(ids: List): Result> = runCatching { ids.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi.getUsersByIds(it) + helixApi + .getUsersByIds(it) .throwHelixApiErrorOnFailure() .body>() .data @@ -65,109 +77,163 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { suspend fun getUserIdByName(name: UserName): Result = getUserByName(name) .mapCatching { it.id } - suspend fun getChannelFollowers(broadcastUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.getChannelFollowers(broadcastUserId, targetUserId) + suspend fun getChannelFollowers( + broadcastUserId: UserId, + targetUserId: UserId, + ): Result = runCatching { + helixApi + .getChannelFollowers(broadcastUserId, targetUserId) .throwHelixApiErrorOnFailure() .body() } suspend fun getStreams(channels: List): Result> = runCatching { channels.chunked(DEFAULT_PAGE_SIZE).flatMap { - helixApi.getStreams(it) + helixApi + .getStreams(it) .throwHelixApiErrorOnFailure() .body>() .data } } - suspend fun getUserBlocks(userId: UserId, maxUserBlocksToFetch: Int = 500): Result> = runCatching { + suspend fun getUserBlocks( + userId: UserId, + maxUserBlocksToFetch: Int = 500, + ): Result> = runCatching { pageUntil(maxUserBlocksToFetch) { cursor -> helixApi.getUserBlocks(userId, DEFAULT_PAGE_SIZE, cursor) } } suspend fun blockUser(targetUserId: UserId): Result = runCatching { - helixApi.putUserBlock(targetUserId) + helixApi + .putUserBlock(targetUserId) .throwHelixApiErrorOnFailure() } suspend fun unblockUser(targetUserId: UserId): Result = runCatching { - helixApi.deleteUserBlock(targetUserId) + helixApi + .deleteUserBlock(targetUserId) .throwHelixApiErrorOnFailure() } suspend fun postAnnouncement( broadcastUserId: UserId, moderatorUserId: UserId, - request: AnnouncementRequestDto + request: AnnouncementRequestDto, ): Result = runCatching { - helixApi.postAnnouncement(broadcastUserId, moderatorUserId, request) + helixApi + .postAnnouncement(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() } suspend fun postWhisper( fromUserId: UserId, toUserId: UserId, - request: WhisperRequestDto + request: WhisperRequestDto, ): Result = runCatching { - helixApi.postWhisper(fromUserId, toUserId, request) + helixApi + .postWhisper(fromUserId, toUserId, request) .throwHelixApiErrorOnFailure() } - suspend fun getModerators(broadcastUserId: UserId, maxModeratorsToFetch: Int = 500): Result> = runCatching { + suspend fun getModerators( + broadcastUserId: UserId, + maxModeratorsToFetch: Int = 500, + ): Result> = runCatching { pageUntil(maxModeratorsToFetch) { cursor -> helixApi.getModerators(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) } } - suspend fun postModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.postModerator(broadcastUserId, userId) + suspend fun postModerator( + broadcastUserId: UserId, + userId: UserId, + ): Result = runCatching { + helixApi + .postModerator(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } - suspend fun deleteModerator(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.deleteModerator(broadcastUserId, userId) + suspend fun deleteModerator( + broadcastUserId: UserId, + userId: UserId, + ): Result = runCatching { + helixApi + .deleteModerator(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } - suspend fun getVips(broadcastUserId: UserId, maxVipsToFetch: Int = 500): Result> = runCatching { + suspend fun getVips( + broadcastUserId: UserId, + maxVipsToFetch: Int = 500, + ): Result> = runCatching { pageUntil(maxVipsToFetch) { cursor -> helixApi.getVips(broadcastUserId, DEFAULT_PAGE_SIZE, cursor) } } - suspend fun postVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.postVip(broadcastUserId, userId) + suspend fun postVip( + broadcastUserId: UserId, + userId: UserId, + ): Result = runCatching { + helixApi + .postVip(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } - suspend fun deleteVip(broadcastUserId: UserId, userId: UserId): Result = runCatching { - helixApi.deleteVip(broadcastUserId, userId) + suspend fun deleteVip( + broadcastUserId: UserId, + userId: UserId, + ): Result = runCatching { + helixApi + .deleteVip(broadcastUserId, userId) .throwHelixApiErrorOnFailure() } - suspend fun postBan(broadcastUserId: UserId, moderatorUserId: UserId, requestDto: BanRequestDto): Result = runCatching { - helixApi.postBan(broadcastUserId, moderatorUserId, requestDto) + suspend fun postBan( + broadcastUserId: UserId, + moderatorUserId: UserId, + requestDto: BanRequestDto, + ): Result = runCatching { + helixApi + .postBan(broadcastUserId, moderatorUserId, requestDto) .throwHelixApiErrorOnFailure() } - suspend fun deleteBan(broadcastUserId: UserId, moderatorUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.deleteBan(broadcastUserId, moderatorUserId, targetUserId) + suspend fun deleteBan( + broadcastUserId: UserId, + moderatorUserId: UserId, + targetUserId: UserId, + ): Result = runCatching { + helixApi + .deleteBan(broadcastUserId, moderatorUserId, targetUserId) .throwHelixApiErrorOnFailure() } - suspend fun deleteMessages(broadcastUserId: UserId, moderatorUserId: UserId, messageId: String? = null): Result = runCatching { - helixApi.deleteMessages(broadcastUserId, moderatorUserId, messageId) + suspend fun deleteMessages( + broadcastUserId: UserId, + moderatorUserId: UserId, + messageId: String? = null, + ): Result = runCatching { + helixApi + .deleteMessages(broadcastUserId, moderatorUserId, messageId) .throwHelixApiErrorOnFailure() } - suspend fun putUserChatColor(targetUserId: UserId, color: String): Result = runCatching { - helixApi.putUserChatColor(targetUserId, color) + suspend fun putUserChatColor( + targetUserId: UserId, + color: String, + ): Result = runCatching { + helixApi + .putUserChatColor(targetUserId, color) .throwHelixApiErrorOnFailure() } suspend fun postMarker(requestDto: MarkerRequestDto): Result = runCatching { - helixApi.postMarker(requestDto) + helixApi + .postMarker(requestDto) .throwHelixApiErrorOnFailure() .body>() .data @@ -175,15 +241,20 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun postCommercial(request: CommercialRequestDto): Result = runCatching { - helixApi.postCommercial(request) + helixApi + .postCommercial(request) .throwHelixApiErrorOnFailure() .body>() .data .first() } - suspend fun postRaid(broadcastUserId: UserId, targetUserId: UserId): Result = runCatching { - helixApi.postRaid(broadcastUserId, targetUserId) + suspend fun postRaid( + broadcastUserId: UserId, + targetUserId: UserId, + ): Result = runCatching { + helixApi + .postRaid(broadcastUserId, targetUserId) .throwHelixApiErrorOnFailure() .body>() .data @@ -191,12 +262,18 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun deleteRaid(broadcastUserId: UserId): Result = runCatching { - helixApi.deleteRaid(broadcastUserId) + helixApi + .deleteRaid(broadcastUserId) .throwHelixApiErrorOnFailure() } - suspend fun patchChatSettings(broadcastUserId: UserId, moderatorUserId: UserId, request: ChatSettingsRequestDto): Result = runCatching { - helixApi.patchChatSettings(broadcastUserId, moderatorUserId, request) + suspend fun patchChatSettings( + broadcastUserId: UserId, + moderatorUserId: UserId, + request: ChatSettingsRequestDto, + ): Result = runCatching { + helixApi + .patchChatSettings(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() .body>() .data @@ -204,26 +281,68 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun getGlobalBadges(): Result> = runCatching { - helixApi.getGlobalBadges() + helixApi + .getGlobalBadges() .throwHelixApiErrorOnFailure() .body>() .data } suspend fun getChannelBadges(broadcastUserId: UserId): Result> = runCatching { - helixApi.getChannelBadges(broadcastUserId) + helixApi + .getChannelBadges(broadcastUserId) .throwHelixApiErrorOnFailure() .body>() .data } - suspend fun postShoutout(broadcastUserId: UserId, targetUserId: UserId, moderatorUserId: UserId): Result = runCatching { - helixApi.postShoutout(broadcastUserId, targetUserId, moderatorUserId) + suspend fun getCheermotes(broadcasterId: UserId): Result> = runCatching { + helixApi + .getCheermotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } + + suspend fun manageAutomodMessage( + userId: UserId, + msgId: String, + action: String, + ): Result = runCatching { + helixApi + .postManageAutomodMessage(ManageAutomodMessageRequestDto(userId = userId, msgId = msgId, action = action)) .throwHelixApiErrorOnFailure() } - suspend fun putShieldMode(broadcastUserId: UserId, moderatorUserId: UserId, request: ShieldModeRequestDto): Result = runCatching { - helixApi.putShieldMode(broadcastUserId, moderatorUserId, request) + suspend fun postShoutout( + broadcastUserId: UserId, + targetUserId: UserId, + moderatorUserId: UserId, + ): Result = runCatching { + helixApi + .postShoutout(broadcastUserId, targetUserId, moderatorUserId) + .throwHelixApiErrorOnFailure() + } + + suspend fun getShieldMode( + broadcastUserId: UserId, + moderatorUserId: UserId, + ): Result = runCatching { + helixApi + .getShieldMode(broadcastUserId, moderatorUserId) + .throwHelixApiErrorOnFailure() + .body>() + .data + .first() + } + + suspend fun putShieldMode( + broadcastUserId: UserId, + moderatorUserId: UserId, + request: ShieldModeRequestDto, + ): Result = runCatching { + helixApi + .putShieldMode(broadcastUserId, moderatorUserId, request) .throwHelixApiErrorOnFailure() .body>() .data @@ -231,29 +350,79 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { } suspend fun postEventSubSubscription(request: EventSubSubscriptionRequestDto): Result = runCatching { - helixApi.postEventSubSubscription(request) + helixApi + .postEventSubSubscription(request) .throwHelixApiErrorOnFailure() .body() } suspend fun deleteEventSubSubscription(id: String): Result = runCatching { - helixApi.deleteEventSubSubscription(id) + helixApi + .deleteEventSubSubscription(id) .throwHelixApiErrorOnFailure() } - private suspend inline fun pageUntil(amountToFetch: Int, request: (cursor: String?) -> HttpResponse?): List { - val initialPage = request(null) + fun getUserEmotesFlow(userId: UserId): Flow> = pageAsFlow(MAX_USER_EMOTES) { cursor -> + helixApi.getUserEmotes(userId, cursor) + } + + suspend fun getChannelEmotes(broadcasterId: UserId): Result> = runCatching { + helixApi + .getChannelEmotes(broadcasterId) + .throwHelixApiErrorOnFailure() + .body>() + .data + } + + suspend fun postChatMessage(request: SendChatMessageRequestDto): Result = runCatching { + helixApi + .postChatMessage(request) .throwHelixApiErrorOnFailure() - .body>() + .body>() + .data + .first() + } + private inline fun pageAsFlow( + amountToFetch: Int, + crossinline request: suspend (cursor: String?) -> HttpResponse?, + ): Flow> = flow { + val initialPage = + request(null) + .throwHelixApiErrorOnFailure() + .body>() + emit(initialPage.data) var cursor = initialPage.pagination.cursor - val entries = initialPage.data.toMutableList() + var count = initialPage.data.size + while (cursor != null && count < amountToFetch) { + val result = + request(cursor) + .throwHelixApiErrorOnFailure() + .body>() + emit(result.data) + count += result.data.size + cursor = result.pagination.cursor + } + } - while (cursor != null && entries.size < amountToFetch) { - val result = request(cursor) + private suspend inline fun pageUntil( + amountToFetch: Int, + request: (cursor: String?) -> HttpResponse?, + ): List { + val initialPage = + request(null) .throwHelixApiErrorOnFailure() .body>() + var cursor = initialPage.pagination.cursor + val entries = initialPage.data.toMutableList() + + while (cursor != null && entries.size < amountToFetch) { + val result = + request(cursor) + .throwHelixApiErrorOnFailure() + .body>() + entries.addAll(result.data) cursor = result.pagination.cursor } @@ -261,6 +430,7 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { return entries } + @Suppress("ThrowsCount") private suspend fun HttpResponse?.throwHelixApiErrorOnFailure(): HttpResponse { this ?: throw HelixApiException(HelixError.NotLoggedIn, HttpStatusCode.Unauthorized, url = null) if (status.isSuccess()) { @@ -270,77 +440,137 @@ class HelixApiClient(private val helixApi: HelixApi, private val json: Json) { val errorBody = json.decodeOrNull(bodyAsText()) ?: throw HelixApiException(HelixError.Unknown, status, request.url, status.description) val message = errorBody.message val betterStatus = HttpStatusCode.fromValue(status.value) - val error = when (betterStatus) { - HttpStatusCode.BadRequest -> when { - message.startsWith(WHISPER_SELF_ERROR, ignoreCase = true) -> HelixError.WhisperSelf - message.startsWith(USER_ALREADY_MOD_ERROR, ignoreCase = true) -> HelixError.TargetAlreadyModded - message.startsWith(USER_NOT_MOD_ERROR, ignoreCase = true) -> HelixError.TargetNotModded - message.startsWith(USER_ALREADY_BANNED_ERROR, ignoreCase = true) -> HelixError.TargetAlreadyBanned - message.startsWith(USER_MAY_NOT_BE_BANNED_ERROR, ignoreCase = true) -> HelixError.TargetCannotBeBanned - message.startsWith(USER_NOT_BANNED_ERROR, ignoreCase = true) -> HelixError.TargetNotBanned - message.startsWith(INVALID_COLOR_ERROR, ignoreCase = true) -> HelixError.InvalidColor - message.startsWith(BROADCASTER_NOT_LIVE_ERROR, ignoreCase = true) -> HelixError.CommercialNotStreaming - message.startsWith(MISSING_REQUIRED_PARAM_ERROR, ignoreCase = true) -> HelixError.MissingLengthParameter - message.startsWith(RAID_SELF_ERROR, ignoreCase = true) -> HelixError.RaidSelf - message.startsWith(SHOUTOUT_SELF_ERROR, ignoreCase = true) -> HelixError.ShoutoutSelf - message.startsWith(SHOUTOUT_NOT_LIVE_ERROR, ignoreCase = true) -> HelixError.ShoutoutTargetNotStreaming - message.contains(NOT_IN_RANGE_ERROR, ignoreCase = true) -> { - val match = INVALID_RANGE_REGEX.find(message)?.groupValues - val start = match?.getOrNull(1)?.toIntOrNull() - val end = match?.getOrNull(2)?.toIntOrNull() + val error = + when (betterStatus) { + HttpStatusCode.BadRequest -> { when { - start != null && end != null -> HelixError.NotInRange(validRange = start..end) - else -> HelixError.NotInRange(validRange = null) + message.startsWith(WHISPER_SELF_ERROR, ignoreCase = true) -> { + HelixError.WhisperSelf + } + + message.startsWith(USER_ALREADY_MOD_ERROR, ignoreCase = true) -> { + HelixError.TargetAlreadyModded + } + + message.startsWith(USER_NOT_MOD_ERROR, ignoreCase = true) -> { + HelixError.TargetNotModded + } + + message.startsWith(USER_ALREADY_BANNED_ERROR, ignoreCase = true) -> { + HelixError.TargetAlreadyBanned + } + + message.startsWith(USER_MAY_NOT_BE_BANNED_ERROR, ignoreCase = true) -> { + HelixError.TargetCannotBeBanned + } + + message.startsWith(USER_NOT_BANNED_ERROR, ignoreCase = true) -> { + HelixError.TargetNotBanned + } + + message.startsWith(INVALID_COLOR_ERROR, ignoreCase = true) -> { + HelixError.InvalidColor + } + + message.startsWith(BROADCASTER_NOT_LIVE_ERROR, ignoreCase = true) -> { + HelixError.CommercialNotStreaming + } + + message.startsWith(MISSING_REQUIRED_PARAM_ERROR, ignoreCase = true) -> { + HelixError.MissingLengthParameter + } + + message.startsWith(RAID_SELF_ERROR, ignoreCase = true) -> { + HelixError.RaidSelf + } + + message.startsWith(SHOUTOUT_SELF_ERROR, ignoreCase = true) -> { + HelixError.ShoutoutSelf + } + + message.startsWith(SHOUTOUT_NOT_LIVE_ERROR, ignoreCase = true) -> { + HelixError.ShoutoutTargetNotStreaming + } + + message.contains(NOT_IN_RANGE_ERROR, ignoreCase = true) -> { + val match = INVALID_RANGE_REGEX.find(message)?.groupValues + val start = match?.getOrNull(1)?.toIntOrNull() + val end = match?.getOrNull(2)?.toIntOrNull() + when { + start != null && end != null -> HelixError.NotInRange(validRange = start..end) + else -> HelixError.NotInRange(validRange = null) + } + } + + else -> { + HelixError.Forwarded + } } } - else -> HelixError.Forwarded - } + HttpStatusCode.Forbidden -> { + when { + message.startsWith(RECIPIENT_BLOCKED_USER_ERROR, ignoreCase = true) -> HelixError.RecipientBlockedUser + else -> HelixError.UserNotAuthorized + } + } - HttpStatusCode.Forbidden -> when { - message.startsWith(RECIPIENT_BLOCKED_USER_ERROR, ignoreCase = true) -> HelixError.RecipientBlockedUser - else -> HelixError.UserNotAuthorized - } + HttpStatusCode.Unauthorized -> { + when { + message.startsWith(MISSING_SCOPE_ERROR, ignoreCase = true) -> HelixError.MissingScopes + message.startsWith(NO_VERIFIED_PHONE_ERROR, ignoreCase = true) -> HelixError.NoVerifiedPhone + message.startsWith(BROADCASTER_OAUTH_TOKEN_ERROR, ignoreCase = true) -> HelixError.BroadcasterTokenRequired + message.startsWith(USER_AUTH_ERROR, ignoreCase = true) -> HelixError.UserNotAuthorized + else -> HelixError.Forwarded + } + } - HttpStatusCode.Unauthorized -> when { - message.startsWith(MISSING_SCOPE_ERROR, ignoreCase = true) -> HelixError.MissingScopes - message.startsWith(NO_VERIFIED_PHONE_ERROR, ignoreCase = true) -> HelixError.NoVerifiedPhone - message.startsWith(BROADCASTER_OAUTH_TOKEN_ERROR, ignoreCase = true) -> HelixError.BroadcasterTokenRequired - message.startsWith(USER_AUTH_ERROR, ignoreCase = true) -> HelixError.UserNotAuthorized - else -> HelixError.Forwarded - } + HttpStatusCode.NotFound -> { + when (request.url.encodedPath) { + "/helix/streams/markers" -> HelixError.MarkerError(message.substringAfter("message:\"", "").substringBeforeLast('"').ifBlank { null }) + "helix/raids" -> HelixError.NoRaidPending + else -> HelixError.Forwarded + } + } - HttpStatusCode.NotFound -> when (request.url.encodedPath) { - "/helix/streams/markers" -> HelixError.MarkerError(message.substringAfter("message:\"", "").substringBeforeLast('"').ifBlank { null }) - "helix/raids" -> HelixError.NoRaidPending - else -> HelixError.Forwarded - } + HttpStatusCode.UnprocessableEntity -> { + when (request.url.encodedPath) { + "/helix/moderation/moderators" -> HelixError.TargetIsVip + "/helix/chat/messages" -> HelixError.MessageTooLarge + else -> HelixError.Forwarded + } + } - HttpStatusCode.UnprocessableEntity -> when (request.url.encodedPath) { - "/helix/moderation/moderators" -> HelixError.TargetIsVip - else -> HelixError.Forwarded - } + HttpStatusCode.TooManyRequests -> { + when (request.url.encodedPath) { + "/helix/whispers" -> HelixError.WhisperRateLimited + "/helix/channels/commercial" -> HelixError.CommercialRateLimited + "/helix/chat/messages" -> HelixError.ChatMessageRateLimited + else -> HelixError.Forwarded + } + } - HttpStatusCode.TooManyRequests -> when (request.url.encodedPath) { - "/helix/whispers" -> HelixError.WhisperRateLimited - "/helix/channels/commercial" -> HelixError.CommercialRateLimited - else -> HelixError.Forwarded - } + HttpStatusCode.Conflict -> { + when (request.url.encodedPath) { + "helix/moderation/bans" -> HelixError.ConflictingBanOperation + else -> HelixError.Forwarded + } + } - HttpStatusCode.Conflict -> when (request.url.encodedPath) { - "helix/moderation/bans" -> HelixError.ConflictingBanOperation - else -> HelixError.Forwarded - } + HttpStatusCode.TooEarly -> { + HelixError.Forwarded + } - HttpStatusCode.TooEarly -> HelixError.Forwarded - else -> HelixError.Unknown - } + else -> { + HelixError.Unknown + } + } throw HelixApiException(error, betterStatus, request.url, message) } companion object { - private val TAG = HelixApiClient::class.java.simpleName private const val DEFAULT_PAGE_SIZE = 100 + private const val MAX_USER_EMOTES = 5000 private const val WHISPER_SELF_ERROR = "A user cannot whisper themself" private const val MISSING_SCOPE_ERROR = "Missing scope" private const val NO_VERIFIED_PHONE_ERROR = "the sender does not have a verified phone number" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiException.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiException.kt index 0d2013e50..20b6ac05d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiException.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiException.kt @@ -9,36 +9,75 @@ data class HelixApiException( override val status: HttpStatusCode, override val url: Url?, override val message: String? = null, - override val cause: Throwable? = null + override val cause: Throwable? = null, ) : ApiException(status, url, message, cause) sealed interface HelixError { data object MissingScopes : HelixError + data object NotLoggedIn : HelixError + data object Unknown : HelixError + data object WhisperSelf : HelixError + data object NoVerifiedPhone : HelixError + data object RecipientBlockedUser : HelixError + data object WhisperRateLimited : HelixError + data object RateLimited : HelixError + data object BroadcasterTokenRequired : HelixError + data object UserNotAuthorized : HelixError + data object TargetAlreadyModded : HelixError + data object TargetIsVip : HelixError + data object TargetNotModded : HelixError + data object TargetNotBanned : HelixError + data object TargetAlreadyBanned : HelixError + data object TargetCannotBeBanned : HelixError + data object ConflictingBanOperation : HelixError + data object InvalidColor : HelixError - data class MarkerError(val message: String?) : HelixError + + data class MarkerError( + val message: String?, + ) : HelixError + data object CommercialRateLimited : HelixError + data object CommercialNotStreaming : HelixError + data object MissingLengthParameter : HelixError + data object RaidSelf : HelixError + data object NoRaidPending : HelixError - data class NotInRange(val validRange: IntRange?) : HelixError + + data class NotInRange( + val validRange: IntRange?, + ) : HelixError + data object Forwarded : HelixError + data object ShoutoutSelf : HelixError + data object ShoutoutTargetNotStreaming : HelixError + + data object MessageAlreadyProcessed : HelixError + + data object MessageNotFound : HelixError + + data object MessageTooLarge : HelixError + + data object ChatMessageRateLimited : HelixError } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiStats.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiStats.kt new file mode 100644 index 000000000..dd4e7ca78 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/HelixApiStats.kt @@ -0,0 +1,20 @@ +package com.flxrs.dankchat.data.api.helix + +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.plusAssign + +@Single +class HelixApiStats { + private val _totalRequests = AtomicInt(0) + private val _statusCounts = ConcurrentHashMap() + + val totalRequests: Int get() = _totalRequests.load() + val statusCounts: Map get() = _statusCounts.mapValues { it.value.load() } + + fun recordResponse(statusCode: Int) { + _totalRequests += 1 + _statusCounts.getOrPut(statusCode) { AtomicInt(0) } += 1 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementColor.kt index b836a8af0..2219a22c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementColor.kt @@ -20,5 +20,5 @@ enum class AnnouncementColor { Orange, @SerialName("purple") - Purple + Purple, } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementRequestDto.kt index 8ea8997ee..36aa0ba7a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/AnnouncementRequestDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class AnnouncementRequestDto(val message: String, val color: AnnouncementColor = AnnouncementColor.Primary) +data class AnnouncementRequestDto( + val message: String, + val color: AnnouncementColor = AnnouncementColor.Primary, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BadgeSetDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BadgeSetDto.kt index 15bd3b3eb..54675c8e9 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BadgeSetDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BadgeSetDto.kt @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BadgeSetDto(@SerialName("set_id") val id: String, val versions: List) +data class BadgeSetDto( + @SerialName("set_id") val id: String, + val versions: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BanRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BanRequestDto.kt index af9fda25a..34b1c5029 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BanRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/BanRequestDto.kt @@ -5,4 +5,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class BanRequestDto(val data: BanRequestDataDto) +data class BanRequestDto( + val data: BanRequestDataDto, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ChannelEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ChannelEmoteDto.kt new file mode 100644 index 000000000..d624c5a94 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ChannelEmoteDto.kt @@ -0,0 +1,14 @@ +package com.flxrs.dankchat.data.api.helix.dto + +import androidx.annotation.Keep +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Keep +@Serializable +data class ChannelEmoteDto( + @SerialName(value = "id") val id: String, + @SerialName(value = "name") val name: String, + @SerialName(value = "emote_type") val emoteType: String, + @SerialName(value = "emote_set_id") val emoteSetId: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt new file mode 100644 index 000000000..786f0fcef --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CheermoteDto.kt @@ -0,0 +1,37 @@ +package com.flxrs.dankchat.data.api.helix.dto + +import androidx.annotation.Keep +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Keep +@Serializable +data class CheermoteSetDto( + val prefix: String, + val tiers: List, + val type: String, + val order: Int, +) + +@Keep +@Serializable +data class CheermoteTierDto( + @SerialName("min_bits") val minBits: Int, + val id: String, + val color: String, + val images: CheermoteTierImagesDto, +) + +@Keep +@Serializable +data class CheermoteTierImagesDto( + val dark: CheermoteThemeImagesDto, + val light: CheermoteThemeImagesDto, +) + +@Keep +@Serializable +data class CheermoteThemeImagesDto( + val animated: Map, + val static: Map, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialRequestDto.kt index eb99287ff..f0a886858 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/CommercialRequestDto.kt @@ -7,4 +7,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class CommercialRequestDto(@SerialName("broadcaster_id") val broadcastUserId: UserId, val length: Int) +data class CommercialRequestDto( + @SerialName("broadcaster_id") val broadcastUserId: UserId, + val length: Int, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/DataListDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/DataListDto.kt index 9f8debf23..96d1c25dd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/DataListDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/DataListDto.kt @@ -5,4 +5,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -class DataListDto(val data: List) +class DataListDto( + val data: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/HelixErrorDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/HelixErrorDto.kt index a2c8424ff..f15837973 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/HelixErrorDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/HelixErrorDto.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable @Serializable data class HelixErrorDto( val status: Int, - val message: String + val message: String, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ManageAutomodMessageRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ManageAutomodMessageRequestDto.kt new file mode 100644 index 000000000..238e4117b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ManageAutomodMessageRequestDto.kt @@ -0,0 +1,14 @@ +package com.flxrs.dankchat.data.api.helix.dto + +import com.flxrs.dankchat.data.UserId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ManageAutomodMessageRequestDto( + @SerialName("user_id") + val userId: UserId, + @SerialName("msg_id") + val msgId: String, + val action: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerRequestDto.kt index aca75729c..7deb64a4b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/MarkerRequestDto.kt @@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable @Serializable data class MarkerRequestDto( @SerialName("user_id") val userId: UserId, - val description: String? + val description: String?, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PagedDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PagedDto.kt index c315a375f..7f883106e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PagedDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PagedDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PagedDto(val data: List, val pagination: PaginationDto) +data class PagedDto( + val data: List, + val pagination: PaginationDto, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PaginationDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PaginationDto.kt index 5775ccee9..9ac24276c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PaginationDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/PaginationDto.kt @@ -5,4 +5,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class PaginationDto(val cursor: String?) +data class PaginationDto( + val cursor: String?, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/RaidDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/RaidDto.kt index 2c64e9b18..380b2f1b7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/RaidDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/RaidDto.kt @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class RaidDto(@SerialName("created_at") val createdAt: String, @SerialName("is_mature") val isMature: Boolean) +data class RaidDto( + @SerialName("created_at") val createdAt: String, + @SerialName("is_mature") val isMature: Boolean, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/SendChatMessageDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/SendChatMessageDto.kt new file mode 100644 index 000000000..3a1aa5e89 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/SendChatMessageDto.kt @@ -0,0 +1,26 @@ +package com.flxrs.dankchat.data.api.helix.dto + +import com.flxrs.dankchat.data.UserId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SendChatMessageRequestDto( + @SerialName("broadcaster_id") val broadcasterId: UserId, + @SerialName("sender_id") val senderId: UserId, + val message: String, + @SerialName("reply_parent_message_id") val replyParentMessageId: String? = null, +) + +@Serializable +data class SendChatMessageResponseDto( + @SerialName("message_id") val messageId: String, + @SerialName("is_sent") val isSent: Boolean, + @SerialName("drop_reason") val dropReason: DropReasonDto? = null, +) + +@Serializable +data class DropReasonDto( + val code: String, + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeRequestDto.kt index 89bb0e1ff..7d26d0a1c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeRequestDto.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable @Keep @Serializable data class ShieldModeRequestDto( - @SerialName("is_active") val isActive: Boolean + @SerialName("is_active") val isActive: Boolean, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeStatusDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeStatusDto.kt index a10f6df73..0f5d03475 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeStatusDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/ShieldModeStatusDto.kt @@ -4,9 +4,9 @@ import androidx.annotation.Keep import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import kotlin.time.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Keep @Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserBlockDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserBlockDto.kt index d79091485..11b551aa5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserBlockDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserBlockDto.kt @@ -8,5 +8,5 @@ import kotlinx.serialization.Serializable @Keep @Serializable data class UserBlockDto( - @SerialName(value = "user_id") val id: UserId + @SerialName(value = "user_id") val id: UserId, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserDto.kt index 9688c7cb2..9bcc6910a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserDto.kt @@ -19,5 +19,5 @@ data class UserDto( @SerialName(value = "profile_image_url") val avatarUrl: String, @SerialName(value = "offline_image_url") val offlineImageUrl: String, @SerialName(value = "view_count") val viewCount: Int, - @SerialName(value = "created_at") val createdAt: String + @SerialName(value = "created_at") val createdAt: String, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserEmoteDto.kt new file mode 100644 index 000000000..16edb5f22 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserEmoteDto.kt @@ -0,0 +1,15 @@ +package com.flxrs.dankchat.data.api.helix.dto + +import androidx.annotation.Keep +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Keep +@Serializable +data class UserEmoteDto( + @SerialName(value = "id") val id: String, + @SerialName(value = "name") val name: String, + @SerialName(value = "emote_type") val emoteType: String, + @SerialName(value = "emote_set_id") val emoteSetId: String, + @SerialName(value = "owner_id") val ownerId: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDataDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDataDto.kt index 01e42ab6a..7135f0c38 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDataDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDataDto.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable @Keep @Serializable data class UserFollowsDataDto( - @SerialName(value = "followed_at") val followedAt: String + @SerialName(value = "followed_at") val followedAt: String, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDto.kt index c22649e69..efc22f7ac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/UserFollowsDto.kt @@ -8,5 +8,5 @@ import kotlinx.serialization.Serializable @Serializable data class UserFollowsDto( @SerialName(value = "total") val total: Int, - @SerialName(value = "data") val data: List + @SerialName(value = "data") val data: List, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/WhisperRequestDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/WhisperRequestDto.kt index 1f4e9b6b0..eeab00eac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/WhisperRequestDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/helix/dto/WhisperRequestDto.kt @@ -5,4 +5,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class WhisperRequestDto(val message: String) +data class WhisperRequestDto( + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApi.kt index 70a219a30..e65a4339b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApi.kt @@ -5,9 +5,13 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.parameter -class RecentMessagesApi(private val ktorClient: HttpClient) { - - suspend fun getRecentMessages(channel: UserName, limit: Int) = ktorClient.get("recent-messages/$channel") { +class RecentMessagesApi( + private val ktorClient: HttpClient, +) { + suspend fun getRecentMessages( + channel: UserName, + limit: Int, + ) = ktorClient.get("recent-messages/$channel") { parameter("limit", limit) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiClient.kt index 9b9b60710..5c8909685 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiClient.kt @@ -16,10 +16,13 @@ class RecentMessagesApiClient( private val recentMessagesApi: RecentMessagesApi, private val chatSettingsDataStore: ChatSettingsDataStore, ) { - - suspend fun getRecentMessages(channel: UserName, messageLimit: Int? = null): Result = runCatching { + suspend fun getRecentMessages( + channel: UserName, + messageLimit: Int? = null, + ): Result = runCatching { val limit = messageLimit ?: chatSettingsDataStore.settings.first().scrollbackLength - recentMessagesApi.getRecentMessages(channel, limit) + recentMessagesApi + .getRecentMessages(channel, limit) .throwRecentMessagesErrorOnFailure() .body() } @@ -32,11 +35,12 @@ class RecentMessagesApiClient( val errorBody = runCatching { body() }.getOrNull() val betterStatus = HttpStatusCode.fromValue(status.value) val message = errorBody?.error ?: betterStatus.description - val error = when (errorBody?.errorCode) { - RecentMessagesDto.ERROR_CHANNEL_NOT_JOINED -> RecentMessagesError.ChannelNotJoined - RecentMessagesDto.ERROR_CHANNEL_IGNORED -> RecentMessagesError.ChannelIgnored - else -> RecentMessagesError.Unknown - } + val error = + when (errorBody?.errorCode) { + RecentMessagesDto.ERROR_CHANNEL_NOT_JOINED -> RecentMessagesError.ChannelNotJoined + RecentMessagesDto.ERROR_CHANNEL_IGNORED -> RecentMessagesError.ChannelIgnored + else -> RecentMessagesError.Unknown + } throw RecentMessagesApiException(error, betterStatus, request.url, message) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiException.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiException.kt index 954cce107..67a353666 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiException.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/RecentMessagesApiException.kt @@ -9,11 +9,11 @@ data class RecentMessagesApiException( override val status: HttpStatusCode, override val url: Url?, override val message: String? = null, - override val cause: Throwable? = null + override val cause: Throwable? = null, ) : ApiException(status, url, message, cause) enum class RecentMessagesError { ChannelNotJoined, ChannelIgnored, - Unknown -} \ No newline at end of file + Unknown, +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/dto/RecentMessagesDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/dto/RecentMessagesDto.kt index 189e74a7d..5ff3c3657 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/dto/RecentMessagesDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/recentmessages/dto/RecentMessagesDto.kt @@ -18,4 +18,4 @@ data class RecentMessagesDto( const val ERROR_CHANNEL_NOT_JOINED = "channel_not_joined" const val ERROR_CHANNEL_IGNORED = "channel_ignored" } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApi.kt index 7b885b998..7677f1976 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApi.kt @@ -4,8 +4,9 @@ import com.flxrs.dankchat.data.UserId import io.ktor.client.HttpClient import io.ktor.client.request.get -class SevenTVApi(private val ktorClient: HttpClient) { - +class SevenTVApi( + private val ktorClient: HttpClient, +) { suspend fun getChannelEmotes(channelId: UserId) = ktorClient.get("users/twitch/$channelId") suspend fun getEmoteSet(emoteSetId: String) = ktorClient.get("emote-sets/$emoteSetId") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApiClient.kt index 70348b865..cb8d279d6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVApiClient.kt @@ -11,22 +11,27 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class SevenTVApiClient(private val sevenTVApi: SevenTVApi, private val json: Json) { - +class SevenTVApiClient( + private val sevenTVApi: SevenTVApi, + private val json: Json, +) { suspend fun getSevenTVChannelEmotes(channelId: UserId): Result = runCatching { - sevenTVApi.getChannelEmotes(channelId) + sevenTVApi + .getChannelEmotes(channelId) .throwApiErrorOnFailure(json) .body() }.recoverNotFoundWith(default = null) suspend fun getSevenTVEmoteSet(emoteSetId: String): Result = runCatching { - sevenTVApi.getEmoteSet(emoteSetId) + sevenTVApi + .getEmoteSet(emoteSetId) .throwApiErrorOnFailure(json) .body() } suspend fun getSevenTVGlobalEmotes(): Result> = runCatching { - sevenTVApi.getGlobalEmotes() + sevenTVApi + .getGlobalEmotes() .throwApiErrorOnFailure(json) .body() .emotes diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVUserDetails.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVUserDetails.kt index 77ba4a3b0..24b14b26a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVUserDetails.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/SevenTVUserDetails.kt @@ -1,3 +1,7 @@ package com.flxrs.dankchat.data.api.seventv -data class SevenTVUserDetails(val id: String, val activeEmoteSetId: String, val connectionIndex: Int) +data class SevenTVUserDetails( + val id: String, + val activeEmoteSetId: String, + val connectionIndex: Int, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDataDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDataDto.kt index 73e022c03..a18a28a1a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDataDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDataDto.kt @@ -14,7 +14,6 @@ data class SevenTVEmoteDataDto( val owner: SevenTVEmoteOwnerDto?, @SerialName("name") val baseName: String, ) { - val isTwitchDisallowed get() = (TWITCH_DISALLOWED_FLAG and flags) == TWITCH_DISALLOWED_FLAG companion object { diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDto.kt index 9c733be9f..5fa9af444 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteDto.kt @@ -9,7 +9,7 @@ data class SevenTVEmoteDto( val id: String, val name: String, val flags: Long, - val data: SevenTVEmoteDataDto? + val data: SevenTVEmoteDataDto?, ) { val isZeroWidth get() = (ZERO_WIDTH_FLAG and flags) == ZERO_WIDTH_FLAG diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteOwnerDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteOwnerDto.kt index 75d8e05ab..fcf986e1d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteOwnerDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteOwnerDto.kt @@ -7,4 +7,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVEmoteOwnerDto(@SerialName(value = "display_name") val displayName: DisplayName?) +data class SevenTVEmoteOwnerDto( + @SerialName(value = "display_name") val displayName: DisplayName?, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteSetDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteSetDto.kt index 6aec406b5..c90078d70 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteSetDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVEmoteSetDto.kt @@ -8,5 +8,5 @@ import kotlinx.serialization.Serializable data class SevenTVEmoteSetDto( val id: String, val name: String, - val emotes: List? + val emotes: List?, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserConnection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserConnection.kt index 8a8233289..853c5afbe 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserConnection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserConnection.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVUserConnection(val platform: String) { +data class SevenTVUserConnection( + val platform: String, +) { companion object { const val twitch = "TWITCH" } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDataDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDataDto.kt index e0814b8f2..72b9411ac 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDataDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDataDto.kt @@ -5,4 +5,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SevenTVUserDataDto(val id: String, val connections: List) +data class SevenTVUserDataDto( + val id: String, + val connections: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDto.kt index e887e7a58..27ab282ad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/dto/SevenTVUserDto.kt @@ -9,5 +9,5 @@ import kotlinx.serialization.Serializable data class SevenTVUserDto( val id: String, val user: SevenTVUserDataDto, - @SerialName("emote_set") val emoteSet: SevenTVEmoteSetDto? + @SerialName("emote_set") val emoteSet: SevenTVEmoteSetDto?, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventApiClient.kt index 3ff107caf..45da01c3d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventApiClient.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.api.seventv.eventapi -import android.util.Log import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.api.seventv.eventapi.dto.AckMessage import com.flxrs.dankchat.data.api.seventv.eventapi.dto.DataMessage @@ -15,12 +14,13 @@ import com.flxrs.dankchat.data.api.seventv.eventapi.dto.SubscribeRequest import com.flxrs.dankchat.data.api.seventv.eventapi.dto.UnsubscribeRequest import com.flxrs.dankchat.data.api.seventv.eventapi.dto.UserDispatchData import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.di.WebSocketOkHttpClient +import com.flxrs.dankchat.di.WEBSOCKET_OKHTTP_CLIENT import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.LiveUpdatesBackgroundBehavior import com.flxrs.dankchat.utils.AppLifecycleListener import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle.Background import com.flxrs.dankchat.utils.extensions.timer +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.HttpHeaders import io.ktor.util.collections.ConcurrentSet import kotlinx.coroutines.CoroutineScope @@ -49,10 +49,11 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +private val logger = KotlinLogging.logger("SevenTVEventApiClient") + @Single class SevenTVEventApiClient( - @Named(type = WebSocketOkHttpClient::class) - private val client: OkHttpClient, + @Named(WEBSOCKET_OKHTTP_CLIENT) private val client: OkHttpClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val appLifecycleListener: AppLifecycleListener, defaultJson: Json, @@ -60,14 +61,17 @@ class SevenTVEventApiClient( ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private var socket: WebSocket? = null - private val request = Request.Builder() - .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") - .url("wss://events.7tv.io/v3") - .build() - - private val json = Json(defaultJson) { - encodeDefaults = true - } + private val request = + Request + .Builder() + .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") + .url("wss://events.7tv.io/v3") + .build() + + private val json = + Json(defaultJson) { + encodeDefaults = true + } private var connecting = false private var connected = false @@ -93,23 +97,24 @@ class SevenTVEventApiClient( .collectLatest { enabled -> when { enabled && !connected && !connecting -> start() - else -> close() + else -> close() } if (enabled) { appLifecycleListener.appState .debounce(FLOW_DEBOUNCE) .collectLatest { state -> if (state == Background) { - val timeout = when (chatSettingsDataStore.settings.first().sevenTVLiveEmoteUpdatesBehavior) { - LiveUpdatesBackgroundBehavior.Always -> return@collectLatest - LiveUpdatesBackgroundBehavior.Never -> Duration.ZERO - LiveUpdatesBackgroundBehavior.FiveMinutes -> 5.minutes - LiveUpdatesBackgroundBehavior.OneHour -> 1.hours - LiveUpdatesBackgroundBehavior.OneMinute -> 1.minutes - LiveUpdatesBackgroundBehavior.ThirtyMinutes -> 30.minutes - } - - Log.d(TAG, "[7TV Event-Api] Sleeping for $timeout until connection is closed") + val timeout = + when (chatSettingsDataStore.settings.first().sevenTVLiveEmoteUpdatesBehavior) { + LiveUpdatesBackgroundBehavior.Always -> return@collectLatest + LiveUpdatesBackgroundBehavior.Never -> Duration.ZERO + LiveUpdatesBackgroundBehavior.FiveMinutes -> 5.minutes + LiveUpdatesBackgroundBehavior.OneHour -> 1.hours + LiveUpdatesBackgroundBehavior.OneMinute -> 1.minutes + LiveUpdatesBackgroundBehavior.ThirtyMinutes -> 30.minutes + } + + logger.debug { "[7TV Event-Api] Sleeping for $timeout until connection is closed" } delay(timeout) close() } @@ -222,15 +227,23 @@ class SevenTVEventApiClient( private inner class EventApiWebSocketListener : WebSocketListener() { private var heartBeatJob: Job? = null - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "[7TV Event-Api] connection closed") + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + logger.debug { "[7TV Event-Api] connection closed" } connected = false heartBeatJob?.cancel() } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "[7TV Event-Api] connection failed: $t") - Log.e(TAG, "[7TV Event-Api] attempting to reconnect #${reconnectAttempts}..") + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + logger.error { "[7TV Event-Api] connection failed: $t" } + logger.error { "[7TV Event-Api] attempting to reconnect #$reconnectAttempts.." } connected = false connecting = false heartBeatJob?.cancel() @@ -238,21 +251,28 @@ class SevenTVEventApiClient( attemptReconnect() } - override fun onOpen(webSocket: WebSocket, response: Response) { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { connected = true connecting = false reconnectAttempts = 1 - Log.i(TAG, "[7TV Event-Api] connected") + logger.info { "[7TV Event-Api] connected" } } - override fun onMessage(webSocket: WebSocket, text: String) { - val message = runCatching { json.decodeFromString(text) }.getOrElse { - Log.d(TAG, "Failed to parse incoming message: ", it) - return - } + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val message = + runCatching { json.decodeFromString(text) }.getOrElse { + logger.debug(it) { "Failed to parse incoming message" } + return + } when (message) { - is HelloMessage -> { + is HelloMessage -> { heartBeatInterval = message.d.heartBeatInterval.milliseconds heartBeatJob = setupHeartBeatInterval() @@ -262,70 +282,94 @@ class SevenTVEventApiClient( } } - is HeartbeatMessage -> lastHeartBeat = System.currentTimeMillis() - is DispatchMessage -> message.handleMessage() - is ReconnectMessage -> scope.launch { reconnect() } - is EndOfStreamMessage -> Unit - is AckMessage -> Unit - } - } - - } - - private fun DispatchMessage.handleMessage() { - when (d) { - is EmoteSetDispatchData -> with(d.body) { - val emoteSetId = id - val actorName = actor.displayName - val added = pushed?.mapNotNull { - it.value ?: return@mapNotNull null + is HeartbeatMessage -> { + lastHeartBeat = System.currentTimeMillis() + } + is DispatchMessage -> { + message.handleMessage() } - val removed = pulled?.mapNotNull { - val removedData = it.oldValue ?: return@mapNotNull null - SevenTVEventMessage.EmoteSetUpdated.RemovedEmote(removedData.id, removedData.name) + + is ReconnectMessage -> { + scope.launch { reconnect() } } - val updated = updated?.mapNotNull { - val newData = it.value ?: return@mapNotNull null - val oldData = it.oldValue ?: return@mapNotNull null - SevenTVEventMessage.EmoteSetUpdated.UpdatedEmote(newData.id, newData.name, oldData.name) + + is EndOfStreamMessage -> { + Unit } - scope.launch { - _messages.emit( - SevenTVEventMessage.EmoteSetUpdated( - emoteSetId = emoteSetId, - actorName = actorName, - added = added.orEmpty(), - removed = removed.orEmpty(), - updated = updated.orEmpty(), - ) - ) + + is AckMessage -> { + Unit } } + } + } - is UserDispatchData -> with(d.body) { - val actorName = actor.displayName - updated?.forEach { change -> - val index = change.index - val emoteSetChange = change.value?.filterIsInstance()?.firstOrNull() ?: return + private fun DispatchMessage.handleMessage() { + when (d) { + is EmoteSetDispatchData -> { + with(d.body) { + val emoteSetId = id + val actorName = actor.displayName + val added = + pushed?.mapNotNull { + it.value ?: return@mapNotNull null + } + val removed = + pulled?.mapNotNull { + val removedData = it.oldValue ?: return@mapNotNull null + SevenTVEventMessage.EmoteSetUpdated.RemovedEmote(removedData.id, removedData.name) + } + val updated = + updated?.mapNotNull { + val newData = it.value ?: return@mapNotNull null + val oldData = it.oldValue ?: return@mapNotNull null + SevenTVEventMessage.EmoteSetUpdated.UpdatedEmote(newData.id, newData.name, oldData.name) + } scope.launch { _messages.emit( - SevenTVEventMessage.UserUpdated( + SevenTVEventMessage.EmoteSetUpdated( + emoteSetId = emoteSetId, actorName = actorName, - connectionIndex = index, - emoteSetId = emoteSetChange.value.id, - oldEmoteSetId = emoteSetChange.oldValue.id - ) + added = added.orEmpty(), + removed = removed.orEmpty(), + updated = updated.orEmpty(), + ), ) } } } + + is UserDispatchData -> { + with(d.body) { + val actorName = actor.displayName + updated?.forEach { change -> + val index = change.index + val emoteSetChange = change.value?.filterIsInstance()?.firstOrNull() ?: return + scope.launch { + _messages.emit( + SevenTVEventMessage.UserUpdated( + actorName = actorName, + connectionIndex = index, + emoteSetId = emoteSetChange.value.id, + oldEmoteSetId = emoteSetChange.oldValue.id, + ), + ) + } + } + } + } } } - private inline fun T.encodeOrNull(): String? { - return runCatching { json.encodeToString(this) }.getOrNull() - } + private inline fun T.encodeOrNull(): String? = runCatching { json.encodeToString(this) }.getOrNull() + + data class Status( + val connected: Boolean, + val subscriptionCount: Int, + ) + + fun status(): Status = Status(connected = connected, subscriptionCount = subscriptions.size) companion object { private const val MAX_JITTER = 250L @@ -333,6 +377,5 @@ class SevenTVEventApiClient( private const val RECONNECT_MAX_ATTEMPTS = 6 private val DEFAULT_HEARTBEAT_INTERVAL = 25.seconds private val FLOW_DEBOUNCE = 2.seconds - private val TAG = SevenTVEventApiClient::class.java.simpleName } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventMessage.kt index 095b01444..efbd2d532 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/SevenTVEventMessage.kt @@ -4,7 +4,6 @@ import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.api.seventv.dto.SevenTVEmoteDto sealed interface SevenTVEventMessage { - data class EmoteSetUpdated( val emoteSetId: String, val actorName: DisplayName, @@ -12,9 +11,22 @@ sealed interface SevenTVEventMessage { val removed: List, val updated: List, ) : SevenTVEventMessage { - data class UpdatedEmote(val id: String, val name: String, val oldName: String) - data class RemovedEmote(val id: String, val name: String) + data class UpdatedEmote( + val id: String, + val name: String, + val oldName: String, + ) + + data class RemovedEmote( + val id: String, + val name: String, + ) } - data class UserUpdated(val actorName: DisplayName, val connectionIndex: Int, val emoteSetId: String, val oldEmoteSetId: String) : SevenTVEventMessage + data class UserUpdated( + val actorName: DisplayName, + val connectionIndex: Int, + val emoteSetId: String, + val oldEmoteSetId: String, + ) : SevenTVEventMessage } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/AckMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/AckMessage.kt index 1d356487d..8a4960771 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/AckMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/AckMessage.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("5") -data class AckMessage(override val d: AckData) : DataMessage +data class AckMessage( + override val d: AckData, +) : DataMessage @Serializable data object AckData : Data diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/DispatchMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/DispatchMessage.kt index 84b38cd97..660e6fb19 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/DispatchMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/DispatchMessage.kt @@ -9,7 +9,9 @@ import kotlinx.serialization.json.JsonClassDiscriminator @Serializable @SerialName("0") -data class DispatchMessage(override val d: DispatchData) : DataMessage +data class DispatchMessage( + override val d: DispatchData, +) : DataMessage @Serializable @JsonClassDiscriminator(discriminator = "type") @@ -23,12 +25,14 @@ interface ChangeMapData { } @Serializable -data class Actor(@SerialName("display_name") val displayName: DisplayName) +data class Actor( + @SerialName("display_name") val displayName: DisplayName, +) @Serializable @SerialName("emote_set.update") data class EmoteSetDispatchData( - override val body: EmoteSetChangeMapData + override val body: EmoteSetChangeMapData, ) : DispatchData @Serializable @@ -37,7 +41,7 @@ data class EmoteSetChangeMapData( override val actor: Actor, val pushed: List?, val pulled: List?, - val updated: List? + val updated: List?, ) : ChangeMapData @Serializable @@ -46,31 +50,40 @@ sealed interface ChangeField @Serializable @SerialName("emotes") -data class EmoteChangeField(val value: SevenTVEmoteDto?, @SerialName("old_value") val oldValue: SevenTVEmoteDto?) : ChangeField +data class EmoteChangeField( + val value: SevenTVEmoteDto?, + @SerialName("old_value") val oldValue: SevenTVEmoteDto?, +) : ChangeField @Serializable @SerialName("user.update") data class UserDispatchData( - override val body: UserChangeMapData + override val body: UserChangeMapData, ) : DispatchData @Serializable data class UserChangeMapData( override val id: String, override val actor: Actor, - val updated: List? + val updated: List?, ) : ChangeMapData @Serializable @SerialName("connections") -data class UserChangeFields(val value: List?, val index: Int) : ChangeField +data class UserChangeFields( + val value: List?, + val index: Int, +) : ChangeField @Serializable sealed interface UserChangeField : ChangeField @Serializable @SerialName("emote_set") -data class EmoteSetChangeField(val value: EmoteSet, @SerialName("old_value") val oldValue: EmoteSet) : UserChangeField +data class EmoteSetChangeField( + val value: EmoteSet, + @SerialName("old_value") val oldValue: EmoteSet, +) : UserChangeField @Keep @Serializable @@ -78,4 +91,6 @@ data class EmoteSetChangeField(val value: EmoteSet, @SerialName("old_value") val data object EmoteSetIdChangeField : UserChangeField @Serializable -data class EmoteSet(val id: String) +data class EmoteSet( + val id: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/EndOfStreamMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/EndOfStreamMessage.kt index 259434333..479422a17 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/EndOfStreamMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/EndOfStreamMessage.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("7") -data class EndOfStreamMessage(override val d: EndOfStreamData) : DataMessage +data class EndOfStreamMessage( + override val d: EndOfStreamData, +) : DataMessage @Serializable data object EndOfStreamData : Data diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HeartbeatMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HeartbeatMessage.kt index 38fb35edb..375096846 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HeartbeatMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HeartbeatMessage.kt @@ -5,7 +5,11 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("2") -data class HeartbeatMessage(override val d: HeartbeatData) : DataMessage +data class HeartbeatMessage( + override val d: HeartbeatData, +) : DataMessage @Serializable -data class HeartbeatData(val count: Int) : Data +data class HeartbeatData( + val count: Int, +) : Data diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HelloMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HelloMessage.kt index 4eb84a232..56d447dd5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HelloMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/HelloMessage.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("1") -data class HelloMessage(override val d: HelloData) : DataMessage +data class HelloMessage( + override val d: HelloData, +) : DataMessage @Serializable data class HelloData( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/ReconnectMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/ReconnectMessage.kt index 0936446bf..ea9ae121a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/ReconnectMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/ReconnectMessage.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Serializable @SerialName("4") -data class ReconnectMessage(override val d: ReconnectData) : DataMessage +data class ReconnectMessage( + override val d: ReconnectData, +) : DataMessage @Serializable data object ReconnectData : Data diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscribeRequest.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscribeRequest.kt index 5173a0546..2eadb7851 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscribeRequest.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscribeRequest.kt @@ -22,7 +22,12 @@ data class SubscribeRequest( } @Serializable -data class SubscriptionData(val type: String, val condition: SubscriptionCondition) : RequestData +data class SubscriptionData( + val type: String, + val condition: SubscriptionCondition, +) : RequestData @Serializable -data class SubscriptionCondition(@SerialName("object_id") val objectId: String) +data class SubscriptionCondition( + @SerialName("object_id") val objectId: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscriptionType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscriptionType.kt index 80b1123c9..7780e4a00 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscriptionType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/SubscriptionType.kt @@ -1,6 +1,8 @@ package com.flxrs.dankchat.data.api.seventv.eventapi.dto -enum class SubscriptionType(val type: String) { +enum class SubscriptionType( + val type: String, +) { UserUpdates(type = "user.update"), EmoteSetUpdates(type = "emote_set.update"), } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/UnsubscribeRequest.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/UnsubscribeRequest.kt index 0d8517e4e..a22234d67 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/UnsubscribeRequest.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/seventv/eventapi/dto/UnsubscribeRequest.kt @@ -3,14 +3,17 @@ package com.flxrs.dankchat.data.api.seventv.eventapi.dto import kotlinx.serialization.Serializable @Serializable -data class UnsubscribeRequest(override val op: Int = 36, override val d: SubscriptionData) : DataRequest { +data class UnsubscribeRequest( + override val op: Int = 36, + override val d: SubscriptionData, +) : DataRequest { companion object { fun userUpdates(userId: String) = UnsubscribeRequest( - d = SubscriptionData(type = SubscriptionType.UserUpdates.type, condition = SubscriptionCondition(objectId = userId)) + d = SubscriptionData(type = SubscriptionType.UserUpdates.type, condition = SubscriptionCondition(objectId = userId)), ) fun emoteSetUpdates(emoteSetId: String) = UnsubscribeRequest( - d = SubscriptionData(type = SubscriptionType.EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)) + d = SubscriptionData(type = SubscriptionType.EmoteSetUpdates.type, condition = SubscriptionCondition(objectId = emoteSetId)), ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApi.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApi.kt index 74dd002f2..9578c0a9a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApi.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApi.kt @@ -5,8 +5,9 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.parameter -class SupibotApi(private val ktorClient: HttpClient) { - +class SupibotApi( + private val ktorClient: HttpClient, +) { suspend fun getChannels(platformName: String = "twitch") = ktorClient.get("bot/channel/list") { parameter("platformName", platformName) } @@ -14,4 +15,4 @@ class SupibotApi(private val ktorClient: HttpClient) { suspend fun getCommands() = ktorClient.get("bot/command/list/") suspend fun getUserAliases(user: UserName) = ktorClient.get("bot/user/$user/alias/list/") -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApiClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApiClient.kt index c832d0370..01eac8671 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApiClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/SupibotApiClient.kt @@ -10,22 +10,27 @@ import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @Single -class SupibotApiClient(private val supibotApi: SupibotApi, private val json: Json) { - +class SupibotApiClient( + private val supibotApi: SupibotApi, + private val json: Json, +) { suspend fun getSupibotCommands(): Result = runCatching { - supibotApi.getCommands() + supibotApi + .getCommands() .throwApiErrorOnFailure(json) .body() } suspend fun getSupibotChannels(): Result = runCatching { - supibotApi.getChannels() + supibotApi + .getChannels() .throwApiErrorOnFailure(json) .body() } suspend fun getSupibotUserAliases(user: UserName): Result = runCatching { - supibotApi.getUserAliases(user) + supibotApi + .getUserAliases(user) .throwApiErrorOnFailure(json) .body() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelDto.kt index 93be8e1a1..578619984 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelDto.kt @@ -7,7 +7,10 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotChannelDto(@SerialName(value = "name") val name: UserName, @SerialName(value = "mode") val mode: String) { +data class SupibotChannelDto( + @SerialName(value = "name") val name: UserName, + @SerialName(value = "mode") val mode: String, +) { val isActive: Boolean get() = mode != "Last seen" && mode != "Read" -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelsDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelsDto.kt index 3ffe65aeb..fb5962c3f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelsDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotChannelsDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotChannelsDto(@SerialName(value = "data") val data: List) \ No newline at end of file +data class SupibotChannelsDto( + @SerialName(value = "data") val data: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandDto.kt index b09c58434..d03f9678e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandDto.kt @@ -6,4 +6,7 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotCommandDto(@SerialName(value = "name") val name: String, @SerialName(value = "aliases") val aliases: List) \ No newline at end of file +data class SupibotCommandDto( + @SerialName(value = "name") val name: String, + @SerialName(value = "aliases") val aliases: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandsDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandsDto.kt index 7aa6b733c..2dc3f4371 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandsDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotCommandsDto.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotCommandsDto(@SerialName(value = "data") val data: List) - +data class SupibotCommandsDto( + @SerialName(value = "data") val data: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasDto.kt index 0a14ceb7b..ca55ea81a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotUserAliasDto(@SerialName(value = "name") val name: String) \ No newline at end of file +data class SupibotUserAliasDto( + @SerialName(value = "name") val name: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasesDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasesDto.kt index 5939dd51b..636c5b911 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasesDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/supibot/dto/SupibotUserAliasesDto.kt @@ -6,4 +6,6 @@ import kotlinx.serialization.Serializable @Keep @Serializable -data class SupibotUserAliasesDto(@SerialName(value = "data") val data: List) \ No newline at end of file +data class SupibotUserAliasesDto( + @SerialName(value = "data") val data: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt index 2bd2ac32b..1dc91b206 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/UploadClient.kt @@ -1,60 +1,73 @@ package com.flxrs.dankchat.data.api.upload -import android.util.Log import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.api.ApiException import com.flxrs.dankchat.data.api.upload.dto.UploadDto -import com.flxrs.dankchat.di.UploadOkHttpClient +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.di.UPLOAD_OKHTTP_CLIENT import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.Response -import org.json.JSONObject import org.koin.core.annotation.Named import org.koin.core.annotation.Single import java.io.File import java.net.URLConnection import java.time.Instant +private val logger = KotlinLogging.logger("UploadClient") + @Single class UploadClient( - @Named(type = UploadOkHttpClient::class) private val httpClient: OkHttpClient, + @Named(UPLOAD_OKHTTP_CLIENT) private val httpClient: OkHttpClient, private val toolsSettingsDataStore: ToolsSettingsDataStore, + private val dispatchersProvider: DispatchersProvider, ) { - - suspend fun uploadMedia(file: File): Result = withContext(Dispatchers.IO) { + suspend fun uploadMedia(file: File): Result = withContext(dispatchersProvider.io) { val uploader = toolsSettingsDataStore.settings.first().uploaderConfig val mimetype = URLConnection.guessContentTypeFromName(file.name) - val requestBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart(name = uploader.formField, filename = file.name, body = file.asRequestBody(mimetype.toMediaType())) - .build() - val request = Request.Builder() - .url(uploader.uploadUrl) - .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") - .apply { - uploader.parsedHeaders.forEach { (name, value) -> - header(name, value) - } + val requestBody = + MultipartBody + .Builder() + .setType(MultipartBody.FORM) + .addFormDataPart(name = uploader.formField, filename = file.name, body = file.asRequestBody(mimetype.toMediaType())) + .build() + val request = + Request + .Builder() + .url(uploader.uploadUrl) + .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") + .apply { + uploader.parsedHeaders.forEach { (name, value) -> + header(name, value) + } + }.post(requestBody) + .build() + + val response = + runCatching { + httpClient.newCall(request).execute() + }.getOrElse { + return@withContext Result.failure(it) } - .post(requestBody) - .build() - - val response = runCatching { - httpClient.newCall(request).execute() - }.getOrElse { - return@withContext Result.failure(it) - } when { response.isSuccessful -> { @@ -67,27 +80,26 @@ class UploadClient( UploadDto( imageLink = body, deleteLink = null, - timestamp = Instant.now() + timestamp = Instant.now(), ) } } response - .asJsonObject() + .asJson() .mapCatching { json -> val deleteLink = deletionLinkPattern?.takeIf { it.isNotBlank() }?.let { json.extractLink(it) } val imageLink = json.extractLink(imageLinkPattern) UploadDto( imageLink = imageLink, deleteLink = deleteLink, - timestamp = Instant.now() + timestamp = Instant.now(), ) } - } - else -> { - Log.e(TAG, "Upload failed with ${response.code} ${response.message}") + else -> { + logger.error { "Upload failed with ${response.code} ${response.message}" } val url = URLBuilder(response.request.url.toString()).build() Result.failure(ApiException(HttpStatusCode.fromValue(response.code), url, response.message)) } @@ -95,43 +107,45 @@ class UploadClient( } @Suppress("RegExpRedundantEscape") - private suspend fun JSONObject.extractLink(linkPattern: String): String = withContext(Dispatchers.Default) { - var imageLink: String = linkPattern - - val regex = "\\{(.+?)\\}".toRegex() - regex.findAll(linkPattern).forEach { - val jsonValue = getValue(it.groupValues[1]) - if (jsonValue != null) { - imageLink = imageLink.replace(it.groupValues[0], jsonValue) - } - } - imageLink + private suspend fun JsonElement.extractLink(linkPattern: String): String = withContext(dispatchersProvider.default) { + extractJsonLink(linkPattern) } - private fun Response.asJsonObject(): Result = runCatching { - val bodyString = body.string() - JSONObject(bodyString) + private fun Response.asJson(): Result = runCatching { + Json.parseToJsonElement(body.string()) }.onFailure { - Log.d(TAG, "Error creating JsonObject from response: ", it) + logger.debug(it) { "Error parsing JSON from response" } } - private fun JSONObject.getValue(pattern: String): String? { - return runCatching { - pattern - .split(".") - .fold(this) { acc, key -> - val value = acc.get(key) - if (value !is JSONObject) { - return value.toString() - } - - value + companion object { + private val LINK_PATTERN_REGEX = "\\{(.+?)\\}".toRegex() + + @Suppress("RegExpRedundantEscape") + internal fun JsonElement.extractJsonLink(linkPattern: String): String { + var result = linkPattern + LINK_PATTERN_REGEX.findAll(linkPattern).forEach { + val jsonValue = getJsonValue(it.groupValues[1]) + if (jsonValue != null) { + result = result.replace(it.groupValues[0], jsonValue) } - null - }.getOrNull() - } + } + return result + } - companion object { - private val TAG = UploadClient::class.java.simpleName + internal fun JsonElement.getJsonValue(pattern: String): String? { + return runCatching { + val result = pattern.split(".").fold(this) { acc: JsonElement, key -> + when (acc) { + is JsonObject -> acc.jsonObject[key] ?: return@runCatching null + is JsonArray -> acc.jsonArray[key.toInt()] + is JsonPrimitive -> return@runCatching acc.content + } + } + when (result) { + is JsonPrimitive -> result.content + is JsonObject, is JsonArray -> result.toString() + } + }.getOrNull() + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/dto/UploadDto.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/dto/UploadDto.kt index 923d11a0a..0917b3185 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/dto/UploadDto.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/api/upload/dto/UploadDto.kt @@ -5,5 +5,5 @@ import java.time.Instant data class UploadDto( val imageLink: String, val deleteLink: String?, - val timestamp: Instant -) \ No newline at end of file + val timestamp: Instant, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt new file mode 100644 index 000000000..7d9ba5bfa --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthDataStore.kt @@ -0,0 +1,148 @@ +package com.flxrs.dankchat.data.auth + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.datastore.core.DataMigration +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.toDisplayName +import com.flxrs.dankchat.data.toUserId +import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.utils.datastore.createDataStore +import com.flxrs.dankchat.utils.datastore.safeData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.core.annotation.Single + +@Single +class AuthDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + private val legacyPrefs: SharedPreferences = + context.getSharedPreferences( + "com.flxrs.dankchat_preferences", + Context.MODE_PRIVATE, + ) + + private val sharedPrefsMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: AuthSettings): Boolean = legacyPrefs.contains(LEGACY_LOGGED_IN_KEY) || + legacyPrefs.contains(LEGACY_OAUTH_KEY) || + legacyPrefs.contains(LEGACY_NAME_KEY) + + override suspend fun migrate(currentData: AuthSettings): AuthSettings { + val isLoggedIn = legacyPrefs.getBoolean(LEGACY_LOGGED_IN_KEY, false) + val oAuthKey = legacyPrefs.getString(LEGACY_OAUTH_KEY, null) + val userName = legacyPrefs.getString(LEGACY_NAME_KEY, null)?.ifBlank { null } + val displayName = legacyPrefs.getString(LEGACY_DISPLAY_NAME_KEY, null)?.ifBlank { null } + val userId = legacyPrefs.getString(LEGACY_ID_STRING_KEY, null)?.ifBlank { null } + val clientId = legacyPrefs.getString(LEGACY_CLIENT_ID_KEY, null) ?: AuthSettings.DEFAULT_CLIENT_ID + + return currentData.copy( + oAuthKey = oAuthKey, + userName = userName, + displayName = displayName, + userId = userId, + clientId = clientId, + isLoggedIn = isLoggedIn, + ) + } + + override suspend fun cleanUp() { + legacyPrefs.edit { + remove(LEGACY_LOGGED_IN_KEY) + remove(LEGACY_OAUTH_KEY) + remove(LEGACY_NAME_KEY) + remove(LEGACY_DISPLAY_NAME_KEY) + remove(LEGACY_ID_STRING_KEY) + remove(LEGACY_CLIENT_ID_KEY) + } + } + } + + private val dataStore = + createDataStore( + fileName = "auth", + context = context, + defaultValue = AuthSettings(), + serializer = AuthSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(sharedPrefsMigration), + ) + + val settings = dataStore.safeData(AuthSettings()) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) + + private val persistScope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + + fun current() = currentSettings.value + + val isLoggedIn: Boolean get() = current().isLoggedIn + val oAuthKey: String? get() = current().oAuthKey + val userName: UserName? get() = current().userName?.toUserName() + val displayName: DisplayName? get() = current().displayName?.toDisplayName() + val userIdString: UserId? get() = current().userId?.toUserId() + val clientId: String get() = current().clientId + + suspend fun update(transform: suspend (AuthSettings) -> AuthSettings) { + runCatching { dataStore.updateData(transform) } + } + + /** Fire-and-forget update that survives caller cancellation (e.g. config change). */ + fun updateAsync(transform: suspend (AuthSettings) -> AuthSettings) { + persistScope.launch { update(transform) } + } + + suspend fun login( + oAuthKey: String, + userName: String, + userId: String, + clientId: String, + ) { + update { + it.copy( + oAuthKey = "oauth:$oAuthKey", + userName = userName, + userId = userId, + clientId = clientId, + isLoggedIn = true, + ) + } + } + + suspend fun clearLogin() { + update { + it.copy( + oAuthKey = null, + userName = null, + displayName = null, + userId = null, + clientId = AuthSettings.DEFAULT_CLIENT_ID, + isLoggedIn = false, + ) + } + } + + companion object { + private const val LEGACY_LOGGED_IN_KEY = "loggedIn" + private const val LEGACY_OAUTH_KEY = "oAuthKey" + private const val LEGACY_NAME_KEY = "nameKey" + private const val LEGACY_DISPLAY_NAME_KEY = "displayNameKey" + private const val LEGACY_ID_STRING_KEY = "idStringKey" + private const val LEGACY_CLIENT_ID_KEY = "clientIdKey" + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt new file mode 100644 index 000000000..b6000bc03 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthSettings.kt @@ -0,0 +1,17 @@ +package com.flxrs.dankchat.data.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class AuthSettings( + val oAuthKey: String? = null, + val userName: String? = null, + val displayName: String? = null, + val userId: String? = null, + val clientId: String = DEFAULT_CLIENT_ID, + val isLoggedIn: Boolean = false, +) { + companion object { + const val DEFAULT_CLIENT_ID = "xu7vd1i6tlr0ak45q1li2wdc0lrma8" + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt new file mode 100644 index 000000000..c07e8e208 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/AuthStateCoordinator.kt @@ -0,0 +1,161 @@ +package com.flxrs.dankchat.data.auth + +import android.webkit.CookieManager +import android.webkit.WebStorage +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.ApiException +import com.flxrs.dankchat.data.api.auth.AuthApiClient +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatConnector +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.emote.EmoteRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +sealed interface AuthEvent { + data class LoggedIn( + val userName: UserName, + ) : AuthEvent + + data class ScopesOutdated( + val userName: UserName, + ) : AuthEvent + + data object TokenInvalid : AuthEvent + + data object ValidationFailed : AuthEvent +} + +private val logger = KotlinLogging.logger("AuthStateCoordinator") + +@Single +class AuthStateCoordinator( + private val authDataStore: AuthDataStore, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, + private val channelDataCoordinator: ChannelDataCoordinator, + private val emoteRepository: EmoteRepository, + private val authApiClient: AuthApiClient, + private val ignoresRepository: IgnoresRepository, + private val userStateRepository: UserStateRepository, + private val emoteUsageRepository: EmoteUsageRepository, + private val startupValidationHolder: StartupValidationHolder, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.io) + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + init { + // React to login state changes — handles both login and logout. + // distinctUntilChangedBy on isLoggedIn+oAuthKey solves re-login (new token = new oAuthKey). + // drop(1) skips initial emission (startup connection handled by ConnectionCoordinator). + scope.launch { + authDataStore.settings + .distinctUntilChangedBy { it.isLoggedIn to it.oAuthKey } + .drop(1) + .collect { settings -> + when { + settings.isLoggedIn -> { + startupValidationHolder.update(StartupValidation.Validated) + chatConnector.closeAndReconnect(chatChannelProvider.channels.value.orEmpty()) + channelDataCoordinator.reloadGlobalData() + settings.userName?.let { name -> + _events.send(AuthEvent.LoggedIn(UserName(name))) + } + } + + else -> { + channelDataCoordinator.cancelGlobalLoading() + emoteRepository.clearTwitchEmotes() + userStateRepository.clear() + chatConnector.closeAndReconnect(chatChannelProvider.channels.value.orEmpty()) + } + } + } + } + } + + suspend fun validateOnStartup(): AuthEvent? { + if (!authDataStore.isLoggedIn) { + startupValidationHolder.update(StartupValidation.Validated) + return null + } + + val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return null + val result = + authApiClient.validateUser(token).fold( + onSuccess = { validateDto -> + // Update username from validation response + authDataStore.update { it.copy(userName = validateDto.login.value) } + when { + authApiClient.validateScopes(validateDto.scopes.orEmpty()) -> AuthEvent.LoggedIn(validateDto.login) + else -> AuthEvent.ScopesOutdated(validateDto.login) + } + }, + onFailure = { throwable -> + when { + throwable is ApiException && throwable.status == HttpStatusCode.Unauthorized -> { + AuthEvent.TokenInvalid + } + + else -> { + logger.error { "Failed to validate token: ${throwable.message}" } + AuthEvent.ValidationFailed + } + } + }, + ) + + startupValidationHolder.update( + when (result) { + is AuthEvent.LoggedIn, + is AuthEvent.ValidationFailed, + -> StartupValidation.Validated + + is AuthEvent.ScopesOutdated -> StartupValidation.ScopesOutdated(result.userName) + + AuthEvent.TokenInvalid -> StartupValidation.TokenInvalid + }, + ) + + // Only send snackbar-worthy events through the channel + when (result) { + is AuthEvent.LoggedIn, + is AuthEvent.ValidationFailed, + -> _events.send(result) + + else -> Unit + } + + return result + } + + fun logout() { + scope.launch { + CookieManager.getInstance().removeAllCookies(null) + WebStorage.getInstance().deleteAllData() + + userStateRepository.clear() + ignoresRepository.clearIgnores() + emoteUsageRepository.clearUsages() + + // Setting isLoggedIn = false triggers the settings observer which handles + // clearing twitch emotes and reconnecting anonymously. + authDataStore.clearLogin() + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt new file mode 100644 index 000000000..d078b3fdd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/auth/StartupValidationHolder.kt @@ -0,0 +1,44 @@ +package com.flxrs.dankchat.data.auth + +import com.flxrs.dankchat.data.UserName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +sealed interface StartupValidation { + data object Pending : StartupValidation + + data object Validated : StartupValidation + + data class ScopesOutdated( + val userName: UserName, + ) : StartupValidation + + data object TokenInvalid : StartupValidation +} + +@Single +class StartupValidationHolder { + private val _state = MutableStateFlow(StartupValidation.Pending) + val state: StateFlow = _state.asStateFlow() + + val isAuthAvailable: Boolean + get() { + val current = _state.value + return current is StartupValidation.Validated || current is StartupValidation.ScopesOutdated + } + + fun update(validation: StartupValidation) { + _state.value = validation + } + + fun acknowledge() { + _state.value = StartupValidation.Validated + } + + suspend fun awaitResolved() { + _state.first { it !is StartupValidation.Pending } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatImportance.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt similarity index 52% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/ChatImportance.kt rename to app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt index 227887b35..4fe52a14b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/ChatImportance.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatImportance.kt @@ -1,7 +1,7 @@ -package com.flxrs.dankchat.chat +package com.flxrs.dankchat.data.chat enum class ChatImportance { REGULAR, SYSTEM, - DELETED + DELETED, } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt new file mode 100644 index 000000000..f04d3c9f8 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/chat/ChatItem.kt @@ -0,0 +1,13 @@ +package com.flxrs.dankchat.data.chat + +import com.flxrs.dankchat.data.twitch.message.Message + +data class ChatItem( + val message: Message, + val tag: Int = 0, + val isMentionTab: Boolean = false, + val importance: ChatImportance = ChatImportance.REGULAR, + val isInReplies: Boolean = false, +) + +fun List.toMentionTabItems(): List = map { it.copy(isMentionTab = true) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/DankChatDatabase.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/DankChatDatabase.kt index 37d37b663..e0f199854 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/DankChatDatabase.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/DankChatDatabase.kt @@ -7,8 +7,24 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.flxrs.dankchat.data.database.converter.InstantConverter -import com.flxrs.dankchat.data.database.dao.* -import com.flxrs.dankchat.data.database.entity.* +import com.flxrs.dankchat.data.database.dao.BadgeHighlightDao +import com.flxrs.dankchat.data.database.dao.BlacklistedUserDao +import com.flxrs.dankchat.data.database.dao.EmoteUsageDao +import com.flxrs.dankchat.data.database.dao.MessageHighlightDao +import com.flxrs.dankchat.data.database.dao.MessageIgnoreDao +import com.flxrs.dankchat.data.database.dao.RecentUploadsDao +import com.flxrs.dankchat.data.database.dao.UserDisplayDao +import com.flxrs.dankchat.data.database.dao.UserHighlightDao +import com.flxrs.dankchat.data.database.dao.UserIgnoreDao +import com.flxrs.dankchat.data.database.entity.BadgeHighlightEntity +import com.flxrs.dankchat.data.database.entity.BlacklistedUserEntity +import com.flxrs.dankchat.data.database.entity.EmoteUsageEntity +import com.flxrs.dankchat.data.database.entity.MessageHighlightEntity +import com.flxrs.dankchat.data.database.entity.MessageIgnoreEntity +import com.flxrs.dankchat.data.database.entity.UploadEntity +import com.flxrs.dankchat.data.database.entity.UserDisplayEntity +import com.flxrs.dankchat.data.database.entity.UserHighlightEntity +import com.flxrs.dankchat.data.database.entity.UserIgnoreEntity @Database( version = 7, @@ -35,22 +51,31 @@ import com.flxrs.dankchat.data.database.entity.* @TypeConverters(InstantConverter::class) abstract class DankChatDatabase : RoomDatabase() { abstract fun badgeHighlightDao(): BadgeHighlightDao + abstract fun emoteUsageDao(): EmoteUsageDao + abstract fun recentUploadsDao(): RecentUploadsDao + abstract fun userDisplayDao(): UserDisplayDao + abstract fun messageHighlightDao(): MessageHighlightDao + abstract fun userHighlightDao(): UserHighlightDao + abstract fun userIgnoreDao(): UserIgnoreDao + abstract fun messageIgnoreDao(): MessageIgnoreDao + abstract fun blacklistedUserDao(): BlacklistedUserDao companion object { - val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE user_highlight ADD COLUMN create_notification INTEGER DEFAULT 1 NOT NUll") - db.execSQL("ALTER TABLE message_highlight ADD COLUMN create_notification INTEGER DEFAULT 0 NOT NUll") - db.execSQL("UPDATE message_highlight SET create_notification=1 WHERE type = 'Username' OR type = 'Custom'") + val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE user_highlight ADD COLUMN create_notification INTEGER DEFAULT 1 NOT NUll") + db.execSQL("ALTER TABLE message_highlight ADD COLUMN create_notification INTEGER DEFAULT 0 NOT NUll") + db.execSQL("UPDATE message_highlight SET create_notification=1 WHERE type = 'Username' OR type = 'Custom'") + } } - } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/converter/InstantConverter.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/converter/InstantConverter.kt index 7c9719780..93e8c58cd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/converter/InstantConverter.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/converter/InstantConverter.kt @@ -4,10 +4,9 @@ import androidx.room.TypeConverter import java.time.Instant object InstantConverter { - @TypeConverter fun fromTimestamp(value: Long): Instant = Instant.ofEpochMilli(value) @TypeConverter fun instantToTimestamp(value: Instant): Long = value.toEpochMilli() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt index 43a58472b..8d44df527 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BadgeHighlightDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface BadgeHighlightDao { - @Query("SELECT * FROM badge_highlight WHERE id = :id") suspend fun getBadgeHighlight(id: Long): BadgeHighlightEntity diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BlacklistedUserDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BlacklistedUserDao.kt index 0c6e9ecdb..be711a990 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BlacklistedUserDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/BlacklistedUserDao.kt @@ -26,4 +26,4 @@ interface BlacklistedUserDao { @Query("DELETE FROM blacklisted_user_highlight") suspend fun deleteAllBlacklistedUsers() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt index 03f7e5a9d..c6d43c334 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/EmoteUsageDao.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface EmoteUsageDao { - @Query("SELECT * FROM emote_usage ORDER BY last_used DESC LIMIT $RECENT_EMOTE_USAGE_LIMIT") fun getRecentUsages(): Flow> @@ -22,6 +21,6 @@ interface EmoteUsageDao { suspend fun deleteOldUsages() companion object { - private const val RECENT_EMOTE_USAGE_LIMIT = 30 + private const val RECENT_EMOTE_USAGE_LIMIT = 60 } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageHighlightDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageHighlightDao.kt index fd5487ad3..8ec7154ae 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageHighlightDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageHighlightDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface MessageHighlightDao { - @Query("SELECT * FROM message_highlight WHERE id = :id") suspend fun getMessageHighlight(id: Long): MessageHighlightEntity @@ -30,4 +29,4 @@ interface MessageHighlightDao { @Query("DELETE FROM message_highlight") suspend fun deleteAllHighlights() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageIgnoreDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageIgnoreDao.kt index decd180bc..cca23ed78 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageIgnoreDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/MessageIgnoreDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface MessageIgnoreDao { - @Query("SELECT * FROM message_ignore WHERE id = :id") suspend fun getMessageIgnore(id: Long): MessageIgnoreEntity @@ -30,4 +29,4 @@ interface MessageIgnoreDao { @Query("DELETE FROM message_ignore") suspend fun deleteAllIgnores() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/RecentUploadsDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/RecentUploadsDao.kt index 741112e95..15f6bee3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/RecentUploadsDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/RecentUploadsDao.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface RecentUploadsDao { - @Query("SELECT * FROM upload ORDER BY timestamp DESC LIMIT $RECENT_UPLOADS_LIMIT") fun getRecentUploads(): Flow> @@ -21,4 +20,4 @@ interface RecentUploadsDao { companion object { private const val RECENT_UPLOADS_LIMIT = 100 } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserDisplayDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserDisplayDao.kt index 39c721dfb..8e22b8c3e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserDisplayDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserDisplayDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface UserDisplayDao { - @Query("SELECT * from user_display") fun getUserDisplaysFlow(): Flow> diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserHighlightDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserHighlightDao.kt index fc4a3075c..9ae9aa739 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserHighlightDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserHighlightDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface UserHighlightDao { - @Query("SELECT * FROM user_highlight WHERE id = :id") suspend fun getUserHighlight(id: Long): UserHighlightEntity @@ -30,4 +29,4 @@ interface UserHighlightDao { @Query("DELETE FROM user_highlight") suspend fun deleteAllHighlights() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserIgnoreDao.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserIgnoreDao.kt index 9c1bc7b43..ef71ae3a6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserIgnoreDao.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/dao/UserIgnoreDao.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface UserIgnoreDao { - @Query("SELECT * FROM blacklisted_user WHERE id = :id") suspend fun getUserIgnore(id: Long): UserIgnoreEntity @@ -27,4 +26,4 @@ interface UserIgnoreDao { @Query("DELETE FROM blacklisted_user") suspend fun deleteAllIgnores() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt index cfdc17292..907407022 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BadgeHighlightEntity.kt @@ -11,10 +11,8 @@ data class BadgeHighlightEntity( val enabled: Boolean, val badgeName: String, val isCustom: Boolean, - @ColumnInfo(name = "create_notification") val createNotification: Boolean = false, - @ColumnInfo(name = "custom_color") - val customColor: Int? = null + val customColor: Int? = null, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BlacklistedUserEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BlacklistedUserEntity.kt index 6b60acbad..2eade24ff 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BlacklistedUserEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/BlacklistedUserEntity.kt @@ -1,10 +1,12 @@ package com.flxrs.dankchat.data.database.entity -import android.util.Log import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger("BlacklistedUserEntity") @Entity(tableName = "blacklisted_user_highlight") data class BlacklistedUserEntity( @@ -12,22 +14,16 @@ data class BlacklistedUserEntity( val id: Long, val enabled: Boolean, val username: String, - @ColumnInfo(name = "is_regex") - val isRegex: Boolean = false + val isRegex: Boolean = false, ) { - @delegate:Ignore val regex: Regex? by lazy { runCatching { username.toRegex(RegexOption.IGNORE_CASE) }.getOrElse { - Log.e(TAG, "Failed to create regex for username $username", it) + logger.error(it) { "Failed to create regex for username $username" } null } } - - companion object { - private val TAG = BlacklistedUserEntity::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/EmoteUsageEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/EmoteUsageEntity.kt index 510dff44d..4e3a912aa 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/EmoteUsageEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/EmoteUsageEntity.kt @@ -10,7 +10,6 @@ data class EmoteUsageEntity( @PrimaryKey @ColumnInfo(name = "emote_id") val emoteId: String, - @ColumnInfo(name = "last_used") val lastUsed: Instant, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageHighlightEntity.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageHighlightEntity.kt index 267b8f123..397ac56bc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageHighlightEntity.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/database/entity/MessageHighlightEntity.kt @@ -1,10 +1,12 @@ package com.flxrs.dankchat.data.database.entity -import android.util.Log import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger("MessageHighlightEntity") @Entity(tableName = "message_highlight") data class MessageHighlightEntity( @@ -13,7 +15,6 @@ data class MessageHighlightEntity( val enabled: Boolean, val type: MessageHighlightEntityType, val pattern: String, - @ColumnInfo(name = "is_regex") val isRegex: Boolean = false, @ColumnInfo(name = "is_case_sensitive") @@ -23,28 +24,23 @@ data class MessageHighlightEntity( @ColumnInfo(name = "custom_color") val customColor: Int? = null, ) { - @delegate:Ignore val regex: Regex? by lazy { runCatching { - val options = when { - isCaseSensitive -> emptySet() - else -> setOf(RegexOption.IGNORE_CASE) - } + val options = + when { + isCaseSensitive -> emptySet() + else -> setOf(RegexOption.IGNORE_CASE) + } when { isRegex -> pattern.toRegex(options) - else -> """(? """(? emptySet() - else -> setOf(RegexOption.IGNORE_CASE) - } + val options = + when { + isCaseSensitive -> emptySet() + else -> setOf(RegexOption.IGNORE_CASE) + } when { isRegex -> pattern.toRegex(options) - else -> """(? """(? emptySet() - else -> setOf(RegexOption.IGNORE_CASE) - } + val options = + when { + isCaseSensitive -> emptySet() + else -> setOf(RegexOption.IGNORE_CASE) + } username.toRegex(options) }.getOrElse { - Log.e(TAG, "Failed to create regex for username $username", it) + logger.error(it) { "Failed to create regex for username $username" } null } } - - companion object { - private val TAG = UserIgnoreEntity::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt new file mode 100644 index 000000000..58c1d4dbc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ApiDebugSection.kt @@ -0,0 +1,38 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.api.helix.HelixApiStats +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import org.koin.core.annotation.Single + +@Single +class ApiDebugSection( + private val helixApiStats: HelixApiStats, +) : DebugSection { + override val order = 10 + override val baseTitle = "API" + + override fun entries(): Flow { + val ticker = + flow { + while (true) { + emit(Unit) + delay(2_000) + } + } + return combine(ticker) { + val statusCounts = + helixApiStats.statusCounts + .entries + .sortedBy { entry -> entry.key } + .map { (code, count) -> DebugEntry("HTTP $code", "$count") } + + DebugSectionSnapshot( + title = baseTitle, + entries = listOf(DebugEntry("Total Helix requests", "${helixApiStats.totalRequests}")) + statusCounts, + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt new file mode 100644 index 000000000..3fae5dec0 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AppDebugSection.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.data.debug + +import android.os.Debug +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +import java.util.Locale + +@Single +class AppDebugSection : DebugSection { + override val order = 11 + override val baseTitle = "App" + + override fun entries(): Flow { + val ticker = + flow { + while (true) { + emit(Unit) + delay(2_000) + } + } + return ticker.map { + val runtime = Runtime.getRuntime() + val heapUsed = runtime.totalMemory() - runtime.freeMemory() + val heapMax = runtime.maxMemory() + val nativeAllocated = Debug.getNativeHeapAllocatedSize() + val nativeTotal = Debug.getNativeHeapSize() + + val memInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(memInfo) + val graphicsKb = memInfo.getMemoryStat("summary.graphics")?.toLongOrNull() ?: 0L + val codeKb = memInfo.getMemoryStat("summary.code")?.toLongOrNull() ?: 0L + val stackKb = memInfo.getMemoryStat("summary.stack")?.toLongOrNull() ?: 0L + val privateOtherKb = memInfo.getMemoryStat("summary.private-other")?.toLongOrNull() ?: 0L + val totalPssKb = memInfo.getMemoryStat("summary.total-pss")?.toLongOrNull() ?: 0L + + val graphicsBytes = graphicsKb * 1024L + val totalAppMemory = heapUsed + nativeAllocated + graphicsBytes + + DebugSectionSnapshot( + title = baseTitle, + entries = + buildList { + add(DebugEntry("Total app memory", formatBytes(totalAppMemory))) + add(DebugEntry("JVM heap", "${formatBytes(heapUsed)} / ${formatBytes(heapMax)}")) + add(DebugEntry("Native heap", "${formatBytes(nativeAllocated)} / ${formatBytes(nativeTotal)}")) + add(DebugEntry("Graphics", formatBytes(graphicsBytes))) + add(DebugEntry("Code", formatKb(codeKb))) + add(DebugEntry("Stack", formatKb(stackKb))) + add(DebugEntry("Other", formatKb(privateOtherKb))) + add(DebugEntry("Total PSS", formatKb(totalPssKb))) + add(DebugEntry("Threads", "${Thread.activeCount()}")) + }, + ) + } + } + + private fun formatBytes(bytes: Long): String { + val mb = bytes / (1024.0 * 1024.0) + return "%.1f MB".format(Locale.ROOT, mb) + } + + private fun formatKb(kb: Long): String { + val mb = kb / 1024.0 + return "%.1f MB".format(Locale.ROOT, mb) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt new file mode 100644 index 000000000..9962e5be6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/AuthDebugSection.kt @@ -0,0 +1,33 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class AuthDebugSection( + private val authDataStore: AuthDataStore, +) : DebugSection { + override val order = 2 + override val baseTitle = "Auth" + + override fun entries(): Flow = authDataStore.settings.map { auth -> + val tokenPreview = + auth.oAuthKey + ?.withoutOAuthPrefix + ?.take(8) + ?.let { "$it..." } + ?: "N/A" + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("Logged in as", auth.userName ?: "Not logged in"), + DebugEntry("User ID", auth.userId ?: "N/A", copyValue = auth.userId), + DebugEntry("Token", tokenPreview, copyValue = auth.oAuthKey?.withoutOAuthPrefix), + ), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt new file mode 100644 index 000000000..cfeca0674 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/BuildDebugSection.kt @@ -0,0 +1,23 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.BuildConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.koin.core.annotation.Single + +@Single +class BuildDebugSection : DebugSection { + override val order = 0 + override val baseTitle = "Build" + + override fun entries(): Flow = flowOf( + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("Version", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"), + DebugEntry("Build type", BuildConfig.BUILD_TYPE), + ), + ), + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt new file mode 100644 index 000000000..10c5ee468 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ChannelDebugSection.kt @@ -0,0 +1,49 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class ChannelDebugSection( + private val chatChannelProvider: ChatChannelProvider, + private val chatMessageRepository: ChatMessageRepository, + private val channelRepository: ChannelRepository, +) : DebugSection { + override val order = 4 + override val baseTitle = "Channel" + + override fun entries(): Flow = chatChannelProvider.activeChannel.flatMapLatest { channel -> + when (channel) { + null -> { + flowOf(DebugSectionSnapshot(title = baseTitle, entries = listOf(DebugEntry("Channel", "None")))) + } + + else -> { + chatMessageRepository.getChat(channel).map { messages -> + val roomState = channelRepository.getRoomState(channel) + val entries = + buildList { + add(DebugEntry("Messages in buffer", "${messages.size} / ${chatMessageRepository.scrollBackLength}")) + when (roomState) { + null -> { + add(DebugEntry("Room state", "Unknown")) + } + + else -> { + val display = roomState.toDebugText() + add(DebugEntry("Room state", display.ifEmpty { "None" })) + } + } + } + DebugSectionSnapshot(title = "$baseTitle (${channel.value})", entries = entries) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt new file mode 100644 index 000000000..78ade943b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ConnectionDebugSection.kt @@ -0,0 +1,75 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.api.eventapi.EventSubClient +import com.flxrs.dankchat.data.api.eventapi.EventSubClientState +import com.flxrs.dankchat.data.api.seventv.eventapi.SevenTVEventApiClient +import com.flxrs.dankchat.data.twitch.chat.ChatConnection +import com.flxrs.dankchat.data.twitch.pubsub.PubSubManager +import com.flxrs.dankchat.di.READ_CONNECTION +import com.flxrs.dankchat.di.WRITE_CONNECTION +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single + +@Single +class ConnectionDebugSection( + @Named(READ_CONNECTION) private val readConnection: ChatConnection, + @Named(WRITE_CONNECTION) private val writeConnection: ChatConnection, + private val eventSubClient: EventSubClient, + private val pubSubManager: PubSubManager, + private val sevenTVEventApiClient: SevenTVEventApiClient, +) : DebugSection { + override val order = 3 + override val baseTitle = "Connection" + + override fun entries(): Flow { + val ticker = + flow { + while (true) { + emit(Unit) + delay(2_000) + } + } + return combine(eventSubClient.state, eventSubClient.topics, readConnection.connected, writeConnection.connected, ticker) { state, topics, ircRead, ircWrite, _ -> + val eventSubStatus = + when (state) { + is EventSubClientState.Connected -> "Connected (${state.sessionId.take(8)}...)" + is EventSubClientState.Connecting -> "Connecting" + is EventSubClientState.Disconnected -> "Disconnected" + is EventSubClientState.Failed -> "Failed" + } + + val pubSubStatus = + when { + pubSubManager.connected -> "Connected" + else -> "Disconnected" + } + + val sevenTvStatus = sevenTVEventApiClient.status() + val sevenTvText = + when { + sevenTvStatus.connected -> "Connected (${sevenTvStatus.subscriptionCount} subs)" + else -> "Disconnected" + } + + val ircReadStatus = if (ircRead) "Connected" else "Disconnected" + val ircWriteStatus = if (ircWrite) "Connected" else "Disconnected" + + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("IRC (read)", ircReadStatus), + DebugEntry("IRC (write)", ircWriteStatus), + DebugEntry("PubSub", pubSubStatus), + DebugEntry("EventSub", eventSubStatus), + DebugEntry("EventSub topics", "${topics.size}"), + DebugEntry("7TV EventAPI", sevenTvText), + ), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt new file mode 100644 index 000000000..2519c457c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSection.kt @@ -0,0 +1,21 @@ +package com.flxrs.dankchat.data.debug + +import kotlinx.coroutines.flow.Flow + +interface DebugSection { + val baseTitle: String + val order: Int + + fun entries(): Flow +} + +data class DebugSectionSnapshot( + val title: String, + val entries: List, +) + +data class DebugEntry( + val label: String, + val value: String, + val copyValue: String? = null, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt new file mode 100644 index 000000000..a49876cf1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/DebugSectionRegistry.kt @@ -0,0 +1,18 @@ +package com.flxrs.dankchat.data.debug + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import org.koin.core.annotation.Single + +@Single +class DebugSectionRegistry( + sections: List, +) { + private val sorted = sections.sortedBy { it.order } + + fun allSections(): Flow> { + if (sorted.isEmpty()) return flowOf(emptyList()) + return combine(sorted.map { it.entries() }) { it.toList() } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt new file mode 100644 index 000000000..4404dfd72 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/EmoteDebugSection.kt @@ -0,0 +1,62 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.emote.EmojiRepository +import com.flxrs.dankchat.data.repo.emote.EmoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class EmoteDebugSection( + private val emoteRepository: EmoteRepository, + private val emojiRepository: EmojiRepository, + private val chatChannelProvider: ChatChannelProvider, +) : DebugSection { + override val order = 6 + override val baseTitle = "Emotes" + + override fun entries(): Flow = combine( + chatChannelProvider.activeChannel.flatMapLatest { channel -> + when (channel) { + null -> flowOf(null) + else -> emoteRepository.getEmotes(channel).map { channel to it } + } + }, + emojiRepository.emojis, + ) { channelEmotes, emojis -> + val (channel, emotes) = channelEmotes ?: (null to null) + val channelSuffix = channel?.let { " (${it.value})" }.orEmpty() + when (emotes) { + null -> { + DebugSectionSnapshot( + title = "$baseTitle$channelSuffix", + entries = listOf(DebugEntry("Emojis", "${emojis.size}")), + ) + } + + else -> { + val twitch = emotes.twitchEmotes.size + val ffz = emotes.ffzChannelEmotes.size + emotes.ffzGlobalEmotes.size + val bttv = emotes.bttvChannelEmotes.size + emotes.bttvGlobalEmotes.size + val sevenTv = emotes.sevenTvChannelEmotes.size + emotes.sevenTvGlobalEmotes.size + val total = twitch + ffz + bttv + sevenTv + DebugSectionSnapshot( + title = "$baseTitle$channelSuffix", + entries = + listOf( + DebugEntry("Twitch", "$twitch"), + DebugEntry("FFZ", "$ffz"), + DebugEntry("BTTV", "$bttv"), + DebugEntry("7TV", "$sevenTv"), + DebugEntry("Total emotes", "$total"), + DebugEntry("Emojis", "${emojis.size}"), + ), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt new file mode 100644 index 000000000..16f04c940 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/ErrorsDebugSection.kt @@ -0,0 +1,31 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.data.DataRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.Single + +@Single +class ErrorsDebugSection( + private val dataRepository: DataRepository, + private val chatMessageRepository: ChatMessageRepository, +) : DebugSection { + override val order = 9 + override val baseTitle = "Errors" + + override fun entries(): Flow = combine(dataRepository.dataLoadingFailures, chatMessageRepository.chatLoadingFailures) { dataFailures, chatFailures -> + val totalFailures = dataFailures.size + chatFailures.size + val entries = + buildList { + add(DebugEntry("Total failures", "$totalFailures")) + dataFailures.forEach { failure -> + add(DebugEntry(failure.step::class.simpleName ?: "Unknown", failure.failure.message ?: "Unknown error")) + } + chatFailures.forEach { failure -> + add(DebugEntry(failure.step::class.simpleName ?: "Unknown", failure.failure.message ?: "Unknown error")) + } + } + DebugSectionSnapshot(title = baseTitle, entries = entries) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt new file mode 100644 index 000000000..edd904ec6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/RulesDebugSection.kt @@ -0,0 +1,36 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.HighlightsRepository +import com.flxrs.dankchat.data.repo.IgnoresRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.Single + +@Single +class RulesDebugSection( + private val highlightsRepository: HighlightsRepository, + private val ignoresRepository: IgnoresRepository, +) : DebugSection { + override val order = 8 + override val baseTitle = "Rules" + + override fun entries(): Flow = combine( + highlightsRepository.messageHighlights, + highlightsRepository.userHighlights, + highlightsRepository.badgeHighlights, + highlightsRepository.blacklistedUsers, + ignoresRepository.messageIgnores, + ) { msgHighlights, userHighlights, badgeHighlights, blacklisted, msgIgnores -> + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("Message highlights", "${msgHighlights.size}"), + DebugEntry("User highlights", "${userHighlights.size}"), + DebugEntry("Badge highlights", "${badgeHighlights.size}"), + DebugEntry("Blacklisted users", "${blacklisted.size}"), + DebugEntry("Message ignores", "${msgIgnores.size}"), + ), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt new file mode 100644 index 000000000..0147994c5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/SessionDebugSection.kt @@ -0,0 +1,59 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import org.koin.core.annotation.Single +import kotlin.time.TimeSource + +@Single +class SessionDebugSection( + private val chatMessageRepository: ChatMessageRepository, + private val chatChannelProvider: ChatChannelProvider, + private val developerSettingsDataStore: DeveloperSettingsDataStore, +) : DebugSection { + private val startMark = TimeSource.Monotonic.markNow() + + override val order = 1 + override val baseTitle = "Session" + + override fun entries(): Flow { + val ticker = + flow { + while (true) { + emit(Unit) + delay(1_000) + } + } + return combine(ticker, chatChannelProvider.channels) { _, channels -> + val elapsed = startMark.elapsedNow() + val hours = elapsed.inWholeHours + val minutes = elapsed.inWholeMinutes % 60 + val seconds = elapsed.inWholeSeconds % 60 + val uptime = + buildString { + if (hours > 0) append("${hours}h ") + if (minutes > 0 || hours > 0) append("${minutes}m ") + append("${seconds}s") + } + + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("Uptime", uptime), + DebugEntry("Send protocol", developerSettingsDataStore.current().chatSendProtocol.name), + DebugEntry("Total messages received", "${chatMessageRepository.sessionMessageCount}"), + DebugEntry("Messages sent (IRC)", "${chatMessageRepository.ircSentCount}"), + DebugEntry("Messages sent (Helix)", "${chatMessageRepository.helixSentCount}"), + DebugEntry("Send failures", "${chatMessageRepository.sendFailureCount}"), + DebugEntry("Active channels", "${channels?.size ?: 0}"), + ), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt new file mode 100644 index 000000000..291d4de55 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/StreamDebugSection.kt @@ -0,0 +1,44 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.utils.DateTimeUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.Single + +@Single +class StreamDebugSection( + private val streamDataRepository: StreamDataRepository, + private val chatChannelProvider: ChatChannelProvider, +) : DebugSection { + override val order = 5 + override val baseTitle = "Stream" + + override fun entries(): Flow = combine(chatChannelProvider.activeChannel, streamDataRepository.streamData) { channel, streams -> + val channelSuffix = channel?.let { " (${it.value})" }.orEmpty() + val stream = channel?.let { ch -> streams.find { it.channel == ch } } + when (stream) { + null -> { + DebugSectionSnapshot( + title = "$baseTitle$channelSuffix", + entries = listOf(DebugEntry("Status", "Offline")), + ) + } + + else -> { + DebugSectionSnapshot( + title = "$baseTitle$channelSuffix", + entries = + listOf( + DebugEntry("Status", "Live"), + DebugEntry("Viewers", "${stream.viewerCount}"), + DebugEntry("Uptime", DateTimeUtils.calculateUptime(stream.startedAt)), + DebugEntry("Category", stream.category?.ifBlank { null } ?: "None"), + DebugEntry("Stream fetches", "${streamDataRepository.fetchCount}"), + ), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt new file mode 100644 index 000000000..07676ce69 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/debug/UserStateDebugSection.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.data.debug + +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class UserStateDebugSection( + private val userStateRepository: UserStateRepository, +) : DebugSection { + override val order = 7 + override val baseTitle = "User State" + + override fun entries(): Flow = userStateRepository.userState.map { state -> + DebugSectionSnapshot( + title = baseTitle, + entries = + listOf( + DebugEntry("Mod in", state.moderationChannels.joinToString().ifEmpty { "None" }), + DebugEntry("VIP in", state.vipChannels.joinToString().ifEmpty { "None" }), + ), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/irc/IrcMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/irc/IrcMessage.kt index 826a45dfb..9d436c8f5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/irc/IrcMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/irc/IrcMessage.kt @@ -7,14 +7,53 @@ data class IrcMessage( val prefix: String, val command: String, val params: List = listOf(), - val tags: Map = mapOf() + val tags: Map = mapOf(), ) { - - fun isLoginFailed(): Boolean { - return command == "NOTICE" && params.getOrNull(0) == "*" && params.getOrNull(1) == "Login authentication failed" - } + fun isLoginFailed(): Boolean = command == "NOTICE" && params.getOrNull(0) == "*" && params.getOrNull(1) == "Login authentication failed" companion object { + private fun unescapeIrcTagValue(value: String): String { + val idx = value.indexOf('\\') + if (idx == -1) return value // fast path: no escapes (most values) + + return buildString(value.length) { + var i = 0 + while (i < value.length) { + if (value[i] == '\\' && i + 1 < value.length) { + when (value[i + 1]) { + ':' -> { + append(';') + } + + 's' -> { + append(' ') + } + + 'r' -> { + append('\r') + } + + 'n' -> { + append('\n') + } + + '\\' -> { + append('\\') + } + + else -> { + append(value[i]) + append(value[i + 1]) + } + } + i += 2 + } else { + append(value[i]) + i++ + } + } + } + } fun parse(message: String): IrcMessage { var pos = 0 @@ -28,7 +67,7 @@ data class IrcMessage( while (message[pos] == ' ') pos++ } - //tags + // tags if (message[pos] == '@') { nextSpace = message.indexOf(' ') @@ -36,29 +75,30 @@ data class IrcMessage( throw ParseException("Malformed IRC message", pos) } - tags.putAll( - message - .substring(1, nextSpace) - .split(';') - .associate { - val kv = it.split('=') - val v = when (kv.size) { - 2 -> kv[1].replace("\\:", ";") - .replace("\\s", " ") - .replace("\\r", "\r") - .replace("\\n", "\n") - .replace("\\\\", "\\") - - else -> "true" - } - kv[0] to v - }) + // Index-based tag parsing: walk the tag section without split() allocations + var tagStart = 1 // skip '@' + while (tagStart < nextSpace) { + val semiIdx = message.indexOf(';', tagStart) + val tagEnd = if (semiIdx == -1 || semiIdx > nextSpace) nextSpace else semiIdx + + val eqIdx = message.indexOf('=', tagStart) + if (eqIdx != -1 && eqIdx < tagEnd) { + val key = message.substring(tagStart, eqIdx) + val rawValue = message.substring(eqIdx + 1, tagEnd) + tags[key] = unescapeIrcTagValue(rawValue) + } else { + val key = message.substring(tagStart, tagEnd) + tags[key] = "true" + } + + tagStart = tagEnd + 1 + } pos = nextSpace + 1 } skipTrailingWhitespace() - //prefix + // prefix if (message[pos] == ':') { nextSpace = message.indexOf(' ', pos) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt new file mode 100644 index 000000000..ce84745db --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/ChatTTSPlayer.kt @@ -0,0 +1,258 @@ +package com.flxrs.dankchat.data.notification + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Bundle +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import androidx.core.content.getSystemService +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.data.twitch.message.NoticeMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.tools.TTSMessageFormat +import com.flxrs.dankchat.preferences.tools.TTSPlayMode +import com.flxrs.dankchat.preferences.tools.ToolsSettings +import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import com.flxrs.dankchat.utils.AppLifecycleListener +import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import java.util.Locale + +@Single +class ChatTTSPlayer( + private val context: Context, + private val chatNotificationRepository: ChatNotificationRepository, + private val chatChannelProvider: ChatChannelProvider, + private val toolsSettingsDataStore: ToolsSettingsDataStore, + private val appLifecycleListener: AppLifecycleListener, + private val dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + + private var tts: TextToSpeech? = null + private var audioManager: AudioManager? = null + private var previousTTSUser: UserName? = null + private var audioFocusRequest: AudioFocusRequest? = null + private var utteranceId = 0 + + fun start() { + scope.launch { + appLifecycleListener.appState + .flatMapLatest { state -> + when (state) { + AppLifecycle.Foreground -> { + combine( + chatNotificationRepository.messageUpdates, + toolsSettingsDataStore.settings, + chatChannelProvider.activeChannel, + ) { items, settings, activeChannel -> + Triple(items, settings, activeChannel) + } + } + + AppLifecycle.Background -> { + shutdownTTS() + emptyFlow() + } + } + }.collect { (items, settings, activeChannel) -> + ensureTTSState(settings) + items.forEach { (message) -> + processMessage(message, settings, activeChannel) + } + } + } + } + + private fun ensureTTSState(settings: ToolsSettings) { + when { + settings.ttsEnabled && tts == null -> { + audioManager = context.getSystemService() + tts = TextToSpeech(context) { status -> + when (status) { + TextToSpeech.SUCCESS -> { + applyVoice(settings.ttsForceEnglish) + tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) = Unit + + override fun onDone(utteranceId: String?) = abandonAudioFocus() + + @Suppress("OVERRIDE_DEPRECATION") + override fun onError(utteranceId: String?) = Unit + + override fun onError( + utteranceId: String?, + errorCode: Int, + ) = abandonAudioFocus() + }) + } + + else -> { + shutdownTTS() + scope.launch { + toolsSettingsDataStore.update { it.copy(ttsEnabled = false) } + } + } + } + } + } + + !settings.ttsEnabled && tts != null -> { + shutdownTTS() + } + } + } + + private fun applyVoice(forceEnglish: Boolean) { + val voice = when { + forceEnglish -> tts?.voices?.find { it.locale == Locale.US && !it.isNetworkConnectionRequired } + else -> tts?.defaultVoice + } + + if (voice == null || tts?.setVoice(voice) == TextToSpeech.ERROR) { + shutdownTTS() + scope.launch { + toolsSettingsDataStore.update { it.copy(ttsEnabled = false) } + } + } + } + + private fun shutdownTTS() { + abandonAudioFocus() + tts?.shutdown() + tts = null + previousTTSUser = null + audioManager = null + } + + private fun processMessage( + message: Message, + settings: ToolsSettings, + activeChannel: UserName?, + ) { + val channel = when (message) { + is PrivMessage -> message.channel + is UserNoticeMessage -> message.channel + is NoticeMessage -> message.channel + else -> return + } + + if (!settings.ttsEnabled || tts == null || channel != activeChannel) { + return + } + + if ((audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { + return + } + + if (message is PrivMessage && settings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { + return + } + + playTTSMessage(message, settings) + } + + private fun requestAudioFocus() { + val manager = audioManager ?: return + if (audioFocusRequest != null) return + + val attrs = AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + val request = AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(attrs) + .build() + + if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioFocusRequest = request + } + } + + private fun abandonAudioFocus() { + val request = audioFocusRequest ?: return + audioManager?.abandonAudioFocusRequest(request) + audioFocusRequest = null + } + + private fun playTTSMessage( + message: Message, + settings: ToolsSettings, + ) { + val text = when (message) { + is UserNoticeMessage -> { + message.message + } + + is NoticeMessage -> { + message.message + } + + is PrivMessage -> { + val filtered = message.message + .let { text -> + when { + settings.ttsIgnoreEmotes -> message.emotes.fold(text) { acc, emote -> acc.replace(emote.code, newValue = "", ignoreCase = true) } + else -> text + } + }.let { text -> + when { + settings.ttsIgnoreEmotes -> text.replace(UNICODE_SYMBOL_REGEX, replacement = "") + else -> text + } + }.let { text -> + when { + settings.ttsIgnoreUrls -> text.replace(URL_REGEX, replacement = "") + else -> text + } + } + + if (filtered.isBlank()) return + + when { + settings.ttsMessageFormat == TTSMessageFormat.Message || message.name == previousTTSUser -> filtered + tts?.voice?.locale?.language == Locale.ENGLISH.language -> "${message.name} said $filtered" + else -> "${message.name}. $filtered" + }.also { previousTTSUser = message.name } + } + + else -> { + return + } + } + + val queueMode = when (settings.ttsPlayMode) { + TTSPlayMode.Queue -> TextToSpeech.QUEUE_ADD + TTSPlayMode.Newest -> TextToSpeech.QUEUE_FLUSH + } + + if (settings.ttsAudioDucking) { + requestAudioFocus() + } + + val params = Bundle().apply { + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, settings.ttsVolume) + } + tts?.speak(text, queueMode, params, "tts_${utteranceId++}") + } + + companion object { + private val UNICODE_SYMBOL_REGEX = "\\p{So}|\\p{Sc}|\\p{Sm}|\\p{Cn}".toRegex() + private val URL_REGEX = "[(http(s)?):\\/\\/(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)".toRegex(RegexOption.IGNORE_CASE) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationData.kt index c3da15983..8cf1093f4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationData.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationData.kt @@ -1,7 +1,6 @@ package com.flxrs.dankchat.data.notification import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage @@ -21,14 +20,21 @@ fun Message.toNotificationData(): NotificationData? { } return when (this) { - is PrivMessage -> NotificationData(channel, name, originalMessage) - is WhisperMessage -> NotificationData( - channel = UserName.EMPTY, - name = name, - message = originalMessage, - isWhisper = true, - ) + is PrivMessage -> { + NotificationData(channel, name, originalMessage) + } - else -> null + is WhisperMessage -> { + NotificationData( + channel = UserName.EMPTY, + name = name, + message = originalMessage, + isWhisper = true, + ) + } + + else -> { + null + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt index 51ee2052e..71220954c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/notification/NotificationService.kt @@ -5,82 +5,68 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent -import android.media.AudioManager import android.os.Binder -import android.os.Build import android.os.IBinder -import android.speech.tts.TextToSpeech -import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import androidx.core.content.getSystemService import androidx.media.app.NotificationCompat.MediaStyle import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.data.twitch.message.Message -import com.flxrs.dankchat.data.twitch.message.NoticeMessage -import com.flxrs.dankchat.data.twitch.message.PrivMessage -import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage -import com.flxrs.dankchat.main.MainActivity +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore -import com.flxrs.dankchat.preferences.tools.TTSMessageFormat -import com.flxrs.dankchat.preferences.tools.TTSPlayMode -import com.flxrs.dankchat.preferences.tools.ToolsSettings -import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import com.flxrs.dankchat.ui.main.MainActivity +import com.flxrs.dankchat.utils.AppLifecycleListener +import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.util.collections.ConcurrentSet import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.koin.android.ext.android.inject -import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicInt import kotlin.coroutines.CoroutineContext -class NotificationService : Service(), CoroutineScope { +private val logger = KotlinLogging.logger("NotificationService") +class NotificationService : + Service(), + CoroutineScope { private val binder = LocalBinder() private val manager: NotificationManager by lazy { getSystemService(NOTIFICATION_SERVICE) as NotificationManager } - private var notificationsEnabled = false - private var toolSettings = ToolsSettings() + private val notifications = ConcurrentHashMap>() + private val notifiedMessageIds: MutableSet = ConcurrentSet() - private var notificationsJob: Job? = null - private val notifications = mutableMapOf>() - - private val chatRepository: ChatRepository by inject() + private val chatNotificationRepository: ChatNotificationRepository by inject() + private val chatChannelProvider: ChatChannelProvider by inject() private val dataRepository: DataRepository by inject() - private val toolsSettingsDataStore: ToolsSettingsDataStore by inject() private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() + private val appLifecycleListener: AppLifecycleListener by inject() + private val dispatchersProvider: DispatchersProvider by inject() - private var tts: TextToSpeech? = null - private var audioManager: AudioManager? = null - private var previousTTSUser: UserName? = null - - private val pendingIntentFlag: Int = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - else -> PendingIntent.FLAG_UPDATE_CURRENT - } - - private var activeTTSChannel: UserName? = null - private var shouldNotifyOnMention = false + private val pendingIntentFlag: Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + private val job = SupervisorJob() override val coroutineContext: CoroutineContext - get() = Dispatchers.IO + Job() + get() = dispatchersProvider.io + job - inner class LocalBinder(val service: NotificationService = this@NotificationService) : Binder() + inner class LocalBinder( + val service: NotificationService = this@NotificationService, + ) : Binder() override fun onBind(intent: Intent?): IBinder = binder override fun onDestroy() { coroutineContext.cancelChildren() manager.cancelAll() - shutdownTTS() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() @@ -89,50 +75,80 @@ class NotificationService : Service(), CoroutineScope { override fun onCreate() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = getString(R.string.app_name) - val channel = NotificationChannel(CHANNEL_ID_LOW, name, NotificationManager.IMPORTANCE_LOW).apply { + val name = getString(R.string.app_name) + val channel = + NotificationChannel(CHANNEL_ID_LOW, name, NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false) enableLights(false) setShowBadge(false) } - val mentionChannel = NotificationChannel(CHANNEL_ID_DEFAULT, "Mentions", NotificationManager.IMPORTANCE_DEFAULT) - manager.createNotificationChannel(mentionChannel) - manager.createNotificationChannel(channel) - } + val mentionChannel = NotificationChannel(CHANNEL_ID_DEFAULT, "Mentions", NotificationManager.IMPORTANCE_DEFAULT) + manager.createNotificationChannel(mentionChannel) + manager.createNotificationChannel(channel) + launch { + appLifecycleListener.appState + .flatMapLatest { state -> + when (state) { + AppLifecycle.Foreground -> { + notifiedMessageIds.clear() + val activeChannel = chatChannelProvider.activeChannel.value + if (activeChannel != null) { + clearNotificationsForChannel(activeChannel) + } + emptyFlow() + } + + AppLifecycle.Background -> { + combine( + chatNotificationRepository.messageUpdates, + notificationsSettingsDataStore.showNotifications, + ) { items, enabled -> items to enabled } + } + } + }.collect { (items, enabled) -> + if (!enabled) { + return@collect + } - notificationsSettingsDataStore.showNotifications - .onEach { notificationsEnabled = it } - .launchIn(this) - toolsSettingsDataStore.ttsEnabled - .onEach { setTTSEnabled(enabled = it) } - .launchIn(this) - toolsSettingsDataStore.ttsForceEnglishChanged - .onEach { setTTSVoice(forceEnglish = it) } - .launchIn(this) - toolsSettingsDataStore.settings - .onEach { toolSettings = it } - .launchIn(this) + items.forEach { (message) -> + if (!notifiedMessageIds.add(message.id)) { + return@forEach + } + if (notifiedMessageIds.size > MAX_NOTIFIED_IDS) { + val iterator = notifiedMessageIds.iterator() + iterator.next() + iterator.remove() + } + message.toNotificationData()?.createMentionNotification() + } + } + } } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { when (intent?.action) { STOP_COMMAND -> launch { dataRepository.sendShutdownCommand() } - else -> startForeground() + else -> startForeground() } return START_NOT_STICKY } - override fun onTimeout(startId: Int, fgsType: Int) { - Log.w(TAG, "Stopping foreground service due to 6h timeout restriction..") + override fun onTimeout( + startId: Int, + fgsType: Int, + ) { + logger.warn { "Stopping foreground service due to 6h timeout restriction.." } stopSelf() } - fun setActiveChannel(channel: UserName) { - activeTTSChannel = channel + fun clearNotificationsForChannel(channel: UserName) { val ids = notifications.remove(channel) ids?.forEach { manager.cancel(it) } @@ -142,202 +158,73 @@ class NotificationService : Service(), CoroutineScope { } } - fun enableNotifications() { - shouldNotifyOnMention = true - } - - private suspend fun setTTSEnabled(enabled: Boolean) = when { - enabled -> initTTS() - else -> shutdownTTS() - } - - private suspend fun initTTS() { - val forceEnglish = toolsSettingsDataStore.settings.first().ttsForceEnglish - audioManager = getSystemService() - tts = TextToSpeech(this) { status -> - when (status) { - TextToSpeech.SUCCESS -> setTTSVoice(forceEnglish = forceEnglish) - else -> shutdownAndDisableTTS() - } - } - } - - private fun setTTSVoice(forceEnglish: Boolean) { - val voice = when { - forceEnglish -> tts?.voices?.find { it.locale == Locale.US && !it.isNetworkConnectionRequired } - else -> tts?.defaultVoice - } - - voice?.takeUnless { tts?.setVoice(it) == TextToSpeech.ERROR } ?: shutdownAndDisableTTS() - } - - private fun shutdownAndDisableTTS() { - shutdownTTS() - launch { - toolsSettingsDataStore.update { it.copy(ttsEnabled = false) } - } - } - - private fun shutdownTTS() { - tts?.shutdown() - tts = null - previousTTSUser = null - audioManager = null - } - private fun startForeground() { val title = getString(R.string.notification_title) val message = getString(R.string.notification_message) - val pendingStartActivityIntent = Intent(this, MainActivity::class.java).let { - PendingIntent.getActivity(this, NOTIFICATION_START_INTENT_CODE, it, pendingIntentFlag) - } - - val pendingStopIntent = Intent(this, NotificationService::class.java).let { - it.action = STOP_COMMAND - PendingIntent.getService(this, NOTIFICATION_STOP_INTENT_CODE, it, pendingIntentFlag) - } - - val notification = NotificationCompat.Builder(this, CHANNEL_ID_LOW) - .setSound(null) - .setVibrate(null) - .setContentTitle(title) - .setContentText(message) - .addAction(R.drawable.ic_clear, getString(R.string.notification_stop), pendingStopIntent).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setStyle(MediaStyle().setShowActionsInCompactView(0)) - } + val pendingStartActivityIntent = + Intent(this, MainActivity::class.java).let { + PendingIntent.getActivity(this, NOTIFICATION_START_INTENT_CODE, it, pendingIntentFlag) } - .setContentIntent(pendingStartActivityIntent) - .setSmallIcon(R.drawable.ic_notification_icon) - .build() - - startForeground(NOTIFICATION_ID, notification) - } - fun checkForNotification() { - shouldNotifyOnMention = false - - notificationsJob?.cancel() - notificationsJob = launch { - chatRepository.notificationsFlow.collect { items -> - items.forEach { (message) -> - if (shouldNotifyOnMention && notificationsEnabled) { - val data = message.toNotificationData() - data?.createMentionNotification() - } - - if (!message.shouldPlayTTS()) { - return@forEach - } - - val channel = when (message) { - is PrivMessage -> message.channel - is UserNoticeMessage -> message.channel - is NoticeMessage -> message.channel - else -> return@forEach - } - - if (!toolSettings.ttsEnabled || channel != activeTTSChannel || (audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0) <= 0) { - return@forEach - } - - if (tts == null) { - initTTS() - } - - if (message is PrivMessage && toolSettings.ttsUserNameIgnores.any { it.matches(message.name) || it.matches(message.displayName) }) { - return@forEach - } - - message.playTTSMessage() - } - } - } - } - - private fun Message.shouldPlayTTS(): Boolean = this is PrivMessage || this is NoticeMessage || this is UserNoticeMessage - - private fun Message.playTTSMessage() { - val message = when (this) { - is UserNoticeMessage -> message - is NoticeMessage -> message - else -> { - if (this !is PrivMessage) return - val filtered = message - .filterEmotes(emotes) - .filterUnicodeSymbols() - .filterUrls() - - if (filtered.isBlank()) { - return - } - - when { - toolSettings.ttsMessageFormat == TTSMessageFormat.Message || name == previousTTSUser -> filtered - tts?.voice?.locale?.language == Locale.ENGLISH.language -> "$name said $filtered" - else -> "$name. $filtered" - }.also { previousTTSUser = name } + val pendingStopIntent = + Intent(this, NotificationService::class.java).let { + it.action = STOP_COMMAND + PendingIntent.getService(this, NOTIFICATION_STOP_INTENT_CODE, it, pendingIntentFlag) } - } - - val queueMode = when (toolSettings.ttsPlayMode) { - TTSPlayMode.Queue -> TextToSpeech.QUEUE_ADD - TTSPlayMode.Newest -> TextToSpeech.QUEUE_FLUSH - } - tts?.speak(message, queueMode, null, null) - } - - private fun String.filterEmotes(emotes: List): String = when { - toolSettings.ttsIgnoreEmotes -> emotes.fold(this) { acc, emote -> - acc.replace(emote.code, newValue = "", ignoreCase = true) - } - - else -> this - } - private fun String.filterUnicodeSymbols(): String = when { - // Replaces all unicode character that are: So - Symbol Other, Sc - Symbol Currency, Sm - Symbol Math, Cn - Unassigned. - // This will not filter out non latin script (Arabic and Japanese for example works fine.) - toolSettings.ttsIgnoreEmotes -> replace(UNICODE_SYMBOL_REGEX, replacement = "") - else -> this - } + val notification = + NotificationCompat + .Builder(this, CHANNEL_ID_LOW) + .setSound(null) + .setVibrate(null) + .setContentTitle(title) + .setContentText(message) + .addAction(R.drawable.ic_clear, getString(R.string.notification_stop), pendingStopIntent) + .setStyle(MediaStyle().setShowActionsInCompactView(0)) + .setContentIntent(pendingStartActivityIntent) + .setSmallIcon(R.drawable.ic_notification_icon) + .build() - private fun String.filterUrls(): String = when { - toolSettings.ttsIgnoreUrls -> replace(URL_REGEX, replacement = "") - else -> this + startForeground(NOTIFICATION_ID, notification) } private fun NotificationData.createMentionNotification() { - val pendingStartActivityIntent = Intent(this@NotificationService, MainActivity::class.java).let { - it.putExtra(MainActivity.OPEN_CHANNEL_KEY, channel) - PendingIntent.getActivity(this@NotificationService, notificationIntentCode, it, pendingIntentFlag) - } - - val summary = NotificationCompat.Builder(this@NotificationService, CHANNEL_ID_DEFAULT) - .setContentTitle(getString(R.string.notification_new_mentions)) - .setContentText("") - .setSmallIcon(R.drawable.ic_notification_icon) - .setGroup(MENTION_GROUP) - .setGroupSummary(true) - .setAutoCancel(true) - .build() - - val title = when { - isWhisper -> getString(R.string.notification_whisper_mention, name) - isNotify -> getString(R.string.notification_notify_mention, channel) - else -> getString(R.string.notification_mention, name, channel) - } + val pendingStartActivityIntent = + Intent(this@NotificationService, MainActivity::class.java).let { + it.putExtra(MainActivity.OPEN_CHANNEL_KEY, channel) + PendingIntent.getActivity(this@NotificationService, notificationIntentCode.fetchAndAdd(1), it, pendingIntentFlag) + } - val notification = NotificationCompat.Builder(this@NotificationService, CHANNEL_ID_DEFAULT) - .setContentTitle(title) - .setContentText(message) - .setContentIntent(pendingStartActivityIntent) - .setSmallIcon(R.drawable.ic_notification_icon) - .setGroup(MENTION_GROUP) - .build() + val summary = + NotificationCompat + .Builder(this@NotificationService, CHANNEL_ID_DEFAULT) + .setContentTitle(getString(R.string.notification_new_mentions)) + .setContentText("") + .setSmallIcon(R.drawable.ic_notification_icon) + .setGroup(MENTION_GROUP) + .setGroupSummary(true) + .setAutoCancel(true) + .build() + + val title = + when { + isWhisper -> getString(R.string.notification_whisper_mention, name) + isNotify -> getString(R.string.notification_notify_mention, channel) + else -> getString(R.string.notification_mention, name, channel) + } - val id = notificationId + val notification = + NotificationCompat + .Builder(this@NotificationService, CHANNEL_ID_DEFAULT) + .setContentTitle(title) + .setContentText(message) + .setContentIntent(pendingStartActivityIntent) + .setSmallIcon(R.drawable.ic_notification_icon) + .setGroup(MENTION_GROUP) + .build() + + val id = notificationId.fetchAndAdd(1) notifications.getOrPut(channel) { mutableListOf() } += id manager.notify(id, notification) @@ -345,8 +232,6 @@ class NotificationService : Service(), CoroutineScope { } companion object { - private val TAG = NotificationService::class.simpleName - private const val CHANNEL_ID_LOW = "com.flxrs.dankchat.dank_id" private const val CHANNEL_ID_DEFAULT = "com.flxrs.dankchat.very_dank_id" private const val NOTIFICATION_ID = 77777 @@ -356,12 +241,9 @@ class NotificationService : Service(), CoroutineScope { private const val MENTION_GROUP = "dank_group" private const val STOP_COMMAND = "STOP_DANKING" - private val UNICODE_SYMBOL_REGEX = "\\p{So}|\\p{Sc}|\\p{Sm}|\\p{Cn}".toRegex() - private val URL_REGEX = "[(http(s)?):\\/\\/(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)".toRegex(RegexOption.IGNORE_CASE) + private const val MAX_NOTIFIED_IDS = 500 - private var notificationId = 42 - get() = field++ - private var notificationIntentCode = 420 - get() = field++ + private val notificationId = AtomicInt(42) + private val notificationIntentCode = AtomicInt(420) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/HighlightsRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/HighlightsRepository.kt index 918a2cd88..cb2324805 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/HighlightsRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/HighlightsRepository.kt @@ -1,8 +1,5 @@ -@file:Suppress("DEPRECATION") - package com.flxrs.dankchat.data.repo -import android.util.Log import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.database.dao.BadgeHighlightDao @@ -26,9 +23,11 @@ import com.flxrs.dankchat.data.twitch.message.isElevatedMessage import com.flxrs.dankchat.data.twitch.message.isFirstMessage import com.flxrs.dankchat.data.twitch.message.isReward import com.flxrs.dankchat.data.twitch.message.isSub +import com.flxrs.dankchat.data.twitch.message.isViewerMilestone import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -38,71 +37,77 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Single +private val logger = KotlinLogging.logger("HighlightsRepository") + @Single class HighlightsRepository( private val messageHighlightDao: MessageHighlightDao, private val userHighlightDao: UserHighlightDao, private val badgeHighlightDao: BadgeHighlightDao, private val blacklistedUserDao: BlacklistedUserDao, - private val preferences: DankChatPreferenceStore, + preferences: DankChatPreferenceStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, dispatchersProvider: DispatchersProvider, ) { - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val currentUserAndDisplay = preferences.currentUserAndDisplayFlow.stateIn(coroutineScope, SharingStarted.Eagerly, null) - private val currentUserRegex = currentUserAndDisplay - .map(::createUserAndDisplayRegex) - .stateIn(coroutineScope, SharingStarted.Eagerly, null) + private val currentUserRegex = + currentUserAndDisplay + .map(::createUserAndDisplayRegex) + .stateIn(coroutineScope, SharingStarted.Eagerly, null) - val messageHighlights = messageHighlightDao.getMessageHighlightsFlow() - .map { it.addDefaultsIfNecessary() } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + val messageHighlights = + messageHighlightDao + .getMessageHighlightsFlow() + .map { it.addDefaultsIfNecessary() } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) val userHighlights = userHighlightDao.getUserHighlightsFlow().stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - val badgeHighlights = badgeHighlightDao.getBadgeHighlightsFlow() - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + val badgeHighlights = + badgeHighlightDao + .getBadgeHighlightsFlow() + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) val blacklistedUsers = blacklistedUserDao.getBlacklistedUserFlow().stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - private val validMessageHighlights = messageHighlights - .map { highlights -> highlights.filter { it.enabled && (it.type != MessageHighlightEntityType.Custom || it.pattern.isNotBlank()) } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - private val validUserHighlights = userHighlights - .map { highlights -> highlights.filter { it.enabled && it.username.isNotBlank() } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - private val validBadgeHighlights = badgeHighlights - .map { highlights -> highlights.filter { it.enabled && it.badgeName.isNotBlank() } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - private val validBlacklistedUsers = blacklistedUsers - .map { highlights -> highlights.filter { it.enabled && it.username.isNotBlank() } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - - suspend fun calculateHighlightState(message: Message): Message { - return when (message) { - is UserNoticeMessage -> message.calculateHighlightState() - is PointRedemptionMessage -> message.calculateHighlightState() - is PrivMessage -> message.calculateHighlightState() - is WhisperMessage -> message.calculateHighlightState() - else -> message - } + private val validMessageHighlights = + messageHighlights + .map { highlights -> highlights.filter { it.enabled && (it.type != MessageHighlightEntityType.Custom || it.pattern.isNotBlank()) } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + private val validUserHighlights = + userHighlights + .map { highlights -> highlights.filter { it.enabled && it.username.isNotBlank() } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + private val validBadgeHighlights = + badgeHighlights + .map { highlights -> highlights.filter { it.enabled && it.badgeName.isNotBlank() } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + private val validBlacklistedUsers = + blacklistedUsers + .map { highlights -> highlights.filter { it.enabled && it.username.isNotBlank() } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + + suspend fun calculateHighlightState(message: Message): Message = when (message) { + is UserNoticeMessage -> message.calculateHighlightState() + is PointRedemptionMessage -> message.calculateHighlightState() + is PrivMessage -> message.calculateHighlightState() + is WhisperMessage -> message.calculateHighlightState() + else -> message } fun runMigrationsIfNeeded() = coroutineScope.launch { runCatching { if (messageHighlightDao.getMessageHighlights().isEmpty()) { - Log.d(TAG, "Running message highlights migration...") + logger.debug { "Running message highlights migration" } messageHighlightDao.addHighlights(DEFAULT_MESSAGE_HIGHLIGHTS) - val totalMessageHighlights = DEFAULT_MESSAGE_HIGHLIGHTS.size - Log.d(TAG, "Message highlights migration completed, added $totalMessageHighlights entries.") + logger.debug { "Message highlights migration completed" } } if (badgeHighlightDao.getBadgeHighlights().isEmpty()) { - Log.d(TAG, "Running badge highlights migration...") + logger.debug { "Running badge highlights migration" } badgeHighlightDao.addHighlights(DEFAULT_BADGE_HIGHLIGHTS) - val totalBadgeHighlights = + DEFAULT_BADGE_HIGHLIGHTS.size - Log.d(TAG, "Badge highlights migration completed, added $totalBadgeHighlights entries.") + logger.debug { "Badge highlights migration completed" } } }.getOrElse { - Log.e(TAG, "Failed to run highlights migration", it) + logger.error(it) { "Failed to run highlights migration" } runCatching { messageHighlightDao.deleteAllHighlights() userHighlightDao.deleteAllHighlights() @@ -113,12 +118,13 @@ class HighlightsRepository( } suspend fun addMessageHighlight(): MessageHighlightEntity { - val entity = MessageHighlightEntity( - id = 0, - enabled = true, - type = MessageHighlightEntityType.Custom, - pattern = "" - ) + val entity = + MessageHighlightEntity( + id = 0, + enabled = true, + type = MessageHighlightEntityType.Custom, + pattern = "", + ) val id = messageHighlightDao.addHighlight(entity) return entity.copy(id = id) } @@ -136,11 +142,12 @@ class HighlightsRepository( } suspend fun addUserHighlight(): UserHighlightEntity { - val entity = UserHighlightEntity( - id = 0, - enabled = true, - username = "" - ) + val entity = + UserHighlightEntity( + id = 0, + enabled = true, + username = "", + ) val id = userHighlightDao.addHighlight(entity) return entity.copy(id = id) } @@ -158,12 +165,13 @@ class HighlightsRepository( } suspend fun addBadgeHighlight(): BadgeHighlightEntity { - val entity = BadgeHighlightEntity( - id = 0, - enabled = true, - badgeName = "", - isCustom = true, - ) + val entity = + BadgeHighlightEntity( + id = 0, + enabled = true, + badgeName = "", + isCustom = true, + ) val id = badgeHighlightDao.addHighlight(entity) return entity.copy(id = id) } @@ -181,11 +189,12 @@ class HighlightsRepository( } suspend fun addBlacklistedUser(): BlacklistedUserEntity { - val entity = BlacklistedUserEntity( - id = 0, - enabled = true, - username = "" - ) + val entity = + BlacklistedUserEntity( + id = 0, + enabled = true, + username = "", + ) val id = blacklistedUserDao.addBlacklistedUser(entity) return entity.copy(id = id) } @@ -205,28 +214,34 @@ class HighlightsRepository( private fun UserNoticeMessage.calculateHighlightState(): UserNoticeMessage { val messageHighlights = validMessageHighlights.value - val highlights = buildSet { - val subsHighlight = messageHighlights.subsHighlight - if (isSub && subsHighlight != null) { - add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) - } + val highlights = + buildSet { + val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) + if (isSub && subsHighlight != null) { + add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) + } + + val announcementsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Announcement) + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) + } - val announcementsHighlight = messageHighlights.announcementsHighlight - if (isAnnouncement && announcementsHighlight != null) { - add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) + val watchStreakHighlight = messageHighlights.ofType(MessageHighlightEntityType.WatchStreak) + if (isViewerMilestone && watchStreakHighlight != null) { + add(Highlight(HighlightType.WatchStreak, watchStreakHighlight.customColor)) + } } - } return copy( highlights = highlights, - childMessage = childMessage?.calculateHighlightState() + childMessage = childMessage?.calculateHighlightState(), ) } private fun PointRedemptionMessage.calculateHighlightState(): PointRedemptionMessage { - val rewardsHighlight = validMessageHighlights.value.rewardsHighlight - if (rewardsHighlight != null) { - return copy(highlights = setOf(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor))) + val highlight = validMessageHighlights.value.ofType(MessageHighlightEntityType.ChannelPointRedemption) + if (highlight != null) { + return copy(highlights = setOf(Highlight(HighlightType.ChannelPointRedemption, highlight.customColor))) } return copy(highlights = emptySet()) } @@ -244,126 +259,102 @@ class HighlightsRepository( val userHighlights = validUserHighlights.value val badgeHighlights = validBadgeHighlights.value val messageHighlights = validMessageHighlights.value - val highlights = buildSet { - val subsHighlight = messageHighlights.subsHighlight - if (isSub && subsHighlight != null) { - add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) - } - - val announcementsHighlight = messageHighlights.announcementsHighlight - if (isAnnouncement && announcementsHighlight != null) { - add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) - } - - val rewardsHighlight = messageHighlights.rewardsHighlight - if (isReward && rewardsHighlight != null) { - add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) - } + val highlights = + buildSet { + val subsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Subscription) + if (isSub && subsHighlight != null) { + add(Highlight(HighlightType.Subscription, subsHighlight.customColor)) + } - val firstMessageHighlight = messageHighlights.firstMessageHighlight - if (isFirstMessage && firstMessageHighlight != null) { - add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) - } + val announcementsHighlight = messageHighlights.ofType(MessageHighlightEntityType.Announcement) + if (isAnnouncement && announcementsHighlight != null) { + add(Highlight(HighlightType.Announcement, announcementsHighlight.customColor)) + } - val elevatedMessageHighlight = messageHighlights.elevatedMessageHighlight - if (isElevatedMessage && elevatedMessageHighlight != null) { - add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) - } + val watchStreakHighlight = messageHighlights.ofType(MessageHighlightEntityType.WatchStreak) + if (isViewerMilestone && watchStreakHighlight != null) { + add(Highlight(HighlightType.WatchStreak, watchStreakHighlight.customColor)) + } - if (containsCurrentUserName) { - val highlight = messageHighlights.userNameHighlight - if (highlight?.enabled == true) { - add(Highlight(HighlightType.Username, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + val rewardsHighlight = messageHighlights.ofType(MessageHighlightEntityType.ChannelPointRedemption) + if (isReward && rewardsHighlight != null) { + add(Highlight(HighlightType.ChannelPointRedemption, rewardsHighlight.customColor)) } - } - if (containsParticipatedReply) { - val highlight = messageHighlights.repliesHighlight - if (highlight?.enabled == true) { - add(Highlight(HighlightType.Reply, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + val firstMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.FirstMessage) + if (isFirstMessage && firstMessageHighlight != null) { + add(Highlight(HighlightType.FirstMessage, firstMessageHighlight.customColor)) } - } - messageHighlights - .filter { it.type == MessageHighlightEntityType.Custom } - .forEach { - val regex = it.regex ?: return@forEach + val elevatedMessageHighlight = messageHighlights.ofType(MessageHighlightEntityType.ElevatedMessage) + if (isElevatedMessage && elevatedMessageHighlight != null) { + add(Highlight(HighlightType.ElevatedMessage, elevatedMessageHighlight.customColor)) + } - if (message.contains(regex)) { - add(Highlight(HighlightType.Custom, it.customColor)) - addNotificationHighlightIfEnabled(it) + if (containsCurrentUserName) { + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Username) + if (highlight?.enabled == true) { + add(Highlight(HighlightType.Username, highlight.customColor)) + addNotificationHighlightIfEnabled(highlight.createNotification) } } - userHighlights.forEach { - if (name.matches(it.username)) { - add(Highlight(HighlightType.Custom, it.customColor)) - addNotificationHighlightIfEnabled(it) + if (containsParticipatedReply) { + val highlight = messageHighlights.ofType(MessageHighlightEntityType.Reply) + if (highlight?.enabled == true) { + add(Highlight(HighlightType.Reply, highlight.customColor)) + addNotificationHighlightIfEnabled(highlight.createNotification) + } } - } - badgeHighlights.forEach { highlight -> - badges.forEach { badge -> - val tag = badge.badgeTag ?: return@forEach - if (tag.isNotBlank()) { - val match = if (highlight.badgeName.contains("/")) { - tag == highlight.badgeName - } else { - tag.startsWith(highlight.badgeName + "/") + + messageHighlights + .filter { it.type == MessageHighlightEntityType.Custom } + .forEach { + val regex = it.regex ?: return@forEach + + if (message.contains(regex)) { + add(Highlight(HighlightType.Custom, it.customColor)) + addNotificationHighlightIfEnabled(it.createNotification) } - if (match) { - add(Highlight(HighlightType.Badge, highlight.customColor)) - addNotificationHighlightIfEnabled(highlight) + } + + userHighlights.forEach { + if (name.matches(it.username)) { + add(Highlight(HighlightType.Custom, it.customColor)) + addNotificationHighlightIfEnabled(it.createNotification) + } + } + badgeHighlights.forEach { highlight -> + badges.forEach { badge -> + val tag = badge.badgeTag ?: return@forEach + if (tag.isNotBlank()) { + val match = + if (highlight.badgeName.contains("/")) { + tag == highlight.badgeName + } else { + tag.startsWith(highlight.badgeName + "/") + } + if (match) { + add(Highlight(HighlightType.Badge, highlight.customColor)) + addNotificationHighlightIfEnabled(highlight.createNotification) + } } } } } - } return copy(highlights = highlights) } private suspend fun WhisperMessage.calculateHighlightState(): WhisperMessage = when { notificationsSettingsDataStore.settings.first().showWhisperNotifications -> copy(highlights = setOf(Highlight(HighlightType.Notification))) - else -> this - } - - private val List.subsHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.Subscription } - - private val List.announcementsHighlight : MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.Announcement } - - private val List.rewardsHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.ChannelPointRedemption } - - private val List.firstMessageHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.FirstMessage } - - private val List.elevatedMessageHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.ElevatedMessage } - - private val List.repliesHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.Reply } - - private val List.userNameHighlight: MessageHighlightEntity? - get() = find { it.type == MessageHighlightEntityType.Username } - - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: MessageHighlightEntity) { - if (highlightEntity.createNotification) { - add(Highlight(HighlightType.Notification)) - } + else -> this } - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: UserHighlightEntity) { - if (highlightEntity.createNotification) { - add(Highlight(HighlightType.Notification)) - } - } + private fun List.ofType(type: MessageHighlightEntityType): MessageHighlightEntity? = find { it.type == type } - private fun MutableCollection.addNotificationHighlightIfEnabled(highlightEntity: BadgeHighlightEntity) { - if (highlightEntity.createNotification) { + private fun MutableCollection.addNotificationHighlightIfEnabled(createNotification: Boolean) { + if (createNotification) { add(Highlight(HighlightType.Notification)) } } @@ -385,19 +376,22 @@ class HighlightsRepository( private fun createUserAndDisplayRegex(values: Pair?): Regex? { val (user, display) = values ?: return null user ?: return null - val displayRegex = display - ?.takeIf { !user.matches(it) } - ?.let { "|$it" }.orEmpty() + val displayRegex = + display + ?.takeIf { !user.matches(it) } + ?.let { "|$it" } + .orEmpty() return """\b$user$displayRegex\b""".toRegex(RegexOption.IGNORE_CASE) } private fun isUserBlacklisted(name: UserName): Boolean { validBlacklistedUsers.value .forEach { - val hasMatch = when { - it.isRegex -> it.regex?.let { regex -> name.matches(regex) } ?: false - else -> name.matches(it.username) - } + val hasMatch = + when { + it.isRegex -> it.regex?.let { regex -> name.matches(regex) } ?: false + else -> name.matches(it.username) + } if (hasMatch) { return true @@ -407,36 +401,37 @@ class HighlightsRepository( return false } - private fun List.addDefaultsIfNecessary(): List { - return (this + DEFAULT_MESSAGE_HIGHLIGHTS).distinctBy { + private fun List.addDefaultsIfNecessary(): List = (this + DEFAULT_MESSAGE_HIGHLIGHTS) + .distinctBy { when (it.type) { MessageHighlightEntityType.Custom -> it.id - else -> it.type + else -> it.type } }.sortedBy { it.type.ordinal } - } companion object { - private val TAG = HighlightsRepository::class.java.simpleName - private val DEFAULT_MESSAGE_HIGHLIGHTS = listOf( - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Username, pattern = ""), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Subscription, pattern = "", createNotification = false), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Announcement, pattern = "", createNotification = false), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.ChannelPointRedemption, pattern = "", createNotification = false), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.FirstMessage, pattern = "", createNotification = false), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.ElevatedMessage, pattern = "", createNotification = false), - MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Reply, pattern = ""), - ) - private val DEFAULT_BADGE_HIGHLIGHTS = listOf( - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "broadcaster", isCustom = false, customColor = 0x7f7f3f49.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "admin", isCustom = false, customColor = 0x7f8f3018.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "staff", isCustom = false, customColor = 0x7f8f3018.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "moderator", isCustom = false, customColor = 0x731f8d2b.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "lead_moderator", isCustom = false, customColor = 0x731f8d2b.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "partner", isCustom = false, customColor = 0x64c466ff.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "vip", isCustom = false, customColor = 0x7fc12ea9.toInt()), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "founder", isCustom = false), - BadgeHighlightEntity(id = 0, enabled = false, badgeName = "subscriber", isCustom = false), - ) + private val DEFAULT_MESSAGE_HIGHLIGHTS = + listOf( + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Username, pattern = ""), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Subscription, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Announcement, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.WatchStreak, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.ChannelPointRedemption, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.FirstMessage, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.ElevatedMessage, pattern = "", createNotification = false), + MessageHighlightEntity(id = 0, enabled = true, type = MessageHighlightEntityType.Reply, pattern = ""), + ) + private val DEFAULT_BADGE_HIGHLIGHTS = + listOf( + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "broadcaster", isCustom = false, customColor = 0x7f7f3f49), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "admin", isCustom = false, customColor = 0x7f8f3018), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "staff", isCustom = false, customColor = 0x7f8f3018), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "moderator", isCustom = false, customColor = 0x731f8d2b), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "lead_moderator", isCustom = false, customColor = 0x731f8d2b), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "partner", isCustom = false, customColor = 0x64c466ff), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "vip", isCustom = false, customColor = 0x7fc12ea9), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "founder", isCustom = false), + BadgeHighlightEntity(id = 0, enabled = false, badgeName = "subscriber", isCustom = false), + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/IgnoresRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/IgnoresRepository.kt index c61342252..2f3b63101 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/IgnoresRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/IgnoresRepository.kt @@ -1,8 +1,5 @@ -@file:Suppress("DEPRECATION") - package com.flxrs.dankchat.data.repo -import android.util.Log import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient @@ -22,8 +19,10 @@ import com.flxrs.dankchat.data.twitch.message.isElevatedMessage import com.flxrs.dankchat.data.twitch.message.isFirstMessage import com.flxrs.dankchat.data.twitch.message.isReward import com.flxrs.dankchat.data.twitch.message.isSub +import com.flxrs.dankchat.data.twitch.message.isViewerMilestone import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -37,40 +36,48 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.Single +private val logger = KotlinLogging.logger("IgnoresRepository") + @Single class IgnoresRepository( private val helixApiClient: HelixApiClient, private val messageIgnoreDao: MessageIgnoreDao, private val userIgnoreDao: UserIgnoreDao, private val preferences: DankChatPreferenceStore, - dispatchersProvider: DispatchersProvider, + private val dispatchersProvider: DispatchersProvider, ) { - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - data class TwitchBlock(val id: UserId, val name: UserName) + data class TwitchBlock( + val id: UserId, + val name: UserName, + ) private val _twitchBlocks = MutableStateFlow(emptySet()) - val messageIgnores = messageIgnoreDao.getMessageIgnoresFlow().stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + val messageIgnores = + messageIgnoreDao + .getMessageIgnoresFlow() + .map { it.addDefaultsIfNecessary() } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) val userIgnores = userIgnoreDao.getUserIgnoresFlow().stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) val twitchBlocks = _twitchBlocks.asStateFlow() - private val validMessageIgnores = messageIgnores - .map { ignores -> ignores.filter { it.enabled && (it.type != MessageIgnoreEntityType.Custom || it.pattern.isNotBlank()) } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - private val validUserIgnores = userIgnores - .map { ignores -> ignores.filter { it.enabled && it.username.isNotBlank() } } - .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) - - fun applyIgnores(message: Message): Message? { - return when (message) { - is PointRedemptionMessage -> message.applyIgnores() - is PrivMessage -> message.applyIgnores() - is UserNoticeMessage -> message.applyIgnores() - is WhisperMessage -> message.applyIgnores() - else -> message - } + private val validMessageIgnores = + messageIgnores + .map { ignores -> ignores.filter { it.enabled && (it.type != MessageIgnoreEntityType.Custom || it.pattern.isNotBlank()) } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + private val validUserIgnores = + userIgnores + .map { ignores -> ignores.filter { it.enabled && it.username.isNotBlank() } } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + + fun applyIgnores(message: Message): Message? = when (message) { + is PointRedemptionMessage -> message.applyIgnores() + is PrivMessage -> message.applyIgnores() + is UserNoticeMessage -> message.applyIgnores() + is WhisperMessage -> message.applyIgnores() + else -> message } fun runMigrationsIfNeeded() = coroutineScope.launch { @@ -79,13 +86,13 @@ class IgnoresRepository( return@launch } - Log.d(TAG, "Running ignores migration...") + logger.debug { "Running ignores migration..." } messageIgnoreDao.addIgnores(DEFAULT_IGNORES) val totalIgnores = DEFAULT_IGNORES.size - Log.d(TAG, "Ignores migration completed, added $totalIgnores entries.") + logger.debug { "Ignores migration completed, added $totalIgnores entries." } }.getOrElse { - Log.e(TAG, "Failed to run ignores migration", it) + logger.error(it) { "Failed to run ignores migration" } runCatching { messageIgnoreDao.deleteAllIgnores() userIgnoreDao.deleteAllIgnores() @@ -94,59 +101,68 @@ class IgnoresRepository( } } - fun isUserBlocked(userId: UserId?): Boolean { - return _twitchBlocks.value.any { it.id == userId } - } + fun isUserBlocked(userId: UserId?): Boolean = _twitchBlocks.value.any { it.id == userId } - suspend fun loadUserBlocks() = withContext(Dispatchers.Default) { + suspend fun loadUserBlocks() = withContext(dispatchersProvider.default) { if (!preferences.isLoggedIn) { return@withContext } val userId = preferences.userIdString ?: return@withContext - val blocks = helixApiClient.getUserBlocks(userId).getOrElse { - Log.d(TAG, "Failed to load user blocks for $userId", it) - return@withContext - } + val blocks = + helixApiClient.getUserBlocks(userId).getOrElse { + logger.debug(it) { "Failed to load user blocks for $userId" } + return@withContext + } if (blocks.isEmpty()) { _twitchBlocks.update { emptySet() } return@withContext } val userIds = blocks.map { it.id } - val users = helixApiClient.getUsersByIds(userIds).getOrElse { - Log.d(TAG, "Failed to load user ids $userIds", it) - return@withContext - } - val twitchBlocks = users.mapTo(mutableSetOf()) { user -> - TwitchBlock( - id = user.id, - name = user.name, - ) - } + val users = + helixApiClient.getUsersByIds(userIds).getOrElse { + logger.debug(it) { "Failed to load user ids $userIds" } + return@withContext + } + val twitchBlocks = + users.mapTo(mutableSetOf()) { user -> + TwitchBlock( + id = user.id, + name = user.name, + ) + } _twitchBlocks.update { twitchBlocks } } - suspend fun addUserBlock(targetUserId: UserId, targetUsername: UserName) { + suspend fun addUserBlock( + targetUserId: UserId, + targetUsername: UserName, + ) { val result = helixApiClient.blockUser(targetUserId) if (result.isSuccess) { _twitchBlocks.update { - it + TwitchBlock( - id = targetUserId, - name = targetUsername, - ) + it + + TwitchBlock( + id = targetUserId, + name = targetUsername, + ) } } } - suspend fun removeUserBlock(targetUserId: UserId, targetUsername: UserName) { + suspend fun removeUserBlock( + targetUserId: UserId, + targetUsername: UserName, + ) { val result = helixApiClient.unblockUser(targetUserId) if (result.isSuccess) { _twitchBlocks.update { - it - TwitchBlock( - id = targetUserId, - name = targetUsername, - ) + it - + TwitchBlock( + id = targetUserId, + name = targetUsername, + ) } } } @@ -154,14 +170,15 @@ class IgnoresRepository( fun clearIgnores() = _twitchBlocks.update { emptySet() } suspend fun addMessageIgnore(): MessageIgnoreEntity { - val entity = MessageIgnoreEntity( - id = 0, - enabled = true, - type = MessageIgnoreEntityType.Custom, - pattern = "", - isBlockMessage = false, - replacement = "***", - ) + val entity = + MessageIgnoreEntity( + id = 0, + enabled = true, + type = MessageIgnoreEntityType.Custom, + pattern = "", + isBlockMessage = false, + replacement = "***", + ) val id = messageIgnoreDao.addIgnore(entity) return entity.copy(id = id) } @@ -179,11 +196,12 @@ class IgnoresRepository( } suspend fun addUserIgnore(): UserIgnoreEntity { - val entity = UserIgnoreEntity( - id = 0, - enabled = true, - username = "", - ) + val entity = + UserIgnoreEntity( + id = 0, + enabled = true, + username = "", + ) val id = userIgnoreDao.addIgnore(entity) return entity.copy(id = id) } @@ -203,39 +221,44 @@ class IgnoresRepository( private fun UserNoticeMessage.applyIgnores(): UserNoticeMessage? { val messageIgnores = validMessageIgnores.value - if (isSub && messageIgnores.areSubsIgnored) { + if (isSub && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Subscription)) { + return null + } + + if (isAnnouncement && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Announcement)) { return null } - if (isAnnouncement && messageIgnores.areAnnouncementsIgnored) { + if (isViewerMilestone && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.WatchStreak)) { return null } return copy( - childMessage = childMessage?.applyIgnores() + childMessage = childMessage?.applyIgnores(), ) } + @Suppress("ReturnCount") private fun PrivMessage.applyIgnores(): PrivMessage? { val messageIgnores = validMessageIgnores.value - if (isSub && messageIgnores.areSubsIgnored) { + if (isSub && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Subscription)) { return null } - if (isAnnouncement && messageIgnores.areAnnouncementsIgnored) { + if (isAnnouncement && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Announcement)) { return null } - if (isReward && messageIgnores.areRewardsIgnored) { + if (isReward && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.ChannelPointRedemption)) { return null } - if (isElevatedMessage && messageIgnores.areElevatedMessagesIgnored) { + if (isElevatedMessage && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.ElevatedMessage)) { return null } - if (isFirstMessage && messageIgnores.areFirstMessagesIgnored) { + if (isFirstMessage && messageIgnores.isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.FirstMessage)) { return null } @@ -250,7 +273,7 @@ class IgnoresRepository( return copy( message = replacement.filtered, originalMessage = replacement.filtered, - emoteData = emoteData.copy(message = replacement.filtered, emotesWithPositions = filteredPositions) + emoteData = emoteData.copy(message = replacement.filtered, emotesWithPositions = filteredPositions), ) } @@ -258,8 +281,9 @@ class IgnoresRepository( } private fun PointRedemptionMessage.applyIgnores(): PointRedemptionMessage? { - val redemptionsIgnored = validMessageIgnores.value - .any { it.type == MessageIgnoreEntityType.ChannelPointRedemption } + val redemptionsIgnored = + validMessageIgnores.value + .any { it.type == MessageIgnoreEntityType.ChannelPointRedemption } if (redemptionsIgnored) { return null @@ -280,39 +304,23 @@ class IgnoresRepository( return copy( message = replacement.filtered, originalMessage = replacement.filtered, - emoteData = emoteData.copy(message = replacement.filtered, emotesWithPositions = filteredPositions) + emoteData = emoteData.copy(message = replacement.filtered, emotesWithPositions = filteredPositions), ) } return this } - private val List.areSubsIgnored: Boolean - get() = isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Subscription) - - private val List.areAnnouncementsIgnored: Boolean - get() = isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.Announcement) - - private val List.areRewardsIgnored: Boolean - get() = isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.ChannelPointRedemption) - - private val List.areFirstMessagesIgnored: Boolean - get() = isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.FirstMessage) - - private val List.areElevatedMessagesIgnored: Boolean - get() = isMessageIgnoreTypeEnabled(MessageIgnoreEntityType.ElevatedMessage) - - private fun List.isMessageIgnoreTypeEnabled(type: MessageIgnoreEntityType): Boolean { - return any { it.type == type } - } + private fun List.isMessageIgnoreTypeEnabled(type: MessageIgnoreEntityType): Boolean = any { it.type == type } private fun isIgnoredUsername(name: UserName): Boolean { validUserIgnores.value .forEach { - val hasMatch = when { - it.isRegex -> it.regex?.let { regex -> name.value.matches(regex) } ?: false - else -> name.matches(it.username, ignoreCase = !it.isCaseSensitive) - } + val hasMatch = + when { + it.isRegex -> it.regex?.let { regex -> name.value.matches(regex) } ?: false + else -> name.matches(it.username, ignoreCase = !it.isCaseSensitive) + } if (hasMatch) { return true @@ -322,9 +330,16 @@ class IgnoresRepository( return false } - private data class ReplacementResult(val filtered: String, val replacement: String, val matchedRanges: List) + private data class ReplacementResult( + val filtered: String, + val replacement: String, + val matchedRanges: List, + ) - private inline fun List.isIgnoredMessageWithReplacement(message: String, onReplacement: (ReplacementResult?) -> Unit) { + private inline fun List.isIgnoredMessageWithReplacement( + message: String, + onReplacement: (ReplacementResult?) -> Unit, + ) { filter { it.type == MessageIgnoreEntityType.Custom } .forEach { ignoreEntity -> val regex = ignoreEntity.regex ?: return@forEach @@ -341,32 +356,42 @@ class IgnoresRepository( } } - private fun adaptEmotePositions(replacement: ReplacementResult, emotes: List): List { - return emotes.map { emoteWithPos -> - val adjusted = emoteWithPos.positions + private fun adaptEmotePositions( + replacement: ReplacementResult, + emotes: List, + ): List = emotes.map { emoteWithPos -> + val adjusted = + emoteWithPos.positions .filterNot { pos -> replacement.matchedRanges.any { match -> match in pos || pos in match } } // filter out emotes directly affected by ignore replacement .map { pos -> - val offset = replacement.matchedRanges - .filter { it.last < pos.first } // only replacements before an emote need to be considered - .sumOf { replacement.replacement.length - (it.last + 1 - it.first) } // change between original match and replacement + val offset = + replacement.matchedRanges + .filter { it.last < pos.first } // only replacements before an emote need to be considered + .sumOf { replacement.replacement.length - (it.last + 1 - it.first) } // change between original match and replacement pos.first + offset..pos.last + offset // add sum of changes to the emote position } - emoteWithPos.copy(positions = adjusted) - } + emoteWithPos.copy(positions = adjusted) } - private operator fun IntRange.contains(other: IntRange): Boolean { - return other.first >= first && other.last <= last - } + private operator fun IntRange.contains(other: IntRange): Boolean = other.first >= first && other.last <= last companion object { - private val TAG = IgnoresRepository::class.java.simpleName - private val DEFAULT_IGNORES = listOf( - MessageIgnoreEntity(id = 1, enabled = false, type = MessageIgnoreEntityType.Subscription, pattern = ""), - MessageIgnoreEntity(id = 2, enabled = false, type = MessageIgnoreEntityType.Announcement, pattern = ""), - MessageIgnoreEntity(id = 3, enabled = false, type = MessageIgnoreEntityType.ChannelPointRedemption, pattern = ""), - MessageIgnoreEntity(id = 4, enabled = false, type = MessageIgnoreEntityType.FirstMessage, pattern = ""), - MessageIgnoreEntity(id = 5, enabled = false, type = MessageIgnoreEntityType.ElevatedMessage, pattern = ""), - ) + private val DEFAULT_IGNORES = + listOf( + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.Subscription, pattern = ""), + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.Announcement, pattern = ""), + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.WatchStreak, pattern = ""), + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.ChannelPointRedemption, pattern = ""), + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.FirstMessage, pattern = ""), + MessageIgnoreEntity(id = 0, enabled = false, type = MessageIgnoreEntityType.ElevatedMessage, pattern = ""), + ) + + private fun List.addDefaultsIfNecessary(): List = (this + DEFAULT_IGNORES) + .distinctBy { + when (it.type) { + MessageIgnoreEntityType.Custom -> it.id + else -> it.type + } + }.sortedBy { it.type.ordinal } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RecentUploadsRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RecentUploadsRepository.kt index e1e7e2994..175671249 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RecentUploadsRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RecentUploadsRepository.kt @@ -8,18 +8,18 @@ import org.koin.core.annotation.Single @Single class RecentUploadsRepository( - private val recentUploadsDao: RecentUploadsDao + private val recentUploadsDao: RecentUploadsDao, ) { - fun getRecentUploads(): Flow> = recentUploadsDao.getRecentUploads() suspend fun addUpload(upload: UploadDto) { - val entity = UploadEntity( - id = 0, - timestamp = upload.timestamp, - imageLink = upload.imageLink, - deleteLink = upload.deleteLink - ) + val entity = + UploadEntity( + id = 0, + timestamp = upload.timestamp, + imageLink = upload.imageLink, + deleteLink = upload.deleteLink, + ) recentUploadsDao.addUpload(entity) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RepliesRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RepliesRepository.kt index 7ce82cd9f..5133806f2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RepliesRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/RepliesRepository.kt @@ -1,13 +1,14 @@ package com.flxrs.dankchat.data.repo -import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.twitch.message.HighlightType +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.toDisplayName +import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.MessageThread import com.flxrs.dankchat.data.twitch.message.MessageThreadHeader import com.flxrs.dankchat.data.twitch.message.PrivMessage -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.utils.extensions.replaceIf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,13 +19,14 @@ import org.koin.core.annotation.Single import java.util.concurrent.ConcurrentHashMap @Single -class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceStore) { - +class RepliesRepository( + private val authDataStore: AuthDataStore, +) { private val threads = ConcurrentHashMap>() fun getThreadItemsFlow(rootMessageId: String): Flow> = threads[rootMessageId]?.map { thread -> - val root = ChatItem(thread.rootMessage.clearHighlight(), isInReplies = true) - val replies = thread.replies.map { ChatItem(it.clearHighlight(), isInReplies = true) } + val root = ChatItem(thread.rootMessage, isInReplies = true) + val replies = thread.replies.map { ChatItem(it, isInReplies = true) } listOf(root) + replies } ?: flowOf(emptyList()) @@ -45,48 +47,71 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS .forEach { threads.remove(it.rootMessageId) } } - fun calculateMessageThread(message: Message, findMessageById: (channel: UserName, id: String) -> Message?): Message { + fun calculateMessageThread( + message: Message, + findMessageById: (channel: UserName, id: String) -> Message?, + ): Message { if (message !is PrivMessage) { return message } - if (ROOT_MESSAGE_ID_TAG !in message.tags) { + if (THREAD_ROOT_MESSAGE_ID_TAG !in message.tags) { return message } val strippedMessage = message.stripLeadingReplyMention() - val rootId = message.tags.getValue(ROOT_MESSAGE_ID_TAG) - val thread = when (val existing = threads[rootId]?.value) { - null -> { - val rootMessage = findMessageById(strippedMessage.channel, rootId) as? PrivMessage ?: return message - MessageThread( - rootMessageId = rootId, - rootMessage = rootMessage, - replies = listOf(strippedMessage), - participated = strippedMessage.isParticipating() - ) - } - - else -> { - // Message already exists in thread - if (existing.replies.any { it.id == strippedMessage.id }) { - return strippedMessage + val rootId = message.tags.getValue(THREAD_ROOT_MESSAGE_ID_TAG) + val thread = + when (val existing = threads[rootId]?.value) { + null -> { + val rootMessage = + findMessageById(strippedMessage.channel, rootId) as? PrivMessage + ?: createPlaceholderRootMessage(strippedMessage, rootId) + ?: return message + MessageThread( + rootMessageId = rootId, + rootMessage = rootMessage, + replies = listOf(strippedMessage), + participated = strippedMessage.isParticipating(), + ) } - existing.copy(replies = existing.replies + strippedMessage, participated = existing.updateParticipated(strippedMessage)) + else -> { + // Message already exists in thread + if (existing.replies.any { it.id == strippedMessage.id }) { + val parentName = message.tags[PARENT_MESSAGE_LOGIN_TAG]?.toUserName() ?: existing.rootMessage.name + val parentBody = message.tags[PARENT_MESSAGE_BODY_TAG] ?: existing.rootMessage.originalMessage + return strippedMessage.copy(thread = MessageThreadHeader(rootId, parentName, parentBody, existing.participated)) + } + + existing.copy(replies = existing.replies + strippedMessage, participated = existing.updateParticipated(strippedMessage)) + } + } + val existing = threads.putIfAbsent(rootId, MutableStateFlow(thread)) + existing?.update { thread } + + val parentMessageId = message.tags[PARENT_MESSAGE_ID_TAG] + val parentInThread = + parentMessageId?.let { id -> + if (id == thread.rootMessageId) { + thread.rootMessage + } else { + thread.replies.find { it.id == id } + } } - } - when { - !threads.containsKey(rootId) -> threads[rootId] = MutableStateFlow(thread) - else -> threads.getValue(rootId).update { thread } - } - val parentMessage = message.tags[PARENT_MESSAGE_ID_TAG]?.let { parentMessageId -> - thread.replies.find { it.id == parentMessageId } - } ?: thread.rootMessage + val parentName: UserName + val parentBody: String + if (parentInThread != null) { + parentName = parentInThread.name + parentBody = parentInThread.originalMessage + } else { + parentName = message.tags[PARENT_MESSAGE_LOGIN_TAG]?.toUserName() ?: thread.rootMessage.name + parentBody = message.tags[PARENT_MESSAGE_BODY_TAG] ?: thread.rootMessage.originalMessage + } return strippedMessage - .copy(thread = MessageThreadHeader(thread.rootMessageId, parentMessage.name, parentMessage.originalMessage, thread.participated)) + .copy(thread = MessageThreadHeader(thread.rootMessageId, parentName, parentBody, thread.participated)) } fun updateMessageInThread(message: Message): Message { @@ -94,16 +119,16 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS return message } - when (ROOT_MESSAGE_ID_TAG) { + when (THREAD_ROOT_MESSAGE_ID_TAG) { in message.tags -> { - val rootId = message.tags.getValue(ROOT_MESSAGE_ID_TAG) + val rootId = message.tags.getValue(THREAD_ROOT_MESSAGE_ID_TAG) val flow = threads[rootId] ?: return message flow.update { thread -> thread.copy(replies = thread.replies.replaceIf(message) { it.id == message.id }) } } - else -> { + else -> { val flow = threads[message.id] ?: return message flow.update { thread -> thread.copy(rootMessage = message) } } @@ -120,12 +145,10 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS return message.isParticipating() } - private fun PrivMessage.isParticipating(): Boolean { - return name == dankChatPreferenceStore.userName || (ROOT_MESSAGE_LOGIN_TAG in tags && tags[ROOT_MESSAGE_LOGIN_TAG] == dankChatPreferenceStore.userName?.value) - } + private fun PrivMessage.isParticipating(): Boolean = name == authDataStore.userName || (PARENT_MESSAGE_LOGIN_TAG in tags && tags[PARENT_MESSAGE_LOGIN_TAG] == authDataStore.userName?.value) private fun PrivMessage.stripLeadingReplyMention(): PrivMessage { - val displayName = tags[ROOT_MESSAGE_DISPLAY_TAG] ?: return this + val displayName = tags[PARENT_MESSAGE_DISPLAY_TAG] ?: return this if (message.startsWith("@$displayName ")) { val stripped = message.substringAfter("@$displayName ") @@ -133,23 +156,43 @@ class RepliesRepository(private val dankChatPreferenceStore: DankChatPreferenceS message = stripped, originalMessage = stripped, replyMentionOffset = displayName.length + 2, - emoteData = emoteData.copy(message = stripped) + emoteData = emoteData.copy(message = stripped), ) } return this } - private fun PrivMessage.clearHighlight(): PrivMessage { - return copy(highlights = highlights.filter { it.type != HighlightType.Reply }.toSet()) + private fun createPlaceholderRootMessage( + reply: PrivMessage, + rootId: String, + ): PrivMessage? { + val login = reply.tags[THREAD_ROOT_USER_LOGIN_TAG] ?: return null + val name = login.toUserName() + val displayName = (reply.tags[THREAD_ROOT_DISPLAY_TAG] ?: login).toDisplayName() + val parentId = reply.tags[PARENT_MESSAGE_ID_TAG] + val body = if (parentId == rootId) reply.tags[PARENT_MESSAGE_BODY_TAG].orEmpty() else "" + + return PrivMessage( + id = rootId, + channel = reply.channel, + sourceChannel = null, + name = name, + displayName = displayName, + message = body, + originalMessage = body, + tags = emptyMap(), + ) } companion object { - private val TAG = RepliesRepository::class.java.simpleName - private const val PARENT_MESSAGE_ID_TAG = "reply-parent-msg-id" - private const val ROOT_MESSAGE_ID_TAG = "reply-thread-parent-msg-id" - private const val ROOT_MESSAGE_LOGIN_TAG = "reply-parent-user-login" - private const val ROOT_MESSAGE_DISPLAY_TAG = "reply-parent-display-name" + private const val PARENT_MESSAGE_LOGIN_TAG = "reply-parent-user-login" + private const val PARENT_MESSAGE_DISPLAY_TAG = "reply-parent-display-name" + private const val PARENT_MESSAGE_BODY_TAG = "reply-parent-msg-body" + + private const val THREAD_ROOT_MESSAGE_ID_TAG = "reply-thread-parent-msg-id" + private const val THREAD_ROOT_USER_LOGIN_TAG = "reply-thread-parent-user-login" + private const val THREAD_ROOT_DISPLAY_TAG = "reply-thread-parent-display-name" } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/ShieldModeRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/ShieldModeRepository.kt new file mode 100644 index 000000000..53a8a0145 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/ShieldModeRepository.kt @@ -0,0 +1,36 @@ +package com.flxrs.dankchat.data.repo + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.helix.HelixApiClient +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap + +@Single +class ShieldModeRepository( + private val helixApiClient: HelixApiClient, + private val channelRepository: ChannelRepository, + private val authDataStore: AuthDataStore, +) { + private val states = ConcurrentHashMap>() + + fun getState(channel: UserName): StateFlow = states.getOrPut(channel) { MutableStateFlow(null) } + + fun setState( + channel: UserName, + active: Boolean, + ) { + states.getOrPut(channel) { MutableStateFlow(null) }.value = active + } + + suspend fun fetch(channel: UserName) { + val channelId = channelRepository.getChannel(channel)?.id ?: return + val moderatorId = authDataStore.userIdString ?: return + helixApiClient + .getShieldMode(channelId, moderatorId) + .onSuccess { setState(channel, it.isActive) } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/UserDisplayRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/UserDisplayRepository.kt index cee08bb10..40f7e0ea1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/UserDisplayRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/UserDisplayRepository.kt @@ -22,29 +22,31 @@ class UserDisplayRepository( private val userDisplayDao: UserDisplayDao, dispatchersProvider: DispatchersProvider, ) { - private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - val userDisplays = userDisplayDao.getUserDisplaysFlow() - .stateIn( - scope = coroutineScope, - started = SharingStarted.Eagerly, - initialValue = emptyList() - ) + val userDisplays = + userDisplayDao + .getUserDisplaysFlow() + .stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) suspend fun updateUserDisplays(userDisplays: List) { userDisplayDao.upsertAll(userDisplays) } suspend fun addUserDisplay(): UserDisplayEntity { - val entity = UserDisplayEntity( - id = 0, - targetUser = "", - enabled = true, - colorEnabled = false, - color = Message.DEFAULT_COLOR, - aliasEnabled = false, - alias = "", - ) + val entity = + UserDisplayEntity( + id = 0, + targetUser = "", + enabled = true, + colorEnabled = false, + color = Message.DEFAULT_COLOR, + aliasEnabled = false, + alias = "", + ) val id = userDisplayDao.upsert(entity) return entity.copy(id = id.toInt()) } @@ -60,10 +62,10 @@ class UserDisplayRepository( fun calculateUserDisplay(message: Message): Message { return when (message) { is PointRedemptionMessage -> message.applyUserDisplay() - is PrivMessage -> message.applyUserDisplay() - is UserNoticeMessage -> message.applyUserDisplay() - is WhisperMessage -> message.applyUserDisplay() - else -> return message + is PrivMessage -> message.applyUserDisplay() + is UserNoticeMessage -> message.applyUserDisplay() + is WhisperMessage -> message.applyUserDisplay() + else -> return message } } @@ -92,13 +94,9 @@ class UserDisplayRepository( return copy( userDisplay = senderMatch, - recipientDisplay = recipientMatch + recipientDisplay = recipientMatch, ) } - private fun findMatchingUserDisplay(name: UserName): UserDisplay? { - return userDisplays.value.find { name.matches(it.targetUser) }?.toUserDisplay() - } + private fun findMatchingUserDisplay(name: UserName): UserDisplay? = userDisplays.value.find { name.matches(it.targetUser) }?.toUserDisplay() } - - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/ChannelRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/ChannelRepository.kt index 9afaf3cc6..b395cf030 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/ChannelRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/channel/ChannelRepository.kt @@ -3,16 +3,16 @@ package com.flxrs.dankchat.data.repo.channel import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.irc.IrcMessage import com.flxrs.dankchat.data.repo.chat.UsersRepository import com.flxrs.dankchat.data.toDisplayName import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.RoomState -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.utils.extensions.firstValue import com.flxrs.dankchat.utils.extensions.firstValueOrNull -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -24,9 +24,9 @@ import java.util.concurrent.ConcurrentHashMap class ChannelRepository( private val usersRepository: UsersRepository, private val helixApiClient: HelixApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, + private val dispatchersProvider: DispatchersProvider, ) { - private val channelCache = ConcurrentHashMap() private val roomStates = ConcurrentHashMap() private val roomStateFlows = ConcurrentHashMap>() @@ -37,13 +37,19 @@ class ChannelRepository( return channelCache[name] } - val channel = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getUserByName(name) - .getOrNull() - ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + val channel = + when { + authDataStore.isLoggedIn -> { + helixApiClient + .getUserByName(name) + .getOrNull() + ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + } - else -> null - } ?: tryGetChannelFromIrc(name) + else -> { + null + } + } ?: tryGetChannelFromIrc(name) if (channel != null) { channelCache[name] = channel @@ -58,13 +64,15 @@ class ChannelRepository( return cached } - if (!dankChatPreferenceStore.isLoggedIn) { + if (!authDataStore.isLoggedIn) { return null } - val channel = helixApiClient.getUser(id) - .getOrNull() - ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + val channel = + helixApiClient + .getUser(id) + .getOrNull() + ?.let { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } if (channel != null) { channelCache[channel.name] = channel @@ -73,13 +81,9 @@ class ChannelRepository( return channel } - fun getCachedChannelByIdOrNull(id: UserId): Channel? { - return channelCache.values.find { it.id == id } - } + fun getCachedChannelByIdOrNull(id: UserId): Channel? = channelCache.values.find { it.id == id } - fun tryGetUserNameById(id: UserId): UserName? { - return roomStates.values.find { it.channelId == id }?.channel - } + fun tryGetUserNameById(id: UserId): UserName? = roomStates.values.find { it.channelId == id }?.channel fun getRoomStateFlow(channel: UserName): SharedFlow = roomStateFlows.getOrPut(channel) { MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -88,30 +92,56 @@ class ChannelRepository( fun getRoomState(channel: UserName): RoomState? = roomStateFlows[channel]?.firstValueOrNull fun handleRoomState(msg: IrcMessage) { - val channel = msg.params.getOrNull(0)?.substring(1)?.toUserName() ?: return + val channel = + msg.params + .getOrNull(0) + ?.substring(1) + ?.toUserName() ?: return val channelId = msg.tags["room-id"]?.toUserId() ?: return val flow = roomStateFlows[channel] ?: return - val state = if (flow.replayCache.isEmpty()) { - RoomState(channel, channelId).copyFromIrcMessage(msg) - } else { - flow.firstValue.copyFromIrcMessage(msg) - } + val state = + if (flow.replayCache.isEmpty()) { + RoomState(channel, channelId).copyFromIrcMessage(msg) + } else { + flow.firstValue.copyFromIrcMessage(msg) + } roomStates[channel] = state flow.tryEmit(state) } - suspend fun getChannels(names: Collection): List = withContext(Dispatchers.IO) { + suspend fun getChannelsByIds(ids: Collection): List = withContext(dispatchersProvider.io) { + val cached = ids.mapNotNull { getCachedChannelByIdOrNull(it) } + val cachedIds = cached.mapTo(mutableSetOf(), Channel::id) + val remaining = ids.filterNot { it in cachedIds } + if (remaining.isEmpty() || !authDataStore.isLoggedIn) { + return@withContext cached + } + + val channels = + helixApiClient + .getUsersByIds(remaining) + .getOrNull() + .orEmpty() + .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + + channels.forEach { channelCache[it.name] = it } + return@withContext cached + channels + } + + suspend fun getChannels(names: Collection): List = withContext(dispatchersProvider.io) { val cached = names.mapNotNull { channelCache[it] } val cachedNames = cached.mapTo(mutableSetOf(), Channel::name) val remaining = names - cachedNames - if (remaining.isEmpty() || !dankChatPreferenceStore.isLoggedIn) { + if (remaining.isEmpty() || !authDataStore.isLoggedIn) { return@withContext cached } - val channels = helixApiClient.getUsersByNames(remaining) - .getOrNull() - .orEmpty() - .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } + val channels = + helixApiClient + .getUsersByNames(remaining) + .getOrNull() + .orEmpty() + .map { Channel(id = it.id, name = it.name, displayName = it.displayName, avatarUrl = it.avatarUrl) } channels.forEach { channelCache[it.name] = it } return@withContext cached + channels @@ -122,7 +152,8 @@ class ChannelRepository( } fun initRoomState(channel: UserName) { - roomStateFlows.putIfAbsent(channel, MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)) } + roomStateFlows.putIfAbsent(channel, MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)) + } fun removeRoomState(channel: UserName) { roomStates.remove(channel) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelection.kt new file mode 100644 index 000000000..3313242f6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelection.kt @@ -0,0 +1,8 @@ +package com.flxrs.dankchat.data.repo.chat + +import kotlinx.serialization.Serializable + +@Serializable +data class ChannelSelection( + val activeChannel: String? = null, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelectionDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelectionDataStore.kt new file mode 100644 index 000000000..2f88302ac --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChannelSelectionDataStore.kt @@ -0,0 +1,45 @@ +package com.flxrs.dankchat.data.repo.chat + +import android.content.Context +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.utils.datastore.createDataStore +import com.flxrs.dankchat.utils.datastore.safeData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking +import org.koin.core.annotation.Single + +@Single +class ChannelSelectionDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + + private val dataStore = + createDataStore( + fileName = "channel_selection", + context = context, + defaultValue = ChannelSelection(), + serializer = ChannelSelection.serializer(), + scope = scope, + migrations = emptyList(), + ) + + val settings = dataStore.safeData(ChannelSelection()) + val currentSettings = + settings.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) + + fun current() = currentSettings.value + + suspend fun update(transform: suspend (ChannelSelection) -> ChannelSelection) { + dataStore.updateData(transform) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt new file mode 100644 index 000000000..314544f13 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatChannelProvider.kt @@ -0,0 +1,41 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +@Single +class ChatChannelProvider( + preferenceStore: DankChatPreferenceStore, + private val channelSelectionDataStore: ChannelSelectionDataStore, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + private val _activeChannel = MutableStateFlow(null) + private val _channels = MutableStateFlow(preferenceStore.channels.takeIf { it.isNotEmpty() }?.toImmutableList()) + + val activeChannel: StateFlow = _activeChannel.asStateFlow() + val channels: StateFlow?> = _channels.asStateFlow() + + fun setActiveChannel(channel: UserName?) { + _activeChannel.value = channel + if (channel != null) { + scope.launch { + channelSelectionDataStore.update { it.copy(activeChannel = channel.value) } + } + } + } + + fun setChannels(channels: List) { + _channels.value = channels.toImmutableList() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatConnector.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatConnector.kt new file mode 100644 index 000000000..85799895b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatConnector.kt @@ -0,0 +1,104 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.eventapi.EventSubManager +import com.flxrs.dankchat.data.twitch.chat.ChatConnection +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.data.twitch.pubsub.PubSubManager +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.di.READ_CONNECTION +import com.flxrs.dankchat.di.WRITE_CONNECTION +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap + +@Single +class ChatConnector( + @Named(READ_CONNECTION) private val readConnection: ChatConnection, + @Named(WRITE_CONNECTION) private val writeConnection: ChatConnection, + private val pubSubManager: PubSubManager, + private val eventSubManager: EventSubManager, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val connectionState = ConcurrentHashMap>() + + val readEvents get() = readConnection.messages + val writeEvents get() = writeConnection.messages + val pubSubEvents get() = pubSubManager.messages + val eventSubEvents get() = eventSubManager.events + + fun getConnectionState(channel: UserName): StateFlow = connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } + + fun setAllConnectionStates(state: ConnectionState) { + connectionState.forEach { (_, flow) -> + flow.value = state + } + } + + fun createConnectionState(channel: UserName) { + connectionState.putIfAbsent(channel, MutableStateFlow(ConnectionState.DISCONNECTED)) + } + + fun removeConnectionState(channel: UserName) { + connectionState.remove(channel) + } + + fun connectAndJoin(channels: List) { + if (!readConnection.connected.value) { + readConnection.connect() + writeConnection.connect() + + if (channels.isNotEmpty()) { + readConnection.joinChannels(channels) + } + } + } + + fun closeAndReconnect(channels: List) = scope.launch { + readConnection.close() + writeConnection.close() + eventSubManager.close() + connectAndJoin(channels) + } + + fun reconnect(reconnectPubsub: Boolean = true) { + readConnection.reconnect() + writeConnection.reconnect() + + if (reconnectPubsub) { + pubSubManager.reconnect() + eventSubManager.reconnect() + } + } + + fun reconnectIfNecessary() { + readConnection.reconnectIfNecessary() + writeConnection.reconnectIfNecessary() + pubSubManager.reconnectIfNecessary() + eventSubManager.reconnectIfNecessary() + } + + fun joinIrcChannel(channel: UserName) { + readConnection.joinChannel(channel) + } + + fun partChannel(channel: UserName) { + scope.launch { readConnection.partChannel(channel) } + pubSubManager.removeChannel(channel) + eventSubManager.removeChannel(channel) + } + + suspend fun sendRaw(message: String) { + writeConnection.sendMessage(message) + } + + fun connectedAndHasModerateTopic(channel: UserName): Boolean = eventSubManager.connectedAndHasModerateTopic(channel) + + val connectedAndHasUserMessageTopic: Boolean get() = eventSubManager.connectedAndHasUserMessageTopic +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt new file mode 100644 index 000000000..3b7e9d6ef --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatEventProcessor.kt @@ -0,0 +1,644 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.eventapi.AutomodHeld +import com.flxrs.dankchat.data.api.eventapi.AutomodUpdate +import com.flxrs.dankchat.data.api.eventapi.ModerationAction +import com.flxrs.dankchat.data.api.eventapi.SystemMessage +import com.flxrs.dankchat.data.api.eventapi.UserMessageHeld +import com.flxrs.dankchat.data.api.eventapi.UserMessageUpdated +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodMessageStatus +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.AutomodReasonDto +import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.BlockedTermReasonDto +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.chat.ChatImportance +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.chat.toMentionTabItems +import com.flxrs.dankchat.data.irc.IrcMessage +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.toDisplayName +import com.flxrs.dankchat.data.toUserId +import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.data.twitch.badge.Badge +import com.flxrs.dankchat.data.twitch.badge.BadgeType +import com.flxrs.dankchat.data.twitch.chat.ChatEvent +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.data.twitch.message.AutomodMessage +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.NoticeMessage +import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.data.twitch.message.hasMention +import com.flxrs.dankchat.data.twitch.message.toDebugChatItem +import com.flxrs.dankchat.data.twitch.pubsub.PubSubMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.TextResource +import com.flxrs.dankchat.utils.extensions.withoutInvisibleChar +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger("ChatEventProcessor") + +@Single +class ChatEventProcessor( + private val messageProcessor: MessageProcessor, + private val chatMessageRepository: ChatMessageRepository, + private val chatConnector: ChatConnector, + private val chatNotificationRepository: ChatNotificationRepository, + private val chatChannelProvider: ChatChannelProvider, + private val recentMessagesHandler: RecentMessagesHandler, + private val userStateRepository: UserStateRepository, + private val usersRepository: UsersRepository, + private val authDataStore: AuthDataStore, + private val channelRepository: ChannelRepository, + private val chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val lastMessage = ConcurrentHashMap() + private val knownRewards = ConcurrentHashMap() + private val knownAutomodHeldIds: MutableSet = ConcurrentHashMap.newKeySet() + private val rewardMutex = Mutex() + + init { + scope.launch { collectReadConnectionEvents() } + scope.launch { collectWriteConnectionEvents() } + scope.launch { collectPubSubEvents() } + scope.launch { collectEventSubEvents() } + } + + fun getLastMessage(channel: UserName): String? = lastMessage[channel] + + fun getLastMessageForDisplay(channel: UserName?): String? = channel?.let { lastMessage[it]?.withoutInvisibleChar } + + fun setLastMessage( + channel: UserName, + message: String, + ) { + lastMessage[channel] = message + } + + fun removeLastMessage(channel: UserName) { + lastMessage.remove(channel) + } + + suspend fun loadRecentMessages( + channel: UserName, + isReconnect: Boolean = false, + ) { + val result = recentMessagesHandler.load(channel, isReconnect) + chatNotificationRepository.addMentionsDeduped(result.mentionItems) + usersRepository.updateUsers(channel, result.userSuggestions) + } + + private suspend fun collectReadConnectionEvents() { + chatConnector.readEvents.collect { event -> + when (event) { + is ChatEvent.Connected -> handleConnected(event.isAnonymous) + is ChatEvent.Closed -> handleDisconnect() + is ChatEvent.ChannelNonExistent -> postSystemMessageAndReconnect(SystemMessageType.ChannelNonExistent(event.channel), setOf(event.channel)) + is ChatEvent.LoginFailed -> postSystemMessageAndReconnect(SystemMessageType.LoginExpired) + is ChatEvent.Message -> onMessage(event.message) + is ChatEvent.Error -> handleDisconnect() + } + } + } + + private suspend fun collectWriteConnectionEvents() { + chatConnector.writeEvents.collect { event -> + if (event is ChatEvent.Message) { + onWriterMessage(event.message) + } + } + } + + private suspend fun collectPubSubEvents() { + chatConnector.pubSubEvents.collect { pubSubMessage -> + when (pubSubMessage) { + is PubSubMessage.PointRedemption -> handlePubSubReward(pubSubMessage) + is PubSubMessage.ModeratorAction -> handlePubSubModeration(pubSubMessage) + } + } + } + + private suspend fun collectEventSubEvents() { + chatConnector.eventSubEvents.collect { eventMessage -> + when (eventMessage) { + is ModerationAction -> handleEventSubModeration(eventMessage) + is AutomodHeld -> handleAutomodHeld(eventMessage) + is AutomodUpdate -> handleAutomodUpdate(eventMessage) + is UserMessageHeld -> handleUserMessageHeld(eventMessage) + is UserMessageUpdated -> handleUserMessageUpdated(eventMessage) + is SystemMessage -> postEventSubDebugMessage(eventMessage.message) + } + } + } + + private suspend fun handlePubSubReward(pubSubMessage: PubSubMessage.PointRedemption) { + if (messageProcessor.isUserBlocked(pubSubMessage.data.user.id)) { + return + } + + // Automatic rewards (gigantified emotes, animated messages) are stored + // for cost lookup but don't create separate PointRedemptionMessages. + val isAutomaticReward = pubSubMessage.data.reward.rewardType != null + if (pubSubMessage.data.reward.requiresUserInput || isAutomaticReward) { + val id = pubSubMessage.data.reward.effectiveId + rewardMutex.withLock { + when { + knownRewards.containsKey(id) -> { + logger.debug { "Removing known reward $id" } + knownRewards.remove(id) + } + + else -> { + logger.debug { "Received pubsub reward message with id $id" } + knownRewards[id] = pubSubMessage + } + } + } + } else { + val message = + runCatching { + messageProcessor.processReward( + PointRedemptionMessage.parsePointReward(pubSubMessage.timestamp, pubSubMessage.data), + ) + }.getOrNull() ?: return + + chatMessageRepository.addMessages(pubSubMessage.channelName, listOf(ChatItem(message))) + } + } + + private fun handlePubSubModeration(pubSubMessage: PubSubMessage.ModeratorAction) { + val (timestamp, channelId, data) = pubSubMessage + val channelName = channelRepository.tryGetUserNameById(channelId) ?: return + val message = + runCatching { + ModerationMessage.parseModerationAction(timestamp, channelName, data) + }.getOrElse { return } + + chatMessageRepository.applyModerationMessage(message) + } + + private fun handleEventSubModeration(eventMessage: ModerationAction) { + val (id, timestamp, channelName, data) = eventMessage + val message = + runCatching { + ModerationMessage.parseModerationAction(id, timestamp, channelName, data) + }.getOrElse { + logger.debug { "Failed to parse event sub moderation message: $it" } + return + } + + chatMessageRepository.applyModerationMessage(message) + } + + private fun handleAutomodHeld(eventMessage: AutomodHeld) { + val data = eventMessage.data + knownAutomodHeldIds.add(data.messageId) + val reason = formatAutomodReason(data.reason, data.automod, data.blockedTerm, data.message.text) + val userColor = usersRepository.getCachedUserColor(data.userLogin) + val automodBadge = + Badge.GlobalBadge( + title = "AutoMod", + badgeTag = "automod/1", + badgeInfo = null, + url = "", + type = BadgeType.Authority, + ) + val automodMsg = + AutomodMessage( + timestamp = eventMessage.timestamp.toEpochMilliseconds(), + id = eventMessage.id, + channel = eventMessage.channelName, + heldMessageId = data.messageId, + userName = data.userLogin, + userDisplayName = data.userName, + messageText = data.message.text, + reason = reason, + badges = listOf(automodBadge), + color = userColor, + ) + chatMessageRepository.addMessages(eventMessage.channelName, listOf(ChatItem(automodMsg, importance = ChatImportance.SYSTEM))) + } + + private fun handleAutomodUpdate(eventMessage: AutomodUpdate) { + knownAutomodHeldIds.remove(eventMessage.data.messageId) + val newStatus = + when (eventMessage.data.status) { + AutomodMessageStatus.Approved -> AutomodMessage.Status.Approved + AutomodMessageStatus.Denied -> AutomodMessage.Status.Denied + AutomodMessageStatus.Expired -> AutomodMessage.Status.Expired + } + chatMessageRepository.updateAutomodMessageStatus(eventMessage.channelName, eventMessage.data.messageId, newStatus) + } + + private fun handleUserMessageHeld(eventMessage: UserMessageHeld) { + val data = eventMessage.data + val automodBadge = + Badge.GlobalBadge( + title = "AutoMod", + badgeTag = "automod/1", + badgeInfo = null, + url = "", + type = BadgeType.Authority, + ) + val automodMsg = + AutomodMessage( + timestamp = eventMessage.timestamp.toEpochMilliseconds(), + id = eventMessage.id, + channel = eventMessage.channelName, + heldMessageId = data.messageId, + userName = data.userLogin, + userDisplayName = data.userName, + messageText = null, + reason = TextResource.Res(R.string.automod_user_held), + badges = listOf(automodBadge), + isUserSide = true, + ) + chatMessageRepository.addMessages(eventMessage.channelName, listOf(ChatItem(automodMsg, importance = ChatImportance.SYSTEM))) + } + + private fun handleUserMessageUpdated(eventMessage: UserMessageUpdated) { + val data = eventMessage.data + val automodBadge = + Badge.GlobalBadge( + title = "AutoMod", + badgeTag = "automod/1", + badgeInfo = null, + url = "", + type = BadgeType.Authority, + ) + val reason = + when (data.status) { + AutomodMessageStatus.Approved -> TextResource.Res(R.string.automod_user_accepted) + AutomodMessageStatus.Denied -> TextResource.Res(R.string.automod_user_denied) + AutomodMessageStatus.Expired -> TextResource.Res(R.string.automod_status_expired) + } + val automodMsg = + AutomodMessage( + timestamp = eventMessage.timestamp.toEpochMilliseconds(), + id = eventMessage.id, + channel = eventMessage.channelName, + heldMessageId = data.messageId, + userName = data.userLogin, + userDisplayName = data.userName, + messageText = null, + reason = reason, + badges = listOf(automodBadge), + isUserSide = true, + status = + when (data.status) { + AutomodMessageStatus.Approved -> AutomodMessage.Status.Approved + AutomodMessageStatus.Denied -> AutomodMessage.Status.Denied + AutomodMessageStatus.Expired -> AutomodMessage.Status.Expired + }, + ) + chatMessageRepository.addMessages(eventMessage.channelName, listOf(ChatItem(automodMsg, importance = ChatImportance.SYSTEM))) + } + + private suspend fun onMessage(msg: IrcMessage) { + when (msg.command) { + "CLEARCHAT" -> handleClearChat(msg) + "CLEARMSG" -> handleClearMsg(msg) + "ROOMSTATE" -> channelRepository.handleRoomState(msg) + "USERSTATE" -> userStateRepository.handleUserState(msg) + "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(msg) + "WHISPER" -> handleWhisper(msg) + else -> handleMessage(msg) + } + } + + private suspend fun onWriterMessage(message: IrcMessage) { + when (message.command) { + "USERSTATE" -> userStateRepository.handleUserState(message) + "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(message) + "NOTICE" -> handleMessage(message) + } + } + + private fun handleDisconnect() { + val state = ConnectionState.DISCONNECTED + chatConnector.setAllConnectionStates(state) + postSystemMessageAndReconnect(state.toSystemMessageType()) + } + + private fun handleConnected(isAnonymous: Boolean) { + val state = + when { + isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN + else -> ConnectionState.CONNECTED + } + val transitioning = + chatChannelProvider.channels.value + .orEmpty() + .filter { chatConnector.getConnectionState(it).value != state } + .toSet() + + chatConnector.setAllConnectionStates(state) + + if (transitioning.isNotEmpty()) { + postSystemMessageAndReconnect(state.toSystemMessageType(), transitioning) + } + } + + private fun handleClearChat(msg: IrcMessage) { + val parsed = + runCatching { + ModerationMessage.parseClearChat(msg) + }.getOrElse { return } + + chatMessageRepository.applyModerationMessage(parsed) + } + + private fun handleClearMsg(msg: IrcMessage) { + val parsed = + runCatching { + ModerationMessage.parseClearMessage(msg) + }.getOrElse { return } + + chatMessageRepository.applyModerationMessage(parsed) + } + + private suspend fun handleWhisper(ircMessage: IrcMessage) { + val userId = ircMessage.tags["user-id"]?.toUserId() + if (messageProcessor.isUserBlocked(userId)) { + return + } + + val userState = userStateRepository.userState.value + val recipient = userState.displayName ?: return + val message = + runCatching { + messageProcessor.processWhisper( + WhisperMessage.parseFromIrc(ircMessage, recipient, userState.color), + ) as? WhisperMessage + }.getOrNull() ?: return + + val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() + usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) + val color = message.color + if (color != null) { + usersRepository.cacheUserColor(message.name, color) + } + + val item = ChatItem(message, isMentionTab = true) + chatNotificationRepository.addWhisper(item) + chatNotificationRepository.incrementMentionCount(WhisperMessage.WHISPER_CHANNEL, 1) + chatNotificationRepository.emitMessages(listOf(item)) + } + + private suspend fun handleMessage(ircMessage: IrcMessage) { + if (ircMessage.command == "NOTICE") { + val msgId = ircMessage.tags["msg-id"] + if (msgId in NoticeMessage.ROOM_STATE_CHANGE_MSG_IDS) { + val channel = ircMessage.params[0].substring(1).toUserName() + if (chatConnector.connectedAndHasModerateTopic(channel)) { + return + } + } + if (msgId in AUTOMOD_NOTICE_MSG_IDS && chatConnector.connectedAndHasUserMessageTopic) { + return + } + } + + if (messageProcessor.isUserBlocked(ircMessage.tags["user-id"]?.toUserId())) { + return + } + + val resolvedReward = resolveReward(ircMessage) + val additionalMessages = resolvedReward?.toStandaloneMessage().orEmpty() + + val message = + runCatching { + messageProcessor.processIrcMessage(ircMessage) { channel, id -> + chatMessageRepository.findMessage(id, channel, chatNotificationRepository.whispers) + } + }.getOrElse { + logger.error(it) { "Failed to parse message" } + return + }?.let { resolveAutomaticRewardCost(it) } + ?.let { attachRewardInfo(it, resolvedReward) } ?: return + + if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { + chatMessageRepository.broadcastToAllChannels(ChatItem(message, importance = ChatImportance.SYSTEM)) + return + } + + trackUserState(message) + + val items = + buildList { + if (message is UserNoticeMessage && message.childMessage != null) { + add(ChatItem(message.childMessage)) + } + val importance = + when (message) { + is NoticeMessage -> ChatImportance.SYSTEM + else -> ChatImportance.REGULAR + } + add(ChatItem(message, importance = importance)) + } + + val channel = + when (message) { + is PrivMessage -> message.channel + is UserNoticeMessage -> message.channel + is NoticeMessage -> message.channel + else -> return + } + + chatMessageRepository.addMessages(channel, additionalMessages + items) + chatNotificationRepository.emitMessages(items) + + val mentions = + items + .filter { it.message.highlights.hasMention() } + .toMentionTabItems() + + if (mentions.isNotEmpty()) { + chatNotificationRepository.addMentions(mentions) + } + + if (channel != chatChannelProvider.activeChannel.value) { + if (mentions.isNotEmpty()) { + chatNotificationRepository.incrementMentionCount(channel, mentions.size) + } + + if (message is PrivMessage) { + chatNotificationRepository.setUnreadIfInactive(channel) + } + } + } + + private suspend fun resolveReward(ircMessage: IrcMessage): PubSubMessage.PointRedemption? { + val rewardId = ircMessage.tags["custom-reward-id"]?.takeIf { it.isNotEmpty() } ?: return null + if (knownAutomodHeldIds.remove(rewardId)) { + return null + } + + return rewardMutex.withLock { + knownRewards[rewardId] + ?.also { + logger.debug { "Removing known reward $rewardId" } + knownRewards.remove(rewardId) + } + ?: run { + logger.debug { "Waiting for pubsub reward message with id $rewardId" } + withTimeoutOrNull(PUBSUB_TIMEOUT) { + chatConnector.pubSubEvents + .filterIsInstance() + .first { it.data.reward.id == rewardId } + }?.also { knownRewards[rewardId] = it } + } + } + } + + private suspend fun PubSubMessage.PointRedemption.toStandaloneMessage(): List { + if (data.reward.requiresUserInput) return emptyList() + val processed = messageProcessor.processReward(PointRedemptionMessage.parsePointReward(timestamp, data)) + return listOfNotNull(processed?.let(::ChatItem)) + } + + private fun attachRewardInfo( + message: Message, + reward: PubSubMessage.PointRedemption?, + ): Message { + if (message !is PrivMessage || reward == null) return message + if (!reward.data.reward.requiresUserInput) return message + val rewardData = reward.data.reward + return message.copy( + rewardCost = rewardData.effectiveCost, + rewardTitle = rewardData.effectiveTitle, + rewardImageUrl = rewardData.images?.imageLarge ?: rewardData.defaultImages?.imageLarge, + ) + } + + private suspend fun resolveAutomaticRewardCost(message: Message): Message { + if (message !is PrivMessage) return message + val msgId = message.tags["msg-id"] ?: return message + if (msgId != "gigantified-emote-message" && msgId != "animated-message") return message + + val reward = rewardMutex.withLock { + knownRewards.remove(msgId) + } ?: withTimeoutOrNull(PUBSUB_TIMEOUT) { + chatConnector.pubSubEvents + .filterIsInstance() + .first { it.data.reward.effectiveId == msgId } + } + + val rewardData = reward?.data?.reward ?: return message + return message.copy( + rewardCost = rewardData.effectiveCost, + rewardImageUrl = rewardData.images?.imageLarge ?: rewardData.defaultImages?.imageLarge, + ) + } + + private fun trackUserState(message: Message) { + if (message !is PrivMessage) { + return + } + + val color = message.color + if (color != null) { + usersRepository.cacheUserColor(message.name, color) + } + + if (message.name == authDataStore.userName) { + val previousLastMessage = lastMessage[message.channel].orEmpty() + val lastMessageWasCommand = previousLastMessage.startsWith('.') || previousLastMessage.startsWith('/') + if (!lastMessageWasCommand && previousLastMessage.withoutInvisibleChar != message.originalMessage.withoutInvisibleChar) { + lastMessage[message.channel] = message.originalMessage + } + + val hasVip = message.badges.any { badge -> badge.badgeTag?.startsWith("vip") == true } + when { + hasVip -> userStateRepository.addVipChannel(message.channel) + else -> userStateRepository.removeVipChannel(message.channel) + } + } + + val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() + usersRepository.updateUser(message.channel, message.name.lowercase(), userForSuggestion) + } + + private fun postEventSubDebugMessage(message: String) { + val channels = chatChannelProvider.channels.value.orEmpty() + val chatItem = SystemMessageType.Debug(message).toDebugChatItem() + channels.forEach { channel -> + chatMessageRepository.addMessages(channel, listOf(chatItem)) + } + } + + private fun postSystemMessageAndReconnect( + type: SystemMessageType, + channels: Set = + chatChannelProvider.channels.value + .orEmpty() + .toSet(), + ) { + val reconnectedChannels = chatMessageRepository.addSystemMessageToChannels(type, channels) + reconnectedChannels.forEach { channel -> + scope.launch { + if (chatSettingsDataStore.settings.first().loadMessageHistoryOnReconnect) { + loadRecentMessages(channel, isReconnect = true) + } + } + } + } + + private fun ConnectionState.toSystemMessageType(): SystemMessageType = when (this) { + ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected + + ConnectionState.CONNECTED, + ConnectionState.CONNECTED_NOT_LOGGED_IN, + -> SystemMessageType.Connected + } + + private fun formatAutomodReason( + reason: String, + automod: AutomodReasonDto?, + blockedTerm: BlockedTermReasonDto?, + messageText: String, + ): TextResource = when { + reason == "automod" && automod != null -> { + TextResource.Res(R.string.automod_reason_category, persistentListOf(automod.category, automod.level)) + } + + reason == "blocked_term" && blockedTerm != null -> { + val terms = + blockedTerm.termsFound.joinToString { found -> + val start = found.boundary.startPos + val end = (found.boundary.endPos + 1).coerceAtMost(messageText.length) + "\"${messageText.substring(start, end)}\"" + } + val count = blockedTerm.termsFound.size + TextResource.PluralRes(R.plurals.automod_reason_blocked_terms, count, persistentListOf(count, terms)) + } + + else -> { + TextResource.Plain(reason) + } + } + + companion object { + private const val PUBSUB_TIMEOUT = 5000L + private val AUTOMOD_NOTICE_MSG_IDS = setOf("msg_rejected", "msg_rejected_mandatory") + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingFailure.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingFailure.kt index 3ab9e4227..a9ee6f121 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingFailure.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingFailure.kt @@ -1,3 +1,6 @@ package com.flxrs.dankchat.data.repo.chat -data class ChatLoadingFailure(val step: ChatLoadingStep, val failure: Throwable) +data class ChatLoadingFailure( + val step: ChatLoadingStep, + val failure: Throwable, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingStep.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingStep.kt index d934b3077..9608019c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingStep.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatLoadingStep.kt @@ -1,17 +1,28 @@ package com.flxrs.dankchat.data.repo.chat +import android.content.res.Resources +import androidx.annotation.StringRes +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName sealed interface ChatLoadingStep { - data class RecentMessages(val channel: UserName) : ChatLoadingStep + @get:StringRes + val displayNameRes: Int + + data class RecentMessages( + val channel: UserName, + ) : ChatLoadingStep { + override val displayNameRes = R.string.data_loading_step_recent_messages + } } -fun List.toMergedStrings(): List { +fun List.toDisplayStrings(resources: Resources): List { val recentMessages = filterIsInstance() return buildList { if (recentMessages.isNotEmpty()) { - add("RecentMessages(${recentMessages.joinToString(separator = ",") { it.channel.value }})") + val channels = recentMessages.joinToString { it.channel.value } + add(resources.getString(R.string.data_loading_step_with_channels, resources.getString(R.string.data_loading_step_recent_messages), channels)) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt new file mode 100644 index 000000000..cf9d5b102 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageRepository.kt @@ -0,0 +1,224 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.twitch.message.AutomodMessage +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.preferences.developer.ChatSendProtocol +import com.flxrs.dankchat.utils.extensions.addAndLimit +import com.flxrs.dankchat.utils.extensions.addSystemMessage +import com.flxrs.dankchat.utils.extensions.replaceOrAddModerationMessage +import com.flxrs.dankchat.utils.extensions.replaceWithTimeout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.plusAssign + +@Single +class ChatMessageRepository( + private val messageProcessor: MessageProcessor, + private val chatNotificationRepository: ChatNotificationRepository, + private val dispatchersProvider: DispatchersProvider, + chatSettingsDataStore: ChatSettingsDataStore, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val messages = ConcurrentHashMap>>() + private val _chatLoadingFailures = MutableStateFlow(emptySet()) + private val _sessionMessageCount = AtomicInt(0) + private val _ircSentCount = AtomicInt(0) + private val _helixSentCount = AtomicInt(0) + private val _sendFailureCount = AtomicInt(0) + + val sessionMessageCount: Int get() = _sessionMessageCount.load() + val ircSentCount: Int get() = _ircSentCount.load() + val helixSentCount: Int get() = _helixSentCount.load() + val sendFailureCount: Int get() = _sendFailureCount.load() + + fun incrementSentMessageCount(protocol: ChatSendProtocol) { + when (protocol) { + ChatSendProtocol.IRC -> _ircSentCount += 1 + ChatSendProtocol.Helix -> _helixSentCount += 1 + } + } + + fun incrementSendFailureCount() { + _sendFailureCount += 1 + } + + private val scrollBackLengthFlow = + chatSettingsDataStore.debouncedScrollBack + .onEach { length -> + messages.forEach { (_, flow) -> + if (flow.value.size > length) { + flow.update { it.takeLast(length) } + } + } + }.stateIn(scope, SharingStarted.Eagerly, 500) + val scrollBackLength get() = scrollBackLengthFlow.value + + val chatLoadingFailures = _chatLoadingFailures.asStateFlow() + + fun getChat(channel: UserName): StateFlow> = messages.getOrPut(channel) { MutableStateFlow(emptyList()) }.also { + updateChannelKeys() + } + + fun getMessagesFlow(channel: UserName): MutableStateFlow>? = messages[channel] + + val channels: Flow> + get() = _channelKeys.map { it.toList() } + + private val _channelKeys = MutableStateFlow>(emptySet()) + + private fun updateChannelKeys() { + _channelKeys.value = messages.keys.toSet() + } + + fun getAllChat(): Flow> = _channelKeys.flatMapLatest { keys -> + when { + keys.isEmpty() -> flowOf(emptyList()) + + else -> combine(keys.map { getChat(it) }) { arrays -> + arrays.flatMap { it.toList() }.sortedBy { it.message.timestamp } + } + } + } + + fun findMessage( + messageId: String, + channel: UserName?, + whispers: StateFlow>, + ): Message? = (channel?.let { messages[it] } ?: whispers).value.find { it.message.id == messageId }?.message + + fun addMessages( + channel: UserName, + items: List, + ) { + _sessionMessageCount += items.size + messages[channel]?.update { current -> + current.addAndLimit(items = items, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + + fun applyModerationMessage(message: ModerationMessage) { + messages[message.channel]?.update { current -> + when (message.action) { + ModerationMessage.Action.Delete, + ModerationMessage.Action.SharedDelete, + -> current.replaceWithTimeout(message, scrollBackLength, messageProcessor::onMessageRemoved) + + else -> current.replaceOrAddModerationMessage(message, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + } + + fun broadcastToAllChannels(item: ChatItem) { + messages.keys.forEach { channel -> + messages[channel]?.update { current -> + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + } + + fun clearMessages(channel: UserName) { + messages[channel]?.value = emptyList() + } + + fun updateAutomodMessageStatus( + channel: UserName, + heldMessageId: String, + status: AutomodMessage.Status, + ) { + messages[channel]?.update { current -> + current.map { item -> + val msg = item.message + when { + msg is AutomodMessage && msg.heldMessageId == heldMessageId -> { + item.copy(tag = item.tag + 1, message = msg.copy(status = status)) + } + + else -> { + item + } + } + } + } + } + + suspend fun reparseAllEmotesAndBadges() = withContext(dispatchersProvider.default) { + messages.values + .map { flow -> + async { + flow.update { items -> + items.map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + } + } + } + }.awaitAll() + chatNotificationRepository.reparseAll() + } + + fun addSystemMessage( + channel: UserName, + type: SystemMessageType, + ) { + messages[channel]?.update { + it.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) + } + } + + fun addSystemMessageToChannels( + type: SystemMessageType, + channels: Set = messages.keys, + ): Set { + val reconnectedChannels = mutableSetOf() + channels.forEach { channel -> + val flow = messages[channel] ?: return@forEach + val current = flow.value + flow.value = + current.addSystemMessage(type, scrollBackLength, messageProcessor::onMessageRemoved) { + reconnectedChannels += channel + } + } + return reconnectedChannels + } + + fun addLoadingFailure(failure: ChatLoadingFailure) { + _chatLoadingFailures.update { it + failure } + } + + fun clearChatLoadingFailures() = _chatLoadingFailures.update { emptySet() } + + fun createMessageFlows(channel: UserName) { + messages.putIfAbsent(channel, MutableStateFlow(emptyList())) + } + + fun removeMessageFlows(channel: UserName) { + messages.remove(channel) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt new file mode 100644 index 000000000..b95c3f02b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatMessageSender.kt @@ -0,0 +1,154 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.helix.HelixApiClient +import com.flxrs.dankchat.data.api.helix.HelixApiException +import com.flxrs.dankchat.data.api.helix.HelixError +import com.flxrs.dankchat.data.api.helix.dto.SendChatMessageRequestDto +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.preferences.developer.ChatSendProtocol +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.utils.extensions.INVISIBLE_CHAR +import io.github.oshai.kotlinlogging.KotlinLogging +import org.koin.core.annotation.Single + +private val logger = KotlinLogging.logger("ChatMessageSender") + +@Single +class ChatMessageSender( + private val chatConnector: ChatConnector, + private val helixApiClient: HelixApiClient, + private val channelRepository: ChannelRepository, + private val authDataStore: AuthDataStore, + private val chatMessageRepository: ChatMessageRepository, + private val chatEventProcessor: ChatEventProcessor, + private val developerSettingsDataStore: DeveloperSettingsDataStore, +) { + suspend fun send( + channel: UserName, + message: String, + replyId: String? = null, + forceIrc: Boolean = false, + ) { + if (message.isBlank()) { + return + } + + val protocol = developerSettingsDataStore.current().chatSendProtocol + when { + forceIrc || protocol == ChatSendProtocol.IRC -> sendViaIrc(channel, message, replyId) + else -> sendViaHelix(channel, message, replyId) + } + } + + private suspend fun sendViaIrc( + channel: UserName, + message: String, + replyId: String?, + ) { + val trimmedMessage = message.trimEnd() + val replyIdOrBlank = replyId?.let { "@reply-parent-msg-id=$it " }.orEmpty() + val currentLastMessage = chatEventProcessor.getLastMessage(channel).orEmpty() + + val messageWithSuffix = + when { + currentLastMessage == trimmedMessage -> applyAntiDuplicate(trimmedMessage) + else -> trimmedMessage + } + + chatEventProcessor.setLastMessage(channel, messageWithSuffix) + chatConnector.sendRaw("${replyIdOrBlank}PRIVMSG #$channel :$messageWithSuffix") + chatMessageRepository.incrementSentMessageCount(ChatSendProtocol.IRC) + } + + private suspend fun sendViaHelix( + channel: UserName, + message: String, + replyId: String?, + ) { + val trimmedMessage = message.trimEnd() + val senderId = + authDataStore.userIdString ?: run { + postError(channel, SystemMessageType.SendNotLoggedIn) + return + } + val broadcasterId = + channelRepository.getChannel(channel)?.id ?: run { + postError(channel, SystemMessageType.SendChannelNotResolved(channel)) + return + } + + val request = + SendChatMessageRequestDto( + broadcasterId = broadcasterId, + senderId = senderId, + message = trimmedMessage, + replyParentMessageId = replyId, + ) + + helixApiClient.postChatMessage(request).fold( + onSuccess = { response -> + when { + response.isSent -> { + chatEventProcessor.setLastMessage(channel, trimmedMessage) + chatMessageRepository.incrementSentMessageCount(ChatSendProtocol.Helix) + } + + else -> { + val type = + when (val reason = response.dropReason) { + null -> SystemMessageType.SendNotDelivered + else -> SystemMessageType.SendDropped(reason.message, reason.code) + } + postError(channel, type) + } + } + }, + onFailure = { throwable -> + logger.error(throwable) { "Helix send failed" } + postError(channel, throwable.toSendErrorType()) + }, + ) + } + + private fun postError( + channel: UserName, + type: SystemMessageType, + ) { + chatMessageRepository.addSystemMessage(channel, type) + chatMessageRepository.incrementSendFailureCount() + } + + private fun applyAntiDuplicate(message: String): String { + val startIndex = + when { + message.startsWith('/') || message.startsWith('.') -> message.indexOf(' ').let { if (it == -1) 0 else it + 1 } + else -> 0 + } + val spaceIndex = message.indexOf(' ', startIndex) + + return when { + spaceIndex != -1 -> message.replaceRange(spaceIndex, spaceIndex + 1, " ") + else -> "$message $INVISIBLE_CHAR" + } + } + + private fun Throwable.toSendErrorType(): SystemMessageType = when (this) { + is HelixApiException -> { + when (error) { + HelixError.NotLoggedIn -> SystemMessageType.SendNotLoggedIn + HelixError.MissingScopes -> SystemMessageType.SendMissingScopes + HelixError.UserNotAuthorized -> SystemMessageType.SendNotAuthorized + HelixError.MessageTooLarge -> SystemMessageType.SendMessageTooLarge + HelixError.ChatMessageRateLimited -> SystemMessageType.SendRateLimited + else -> SystemMessageType.SendFailed(message) + } + } + + else -> { + SystemMessageType.SendFailed(message) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt new file mode 100644 index 000000000..4bea8344d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatNotificationRepository.kt @@ -0,0 +1,142 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.extensions.addAndLimit +import com.flxrs.dankchat.utils.extensions.assign +import com.flxrs.dankchat.utils.extensions.clear +import com.flxrs.dankchat.utils.extensions.firstValue +import com.flxrs.dankchat.utils.extensions.increment +import com.flxrs.dankchat.utils.extensions.mutableSharedFlowOf +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.Single + +@Single +class ChatNotificationRepository( + private val messageProcessor: MessageProcessor, + chatSettingsDataStore: ChatSettingsDataStore, + private val chatChannelProvider: ChatChannelProvider, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + + private val _mentions = MutableStateFlow>(persistentListOf()) + private val _whispers = MutableStateFlow>(persistentListOf()) + private val _messageUpdates = MutableSharedFlow>(replay = 0, extraBufferCapacity = 10) + private val _channelMentionCount = mutableSharedFlowOf(mutableMapOf()) + private val _unreadMessagesMap = mutableSharedFlowOf(mutableMapOf()) + + private val scrollBackLengthFlow = + chatSettingsDataStore.debouncedScrollBack + .stateIn(scope, SharingStarted.Eagerly, 500) + private val scrollBackLength get() = scrollBackLengthFlow.value + + val messageUpdates: SharedFlow> = _messageUpdates.asSharedFlow() + val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() + val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() + val mentions: StateFlow> = _mentions + val whispers: StateFlow> = _whispers + + suspend fun reparseAll() { + _mentions.update { items -> + items + .map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + }.toImmutableList() + } + _whispers.update { items -> + items + .map { + it.copy( + tag = it.tag + 1, + message = messageProcessor.reparseEmotesAndBadges(it.message), + ) + }.toImmutableList() + } + } + + fun addMentions(items: List) { + if (items.isEmpty()) return + _mentions.update { current -> + current.addAndLimit(items, scrollBackLength, messageProcessor::onMessageRemoved).toImmutableList() + } + } + + fun addMentionsDeduped(items: List) { + if (items.isEmpty()) return + _mentions.update { current -> + (current + items) + .distinctBy { it.message.id } + .sortedBy { it.message.timestamp } + .toImmutableList() + } + } + + fun addWhisper(item: ChatItem) { + _whispers.update { current -> + current.addAndLimit(item, scrollBackLength, messageProcessor::onMessageRemoved).toImmutableList() + } + } + + fun emitMessages(items: List) { + _messageUpdates.tryEmit(items) + } + + fun setUnreadIfInactive(channel: UserName) { + if (channel != chatChannelProvider.activeChannel.value) { + val isUnread = _unreadMessagesMap.firstValue[channel] == true + if (!isUnread) { + _unreadMessagesMap.assign(channel, true) + } + } + } + + fun incrementMentionCount( + channel: UserName, + count: Int, + ) { + _channelMentionCount.increment(channel, count) + } + + fun clearMentionCount(channel: UserName) = with(_channelMentionCount) { + tryEmit(firstValue.apply { set(channel, 0) }) + } + + fun clearMentionCounts() = with(_channelMentionCount) { + tryEmit(firstValue.apply { keys.forEach { if (it != WhisperMessage.WHISPER_CHANNEL) set(it, 0) } }) + } + + fun clearUnreadMessage(channel: UserName) { + _unreadMessagesMap.assign(channel, false) + } + + fun createMentionFlows(channel: UserName) { + with(_channelMentionCount) { + if (!firstValue.contains(WhisperMessage.WHISPER_CHANNEL)) tryEmit(firstValue.apply { set(channel, 0) }) + if (!firstValue.contains(channel)) tryEmit(firstValue.apply { set(channel, 0) }) + } + } + + fun removeMentionFlows(channel: UserName) { + _channelMentionCount.clear(channel) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt index a06a26c59..536681ab1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/ChatRepository.kt @@ -1,920 +1,154 @@ package com.flxrs.dankchat.data.repo.chat import android.graphics.Color -import android.util.Log -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.chat.toMentionTabItems -import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.api.eventapi.EventSubManager -import com.flxrs.dankchat.data.api.eventapi.ModerationAction -import com.flxrs.dankchat.data.api.eventapi.SystemMessage -import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiClient -import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiException -import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesError -import com.flxrs.dankchat.data.api.recentmessages.dto.RecentMessagesDto -import com.flxrs.dankchat.data.irc.IrcMessage -import com.flxrs.dankchat.data.repo.HighlightsRepository -import com.flxrs.dankchat.data.repo.IgnoresRepository -import com.flxrs.dankchat.data.repo.RepliesRepository -import com.flxrs.dankchat.data.repo.UserDisplayRepository +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.chat.ChatItem import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.repo.emote.EmoteRepository import com.flxrs.dankchat.data.toDisplayName -import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.toUserName -import com.flxrs.dankchat.data.twitch.chat.ChatConnection -import com.flxrs.dankchat.data.twitch.chat.ChatEvent -import com.flxrs.dankchat.data.twitch.chat.ConnectionState -import com.flxrs.dankchat.data.twitch.message.Message -import com.flxrs.dankchat.data.twitch.message.ModerationMessage -import com.flxrs.dankchat.data.twitch.message.NoticeMessage -import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage -import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.SystemMessageType -import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage -import com.flxrs.dankchat.data.twitch.message.hasMention import com.flxrs.dankchat.data.twitch.message.toChatItem -import com.flxrs.dankchat.data.twitch.pubsub.PubSubManager -import com.flxrs.dankchat.data.twitch.pubsub.PubSubMessage -import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.di.ReadConnection -import com.flxrs.dankchat.di.WriteConnection -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.extensions.INVISIBLE_CHAR -import com.flxrs.dankchat.utils.extensions.addAndLimit -import com.flxrs.dankchat.utils.extensions.addSystemMessage -import com.flxrs.dankchat.utils.extensions.assign -import com.flxrs.dankchat.utils.extensions.clear -import com.flxrs.dankchat.utils.extensions.codePointAsString -import com.flxrs.dankchat.utils.extensions.firstValue -import com.flxrs.dankchat.utils.extensions.increment -import com.flxrs.dankchat.utils.extensions.mutableSharedFlowOf -import com.flxrs.dankchat.utils.extensions.replaceOrAddHistoryModerationMessage -import com.flxrs.dankchat.utils.extensions.replaceOrAddModerationMessage -import com.flxrs.dankchat.utils.extensions.replaceWithTimeout -import com.flxrs.dankchat.utils.extensions.withoutInvisibleChar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterIsInstance +import com.flxrs.dankchat.utils.TextResource import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import java.util.concurrent.ConcurrentHashMap -import kotlin.system.measureTimeMillis @Single class ChatRepository( - private val recentMessagesApiClient: RecentMessagesApiClient, - private val emoteRepository: EmoteRepository, - private val highlightsRepository: HighlightsRepository, - private val ignoresRepository: IgnoresRepository, - private val userDisplayRepository: UserDisplayRepository, - private val repliesRepository: RepliesRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatMessageRepository: ChatMessageRepository, + private val chatConnector: ChatConnector, + private val chatNotificationRepository: ChatNotificationRepository, + private val chatEventProcessor: ChatEventProcessor, private val userStateRepository: UserStateRepository, private val usersRepository: UsersRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, - private val chatSettingsDataStore: ChatSettingsDataStore, - private val pubSubManager: PubSubManager, + private val emoteRepository: EmoteRepository, private val channelRepository: ChannelRepository, - private val eventSubManager: EventSubManager, - @Named(type = ReadConnection::class) private val readConnection: ChatConnection, - @Named(type = WriteConnection::class) private val writeConnection: ChatConnection, - dispatchersProvider: DispatchersProvider, + private val messageProcessor: MessageProcessor, + private val chatMessageSender: ChatMessageSender, + private val authDataStore: AuthDataStore, + private val chatSettingsDataStore: ChatSettingsDataStore, ) { - - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - private val _activeChannel = MutableStateFlow(null) - private val _channels = MutableStateFlow?>(null) - - private val _notificationsFlow = MutableSharedFlow>(replay = 0, extraBufferCapacity = 10) - private val _channelMentionCount = mutableSharedFlowOf(mutableMapOf()) - private val _unreadMessagesMap = mutableSharedFlowOf(mutableMapOf()) - private val messages = ConcurrentHashMap>>() - private val _mentions = MutableStateFlow>(emptyList()) - private val _whispers = MutableStateFlow>(emptyList()) - private val connectionState = ConcurrentHashMap>() - private val _chatLoadingFailures = MutableStateFlow(emptySet()) - - private var lastMessage = ConcurrentHashMap() - private val loadedRecentsInChannels = mutableSetOf() - private val knownRewards = ConcurrentHashMap() - private val rewardMutex = Mutex() - - private val scrollBackLengthFlow = chatSettingsDataStore.debouncedScrollBack - .onEach { length -> - messages.forEach { (_, messagesFlow) -> - if (messagesFlow.value.size > length) { - messagesFlow.update { - it.takeLast(length) - } - } - } - } - .stateIn(scope, SharingStarted.Eagerly, 500) - private val scrollBackLength get() = scrollBackLengthFlow.value + val activeChannel get() = chatChannelProvider.activeChannel + val channels get() = chatChannelProvider.channels init { - scope.launch { - readConnection.messages.collect { event -> - when (event) { - is ChatEvent.Connected -> handleConnected(event.channel, event.isAnonymous) - is ChatEvent.Closed -> handleDisconnect() - is ChatEvent.ChannelNonExistent -> makeAndPostSystemMessage(SystemMessageType.ChannelNonExistent(event.channel), setOf(event.channel)) - is ChatEvent.LoginFailed -> makeAndPostSystemMessage(SystemMessageType.LoginExpired) - is ChatEvent.Message -> onMessage(event.message) - is ChatEvent.Error -> handleDisconnect() - } - } - } - scope.launch { - writeConnection.messages.collect { event -> - if (event !is ChatEvent.Message) return@collect - onWriterMessage(event.message) - } - } - scope.launch { - pubSubManager.messages.collect { pubSubMessage -> - when (pubSubMessage) { - is PubSubMessage.PointRedemption -> { - if (ignoresRepository.isUserBlocked(pubSubMessage.data.user.id)) { - return@collect - } - - if (pubSubMessage.data.reward.requiresUserInput) { - val id = pubSubMessage.data.reward.id - rewardMutex.withLock { - when { - // already handled, remove it and do nothing else - knownRewards.containsKey(id) -> { - Log.d(TAG, "Removing known reward $id") - knownRewards.remove(id) - } - - else -> { - Log.d(TAG, "Received pubsub reward message with id $id") - knownRewards[id] = pubSubMessage - } - } - } - } else { - val message = runCatching { - PointRedemptionMessage - .parsePointReward(pubSubMessage.timestamp, pubSubMessage.data) - .applyIgnores() - ?.calculateHighlightState() - ?.calculateUserDisplays() - }.getOrNull() ?: return@collect - - messages[pubSubMessage.channelName]?.update { - it.addAndLimit(ChatItem(message), scrollBackLength, ::onMessageRemoved) - } - } - } - - is PubSubMessage.Whisper -> { - if (ignoresRepository.isUserBlocked(pubSubMessage.data.userId)) { - return@collect - } - - val message = runCatching { - WhisperMessage.fromPubSub(pubSubMessage.data) - .applyIgnores() - ?.calculateHighlightState() - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() as? WhisperMessage - }.getOrNull() ?: return@collect - - val item = ChatItem(message, isMentionTab = true) - _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, ::onMessageRemoved) - } - - if (pubSubMessage.data.userId == userStateRepository.userState.value.userId) { - return@collect - } - - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) - _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) - _notificationsFlow.tryEmit(listOf(item)) - } - - is PubSubMessage.ModeratorAction -> { - val (timestamp, channelId, data) = pubSubMessage - val channelName = channelRepository.tryGetUserNameById(channelId) ?: return@collect - val message = runCatching { - ModerationMessage.parseModerationAction(timestamp, channelName, data) - }.getOrElse { - return@collect - } - - messages[message.channel]?.update { current -> - when (message.action) { - ModerationMessage.Action.Delete -> current.replaceWithTimeout(message, scrollBackLength, ::onMessageRemoved) - else -> current.replaceOrAddModerationMessage(message, scrollBackLength, ::onMessageRemoved) - } - } - } - } - } - } - scope.launch { - eventSubManager.events.collect { eventMessage -> - when (eventMessage) { - is ModerationAction -> { - val (id, timestamp, channelName, data) = eventMessage - val message = runCatching { - ModerationMessage.parseModerationAction(id, timestamp, channelName, data) - }.getOrElse { - Log.d(TAG, "Failed to parse event sub moderation message: $it") - return@collect - } - - messages[message.channel]?.update { current -> - when (message.action) { - ModerationMessage.Action.Delete, - ModerationMessage.Action.SharedDelete -> current.replaceWithTimeout(message, scrollBackLength, ::onMessageRemoved) - - else -> current.replaceOrAddModerationMessage(message, scrollBackLength, ::onMessageRemoved) - } - } - } - - is SystemMessage -> makeAndPostSystemMessage(type = SystemMessageType.Custom(eventMessage.message)) - } - } - } + chatChannelProvider.channels.value?.forEach { createFlowsIfNecessary(it) } } - val notificationsFlow: SharedFlow> = _notificationsFlow.asSharedFlow() - val channelMentionCount: SharedFlow> = _channelMentionCount.asSharedFlow() - val unreadMessagesMap: SharedFlow> = _unreadMessagesMap.asSharedFlow() - val hasMentions = channelMentionCount.map { it.any { channel -> channel.key != WhisperMessage.WHISPER_CHANNEL && channel.value > 0 } } - val hasWhispers = channelMentionCount.map { it.getOrDefault(WhisperMessage.WHISPER_CHANNEL, 0) > 0 } - val mentions: StateFlow> = _mentions - val whispers: StateFlow> = _whispers - val activeChannel: StateFlow = _activeChannel.asStateFlow() - val channels: StateFlow?> = _channels.asStateFlow() - val chatLoadingFailures = _chatLoadingFailures.asStateFlow() - - fun getChat(channel: UserName): StateFlow> = messages.getOrPut(channel) { MutableStateFlow(emptyList()) } - fun getConnectionState(channel: UserName): StateFlow = connectionState.getOrPut(channel) { MutableStateFlow(ConnectionState.DISCONNECTED) } - - fun findMessage(messageId: String, channel: UserName?) = (channel?.let { messages[channel] } ?: whispers).value.find { it.message.id == messageId }?.message - - fun clearChatLoadingFailures() = _chatLoadingFailures.update { emptySet() } - - suspend fun loadRecentMessagesIfEnabled(channel: UserName) { - when { - chatSettingsDataStore.settings.first().loadMessageHistory -> loadRecentMessages(channel) - else -> messages[channel]?.update { current -> - val message = SystemMessageType.NoHistoryLoaded.toChatItem() - listOf(message).addAndLimit(current, scrollBackLength, ::onMessageRemoved, checkForDuplications = true) - } + fun joinChannel(channel: UserName): List { + val currentChannels = channels.value.orEmpty() + if (channel in currentChannels) { + return currentChannels } - } - suspend fun reparseAllEmotesAndBadges() = withContext(Dispatchers.Default) { - messages.values.map { flow -> - async { - flow.update { messages -> - messages.map { - it.copy( - tag = it.tag + 1, - message = it.message - .parseEmotesAndBadges() - .updateMessageInThread(), - ) - } - } - } - }.awaitAll() - } - - fun setActiveChannel(channel: UserName?) { - _activeChannel.value = channel - } + val updatedChannels = currentChannels + channel + chatChannelProvider.setChannels(updatedChannels) - fun clearMentionCount(channel: UserName) = with(_channelMentionCount) { - tryEmit(firstValue.apply { set(channel, 0) }) - } + createFlowsIfNecessary(channel) + chatMessageRepository.clearMessages(channel) - fun clearMentionCounts() = with(_channelMentionCount) { - tryEmit(firstValue.apply { keys.forEach { if (it != WhisperMessage.WHISPER_CHANNEL) set(it, 0) } }) - } + chatConnector.joinIrcChannel(channel) - fun clearUnreadMessage(channel: UserName) { - _unreadMessagesMap.assign(channel, false) + return updatedChannels } - fun clear(channel: UserName) { - messages[channel]?.value = emptyList() - } + fun updateChannels(updatedChannels: List): List { + val currentChannels = channels.value.orEmpty() + val removedChannels = currentChannels - updatedChannels.toSet() - fun closeAndReconnect() = scope.launch { - val channels = channels.value.orEmpty() + removedChannels.forEach { partChannel(it) } - readConnection.close() - writeConnection.close() - pubSubManager.close() - eventSubManager.close() - userStateRepository.clear() - connectAndJoin(channels) + chatChannelProvider.setChannels(updatedChannels) + return removedChannels } - fun reconnect(reconnectPubsub: Boolean = true) { - readConnection.reconnect() - writeConnection.reconnect() - - if (reconnectPubsub) { - pubSubManager.reconnect() - eventSubManager.reconnect() - } + fun createFlowsIfNecessary(channel: UserName) { + chatMessageRepository.createMessageFlows(channel) + chatConnector.createConnectionState(channel) + chatNotificationRepository.createMentionFlows(channel) + usersRepository.initChannel(channel) + channelRepository.initRoomState(channel) } - fun reconnectIfNecessary() { - readConnection.reconnectIfNecessary() - writeConnection.reconnectIfNecessary() - pubSubManager.reconnectIfNecessary() - eventSubManager.reconnectIfNecessary() + suspend fun sendMessage( + input: String, + replyId: String? = null, + forceIrc: Boolean = false, + ) { + val channel = chatChannelProvider.activeChannel.value ?: return + chatMessageSender.send(channel, input, replyId, forceIrc) } - fun getLastMessage(): String? = lastMessage[activeChannel.value]?.withoutInvisibleChar - fun fakeWhisperIfNecessary(input: String) { - if (pubSubManager.connectedAndHasWhisperTopic) { - return - } - // fake whisper handling val split = input.split(" ") if (split.size > 2 && (split[0] == "/w" || split[0] == ".w") && split[1].isNotBlank()) { val message = input.substring(4 + split[1].length) val emotes = emoteRepository.parse3rdPartyEmotes(message, WhisperMessage.WHISPER_CHANNEL, withTwitch = true) val userState = userStateRepository.userState.value - val name = dankChatPreferenceStore.userName ?: return + val name = authDataStore.userName ?: return val displayName = userState.displayName ?: return - val fakeMessage = WhisperMessage( - userId = userState.userId, - name = name, - displayName = displayName, - color = userState.color?.let(Color::parseColor) ?: Message.DEFAULT_COLOR, - recipientId = null, - recipientColor = Message.DEFAULT_COLOR, - recipientName = split[1].toUserName(), - recipientDisplayName = split[1].toDisplayName(), - message = message, - rawEmotes = "", - rawBadges = "", - emotes = emotes, - ) + val fakeMessage = + WhisperMessage( + userId = userState.userId, + name = name, + displayName = displayName, + color = userState.color?.let(Color::parseColor), + recipientId = null, + recipientColor = usersRepository.getCachedUserColor(split[1].toUserName()), + recipientName = split[1].toUserName(), + recipientDisplayName = split[1].toDisplayName(), + message = message, + rawEmotes = "", + rawBadges = "", + emotes = emotes, + ) val fakeItem = ChatItem(fakeMessage, isMentionTab = true) - _whispers.update { - it.addAndLimit(fakeItem, scrollBackLength, ::onMessageRemoved) - } + chatNotificationRepository.addWhisper(fakeItem) } } - fun sendMessage(input: String, replyId: String? = null) { - val channel = activeChannel.value ?: return - val preparedMessage = prepareMessage(channel, input, replyId) ?: return - writeConnection.sendMessage(preparedMessage) - } - - fun connectAndJoin(channels: List = dankChatPreferenceStore.channels) { - if (!pubSubManager.connected) { - pubSubManager.start() - } - - if (!readConnection.connected) { - connect() - joinChannels(channels) - } - } - - fun joinChannel(channel: UserName, listenToPubSub: Boolean = true): List { - val channels = channels.value.orEmpty() - if (channel in channels) - return channels - - val updatedChannels = channels + channel - _channels.value = updatedChannels - - createFlowsIfNecessary(channel) - messages[channel]?.value = emptyList() - - - readConnection.joinChannel(channel) - - if (listenToPubSub) { - pubSubManager.addChannel(channel) - } - - return updatedChannels - } - - fun createFlowsIfNecessary(channel: UserName) { - messages.putIfAbsent(channel, MutableStateFlow(emptyList())) - connectionState.putIfAbsent(channel, MutableStateFlow(ConnectionState.DISCONNECTED)) - usersRepository.initChannel(channel) - channelRepository.initRoomState(channel) - - with(_channelMentionCount) { - if (!firstValue.contains(WhisperMessage.WHISPER_CHANNEL)) tryEmit(firstValue.apply { set(channel, 0) }) - if (!firstValue.contains(channel)) tryEmit(firstValue.apply { set(channel, 0) }) - } - } + fun getLastMessage(): String? = chatEventProcessor.getLastMessageForDisplay(chatChannelProvider.activeChannel.value) - fun updateChannels(updatedChannels: List): List { - val currentChannels = channels.value.orEmpty() - val removedChannels = currentChannels - updatedChannels.toSet() + fun appendLastMessage( + channel: UserName, + message: String, + ) = chatEventProcessor.setLastMessage(channel, message) - removedChannels.forEach { - partChannel(it) + suspend fun loadRecentMessagesIfEnabled(channel: UserName) { + if (chatSettingsDataStore.settings.first().loadMessageHistory) { + chatEventProcessor.loadRecentMessages(channel) } - - _channels.value = updatedChannels - return removedChannels } - fun appendLastMessage(channel: UserName, message: String) { - lastMessage[channel] = message + fun makeAndPostCustomSystemMessage( + msg: TextResource, + channel: UserName, + ) { + chatMessageRepository.addSystemMessage(channel, SystemMessageType.Custom(msg)) } - private fun connect() { - readConnection.connect() - writeConnection.connect() + fun makeAndPostCustomSystemMessage( + msg: String, + channel: UserName, + ) { + makeAndPostCustomSystemMessage(TextResource.Plain(msg), channel) } - private fun joinChannels(channels: List) { - _channels.value = channels - if (channels.isEmpty()) return - - channels.onEach { - createFlowsIfNecessary(it) - if (messages[it]?.value == null) { - messages[it]?.value = emptyList() - } - } - - readConnection.joinChannels(channels) - } - - private fun partChannel(channel: UserName): List { - val updatedChannels = channels.value.orEmpty() - channel - _channels.value = updatedChannels - - removeChannelData(channel) - readConnection.partChannel(channel) - - pubSubManager.removeChannel(channel) - eventSubManager.removeChannel(channel) - - return updatedChannels - } - - private fun removeChannelData(channel: UserName) { - messages.remove(channel) - connectionState.remove(channel) - lastMessage.remove(channel) - _channelMentionCount.clear(channel) - loadedRecentsInChannels.remove(channel) + private fun partChannel(channel: UserName) { + chatMessageRepository.removeMessageFlows(channel) + chatConnector.removeConnectionState(channel) + chatConnector.partChannel(channel) + chatNotificationRepository.removeMentionFlows(channel) + chatEventProcessor.removeLastMessage(channel) usersRepository.removeChannel(channel) userStateRepository.removeChannel(channel) channelRepository.removeRoomState(channel) emoteRepository.removeChannel(channel) - repliesRepository.cleanupMessageThreadsInChannel(channel) - } - - private fun prepareMessage(channel: UserName, message: String, replyId: String?): String? { - if (message.isBlank()) return null - val trimmedMessage = message.trimEnd() - val replyIdOrBlank = replyId?.let { "@reply-parent-msg-id=$it " }.orEmpty() - - val messageWithSuffix = when (lastMessage[channel].orEmpty()) { - trimmedMessage -> when { - trimmedMessage.endsWith(INVISIBLE_CHAR) -> trimmedMessage.withoutInvisibleChar - else -> "$trimmedMessage $INVISIBLE_CHAR" - } - - else -> trimmedMessage - } - - lastMessage[channel] = messageWithSuffix - return "${replyIdOrBlank}PRIVMSG #$channel :$messageWithSuffix" - } - - private suspend fun onMessage(msg: IrcMessage): List? { - when (msg.command) { - "CLEARCHAT" -> handleClearChat(msg) - "CLEARMSG" -> handleClearMsg(msg) - "ROOMSTATE" -> channelRepository.handleRoomState(msg) - "USERSTATE" -> userStateRepository.handleUserState(msg) - "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(msg) - "WHISPER" -> handleWhisper(msg) - else -> handleMessage(msg) - } - return null - } - - private suspend fun onWriterMessage(message: IrcMessage) { - when (message.command) { - "USERSTATE" -> userStateRepository.handleUserState(message) - "GLOBALUSERSTATE" -> userStateRepository.handleGlobalUserState(message) - "NOTICE" -> handleMessage(message) - } - } - - private fun handleDisconnect() { - val state = ConnectionState.DISCONNECTED - connectionState.keys.forEach { - connectionState[it]?.value = state - } - makeAndPostSystemMessage(state.toSystemMessageType()) - - } - - private fun handleConnected(channel: UserName, isAnonymous: Boolean) { - val state = when { - isAnonymous -> ConnectionState.CONNECTED_NOT_LOGGED_IN - else -> ConnectionState.CONNECTED - } - makeAndPostSystemMessage(state.toSystemMessageType(), setOf(channel)) - connectionState[channel]?.value = state - } - - private fun handleClearChat(msg: IrcMessage) { - val parsed = runCatching { - ModerationMessage.parseClearChat(msg) - }.getOrElse { - return - } - - messages[parsed.channel]?.update { current -> - current.replaceOrAddModerationMessage(parsed, scrollBackLength, ::onMessageRemoved) - } - } - - private fun handleClearMsg(msg: IrcMessage) { - val parsed = runCatching { - ModerationMessage.parseClearMessage(msg) - }.getOrElse { - return - } - - messages[parsed.channel]?.update { current -> - current.replaceWithTimeout(parsed, scrollBackLength, ::onMessageRemoved) - } - } - - private suspend fun handleWhisper(ircMessage: IrcMessage) { - if (pubSubManager.connectedAndHasWhisperTopic) { - return - } - - val userId = ircMessage.tags["user-id"]?.toUserId() - if (ignoresRepository.isUserBlocked(userId)) { - return - } - - val userState = userStateRepository.userState.value - val recipient = userState.displayName ?: return - val message = runCatching { - WhisperMessage.parseFromIrc(ircMessage, recipient, userState.color) - .applyIgnores() - ?.calculateHighlightState() - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() as? WhisperMessage - }.getOrNull() ?: return - - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateGlobalUser(message.name.lowercase(), userForSuggestion) - - val item = ChatItem(message, isMentionTab = true) - _whispers.update { current -> - current.addAndLimit(item, scrollBackLength, ::onMessageRemoved) - } - _channelMentionCount.increment(WhisperMessage.WHISPER_CHANNEL, 1) - } - - private suspend fun handleMessage(ircMessage: IrcMessage) { - if (ircMessage.command == "NOTICE" && ircMessage.tags["msg-id"] in NoticeMessage.ROOM_STATE_CHANGE_MSG_IDS) { - val channel = ircMessage.params[0].substring(1).toUserName() - if (eventSubManager.connectedAndHasModerateTopic(channel)) { - // we get better data from event sub, avoid showing this message - return - } - } - - val userId = ircMessage.tags["user-id"]?.toUserId() - if (ignoresRepository.isUserBlocked(userId)) { - return - } - - val rewardId = ircMessage.tags["custom-reward-id"] - val additionalMessages = when { - rewardId != null -> { - val reward = rewardMutex.withLock { - knownRewards[rewardId] - ?.also { - Log.d(TAG, "Removing known reward $rewardId") - knownRewards.remove(rewardId) - } - ?: run { - Log.d(TAG, "Waiting for pubsub reward message with id $rewardId") - withTimeoutOrNull(PUBSUB_TIMEOUT) { - pubSubManager.messages - .filterIsInstance() - .first { it.data.reward.id == rewardId } - }?.also { knownRewards[rewardId] = it } // mark message as known so default collector does not handle it again - } - } - - reward?.let { - listOf(ChatItem(PointRedemptionMessage.parsePointReward(it.timestamp, it.data).calculateHighlightState())) - }.orEmpty() - } - - else -> emptyList() - } - - val message = runCatching { - Message.parse(ircMessage, channelRepository::tryGetUserNameById) - ?.applyIgnores() - ?.calculateMessageThread { channel, id -> messages[channel]?.value?.find { it.message.id == id }?.message } - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() - ?.calculateHighlightState() - ?.updateMessageInThread() - }.getOrElse { - Log.e(TAG, "Failed to parse message", it) - return - } ?: return - - if (message is NoticeMessage && usersRepository.isGlobalChannel(message.channel)) { - messages.keys.forEach { - messages[it]?.update { current -> - current.addAndLimit(ChatItem(message, importance = ChatImportance.SYSTEM), scrollBackLength, ::onMessageRemoved) - } - } - return - } - - if (message is PrivMessage) { - if (message.name == dankChatPreferenceStore.userName) { - val previousLastMessage = lastMessage[message.channel].orEmpty() - val lastMessageWasCommand = previousLastMessage.startsWith('.') || previousLastMessage.startsWith('/') - if (!lastMessageWasCommand && previousLastMessage.withoutInvisibleChar != message.originalMessage.withoutInvisibleChar) { - lastMessage[message.channel] = message.originalMessage - } - - val hasVip = message.badges.any { badge -> badge.badgeTag?.startsWith("vip") == true } - when { - hasVip -> userStateRepository.addVipChannel(message.channel) - else -> userStateRepository.removeVipChannel(message.channel) - } - } - - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - usersRepository.updateUser(message.channel, message.name.lowercase(), userForSuggestion) - } - - val items = buildList { - if (message is UserNoticeMessage && message.childMessage != null) { - add(ChatItem(message.childMessage)) - } - val importance = when (message) { - is NoticeMessage -> ChatImportance.SYSTEM - else -> ChatImportance.REGULAR - } - add(ChatItem(message, importance = importance)) - } - - val channel = when (message) { - is PrivMessage -> message.channel - is UserNoticeMessage -> message.channel - is NoticeMessage -> message.channel - else -> return - } - - messages[channel]?.update { current -> - current.addAndLimit(items = additionalMessages + items, scrollBackLength, ::onMessageRemoved) - } - - _notificationsFlow.tryEmit(items) - val mentions = items - .filter { it.message.highlights.hasMention() } - .toMentionTabItems() - - if (mentions.isNotEmpty()) { - _mentions.update { current -> - current.addAndLimit(mentions, scrollBackLength, ::onMessageRemoved) - } - } - - if (channel != activeChannel.value) { - if (mentions.isNotEmpty()) { - _channelMentionCount.increment(channel, mentions.size) - } - - if (message is PrivMessage) { - val isUnread = _unreadMessagesMap.firstValue[channel] == true - if (!isUnread) { - _unreadMessagesMap.assign(channel, true) - } - } - } - } - - fun makeAndPostCustomSystemMessage(message: String, channel: UserName) { - messages[channel]?.update { - it.addSystemMessage(SystemMessageType.Custom(message), scrollBackLength, ::onMessageRemoved) - } - } - - fun makeAndPostSystemMessage(type: SystemMessageType, channel: UserName) { - messages[channel]?.update { - it.addSystemMessage(type, scrollBackLength, ::onMessageRemoved) - } - } - - private fun makeAndPostSystemMessage(type: SystemMessageType, channels: Set = messages.keys) { - channels.forEach { channel -> - val flow = messages[channel] ?: return@forEach - val current = flow.value - flow.value = current.addSystemMessage(type, scrollBackLength, ::onMessageRemoved) { - scope.launch { - if (chatSettingsDataStore.settings.first().loadMessageHistoryOnReconnect) { - loadRecentMessages(channel, isReconnect = true) - } - } - } - } - } - - private fun ConnectionState.toSystemMessageType(): SystemMessageType = when (this) { - ConnectionState.DISCONNECTED -> SystemMessageType.Disconnected - ConnectionState.CONNECTED, - ConnectionState.CONNECTED_NOT_LOGGED_IN -> SystemMessageType.Connected - } - - private suspend fun loadRecentMessages(channel: UserName, isReconnect: Boolean = false) = withContext(Dispatchers.IO) { - if (!isReconnect && channel in loadedRecentsInChannels) { - return@withContext - } - - val limit = if (isReconnect) RECENT_MESSAGES_LIMIT_AFTER_RECONNECT else null - val result = recentMessagesApiClient.getRecentMessages(channel, limit).getOrElse { throwable -> - if (!isReconnect) { - handleRecentMessagesFailure(throwable, channel) - } - return@withContext - } - - loadedRecentsInChannels += channel - val recentMessages = result.messages.orEmpty() - val items = mutableListOf() - val userSuggestions = mutableListOf>() - measureTimeMillis { - for (recentMessage in recentMessages) { - val parsedIrc = IrcMessage.parse(recentMessage) - val isDeleted = parsedIrc.tags["rm-deleted"] == "1" - if (ignoresRepository.isUserBlocked(parsedIrc.tags["user-id"]?.toUserId())) { - continue - } - - when (parsedIrc.command) { - "CLEARCHAT" -> { - val parsed = runCatching { - ModerationMessage.parseClearChat(parsedIrc) - }.getOrNull() ?: continue - - items.replaceOrAddHistoryModerationMessage(parsed) - } - - "CLEARMSG" -> { - val parsed = runCatching { - ModerationMessage.parseClearMessage(parsedIrc) - }.getOrNull() ?: continue - - items += ChatItem(parsed, importance = ChatImportance.SYSTEM) - } - - else -> { - val message = runCatching { - Message.parse(parsedIrc, channelRepository::tryGetUserNameById) - ?.applyIgnores() - ?.calculateMessageThread { _, id -> items.find { it.message.id == id }?.message } - ?.calculateUserDisplays() - ?.parseEmotesAndBadges() - ?.calculateHighlightState() - ?.updateMessageInThread() - }.getOrNull() ?: continue - - if (message is PrivMessage) { - val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() - userSuggestions += message.name.lowercase() to userForSuggestion - } - - val importance = when { - isDeleted -> ChatImportance.DELETED - isReconnect -> ChatImportance.SYSTEM - else -> ChatImportance.REGULAR - } - if (message is UserNoticeMessage && message.childMessage != null) { - items += ChatItem(message.childMessage, importance = importance) - } - items += ChatItem(message, importance = importance) - } - } - } - }.let { Log.i(TAG, "Parsing message history for #$channel took $it ms") } - - messages[channel]?.update { current -> - val withIncompleteWarning = when { - !isReconnect && recentMessages.isNotEmpty() && result.errorCode == RecentMessagesDto.ERROR_CHANNEL_NOT_JOINED -> { - current + SystemMessageType.MessageHistoryIncomplete.toChatItem() - } - - else -> current - } - withIncompleteWarning.addAndLimit(items, scrollBackLength, ::onMessageRemoved, checkForDuplications = true) - } - - val mentions = items.filter { (it.message.highlights.hasMention()) }.toMentionTabItems() - _mentions.update { current -> - (current + mentions) - .distinctBy { it.message.id } - .sortedBy { it.message.timestamp } - } - usersRepository.updateUsers(channel, userSuggestions) - } - - private fun handleRecentMessagesFailure(throwable: Throwable, channel: UserName) { - val type = when (throwable) { - !is RecentMessagesApiException -> { - _chatLoadingFailures.update { it + ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) } - SystemMessageType.MessageHistoryUnavailable(status = null) - } - - else -> when (throwable.error) { - RecentMessagesError.ChannelNotJoined -> { - loadedRecentsInChannels += channel // not a temporary error, so we don't want to retry - SystemMessageType.MessageHistoryIgnored - } - - else -> { - _chatLoadingFailures.update { it + ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable) } - SystemMessageType.MessageHistoryUnavailable(status = throwable.status.value.toString()) - } - } - } - makeAndPostSystemMessage(type, setOf(channel)) - } - - private fun Message.applyIgnores(): Message? = ignoresRepository.applyIgnores(this) - private suspend fun Message.calculateHighlightState(): Message = highlightsRepository.calculateHighlightState(this) - private suspend fun Message.parseEmotesAndBadges(): Message = emoteRepository.parseEmotesAndBadges(this) - private fun Message.calculateUserDisplays(): Message = userDisplayRepository.calculateUserDisplay(this) - - private fun Message.calculateMessageThread(findMessageById: (channel: UserName, id: String) -> Message?): Message { - return repliesRepository.calculateMessageThread(message = this, findMessageById) - } - - private fun Message.updateMessageInThread(): Message = repliesRepository.updateMessageInThread(this) - - private fun onMessageRemoved(item: ChatItem) = repliesRepository.cleanupMessageThread(item.message) - - companion object { - private val TAG = ChatRepository::class.java.simpleName - private val ESCAPE_TAG = 0x000E0002.codePointAsString - - private const val PUBSUB_TIMEOUT = 5000L - private const val RECENT_MESSAGES_LIMIT_AFTER_RECONNECT = 100 - - val ESCAPE_TAG_REGEX = "(? Message? = { _, _ -> null }, + ): Message? = Message + .parse(ircMessage, channelRepository::tryGetUserNameById) + ?.let { process(it, findMessageById) } + + /** Full pipeline on an already-parsed message. Returns null if ignored. */ + suspend fun process( + message: Message, + findMessageById: (UserName, String) -> Message? = { _, _ -> null }, + ): Message? = message + .applyIgnores() + ?.calculateMessageThread(findMessageById) + ?.calculateUserDisplays() + ?.parseEmotesAndBadges() + ?.calculateHighlightState() + ?.updateMessageInThread() + + /** Partial pipeline for PubSub reward messages (no thread/emote steps). */ + suspend fun processReward(message: Message): Message? = message + .applyIgnores() + ?.calculateHighlightState() + ?.calculateUserDisplays() + + /** Partial pipeline for whisper messages (no thread step). */ + suspend fun processWhisper(message: Message): Message? = message + .applyIgnores() + ?.calculateHighlightState() + ?.calculateUserDisplays() + ?.parseEmotesAndBadges() + + /** Re-parse emotes and badges (e.g. after emote set changes). */ + suspend fun reparseEmotesAndBadges(message: Message): Message = message.parseEmotesAndBadges().updateMessageInThread() + + fun isUserBlocked(userId: UserId?): Boolean = ignoresRepository.isUserBlocked(userId) + + fun onMessageRemoved(item: ChatItem) = repliesRepository.cleanupMessageThread(item.message) + + fun cleanupMessageThreadsInChannel(channel: UserName) = repliesRepository.cleanupMessageThreadsInChannel(channel) + + private fun Message.applyIgnores(): Message? = ignoresRepository.applyIgnores(this) + + private suspend fun Message.calculateHighlightState(): Message = highlightsRepository.calculateHighlightState(this) + + private suspend fun Message.parseEmotesAndBadges(): Message = emoteRepository.parseEmotesAndBadges(this) + + private fun Message.calculateUserDisplays(): Message = userDisplayRepository.calculateUserDisplay(this) + + private fun Message.calculateMessageThread(find: (UserName, String) -> Message?): Message = repliesRepository.calculateMessageThread(this, find) + + private fun Message.updateMessageInThread(): Message = repliesRepository.updateMessageInThread(this) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/RecentMessagesHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/RecentMessagesHandler.kt new file mode 100644 index 000000000..8f026ffc4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/RecentMessagesHandler.kt @@ -0,0 +1,185 @@ +package com.flxrs.dankchat.data.repo.chat + +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiClient +import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApiException +import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesError +import com.flxrs.dankchat.data.api.recentmessages.dto.RecentMessagesDto +import com.flxrs.dankchat.data.chat.ChatImportance +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.chat.toMentionTabItems +import com.flxrs.dankchat.data.irc.IrcMessage +import com.flxrs.dankchat.data.toDisplayName +import com.flxrs.dankchat.data.toUserId +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage +import com.flxrs.dankchat.data.twitch.message.hasMention +import com.flxrs.dankchat.data.twitch.message.toChatItem +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.utils.extensions.addAndLimit +import com.flxrs.dankchat.utils.extensions.replaceOrAddHistoryModerationMessage +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.util.collections.ConcurrentSet +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import kotlin.system.measureTimeMillis + +private val logger = KotlinLogging.logger("RecentMessagesHandler") + +@Single +class RecentMessagesHandler( + private val recentMessagesApiClient: RecentMessagesApiClient, + private val messageProcessor: MessageProcessor, + private val chatMessageRepository: ChatMessageRepository, + private val usersRepository: UsersRepository, + private val dispatchersProvider: DispatchersProvider, +) { + private val loadedChannels = ConcurrentSet() + + data class Result( + val mentionItems: List, + val userSuggestions: List>, + ) + + @Suppress("LoopWithTooManyJumpStatements") + suspend fun load( + channel: UserName, + isReconnect: Boolean = false, + ): Result = withContext(dispatchersProvider.io) { + if (!isReconnect && channel in loadedChannels) { + return@withContext Result(emptyList(), emptyList()) + } + + val limit = if (isReconnect) RECENT_MESSAGES_LIMIT_AFTER_RECONNECT else null + val result = + recentMessagesApiClient.getRecentMessages(channel, limit).getOrElse { throwable -> + if (!isReconnect) { + handleFailure(throwable, channel) + } + return@withContext Result(emptyList(), emptyList()) + } + + loadedChannels += channel + val recentMessages = result.messages.orEmpty() + val items = mutableListOf() + val messageIndex = HashMap(recentMessages.size) + val userSuggestions = mutableListOf>() + + measureTimeMillis { + for (recentMessage in recentMessages) { + val parsedIrc = IrcMessage.parse(recentMessage) + val isDeleted = parsedIrc.tags["rm-deleted"] == "1" + if (messageProcessor.isUserBlocked(parsedIrc.tags["user-id"]?.toUserId())) { + continue + } + + when (parsedIrc.command) { + "CLEARCHAT" -> { + val parsed = + runCatching { + ModerationMessage.parseClearChat(parsedIrc) + }.getOrNull() ?: continue + + items.replaceOrAddHistoryModerationMessage(parsed) + } + + "CLEARMSG" -> { + val parsed = + runCatching { + ModerationMessage.parseClearMessage(parsedIrc) + }.getOrNull() ?: continue + + items += ChatItem(parsed, importance = ChatImportance.SYSTEM) + } + + else -> { + val message = + runCatching { + messageProcessor.processIrcMessage(parsedIrc) { _, id -> messageIndex[id] } + }.getOrNull() ?: continue + + messageIndex[message.id] = message + if (message is PrivMessage) { + val userForSuggestion = message.name.valueOrDisplayName(message.displayName).toDisplayName() + userSuggestions += message.name.lowercase() to userForSuggestion + val color = message.color + if (color != null) { + usersRepository.cacheUserColor(message.name, color) + } + } + + val importance = + when { + isDeleted -> ChatImportance.DELETED + isReconnect -> ChatImportance.SYSTEM + else -> ChatImportance.REGULAR + } + if (message is UserNoticeMessage && message.childMessage != null) { + items += ChatItem(message.childMessage, importance = importance) + } + items += ChatItem(message, importance = importance) + } + } + } + }.let { logger.info { "Parsing message history for #$channel took $it ms" } } + + val messagesFlow = chatMessageRepository.getMessagesFlow(channel) + messagesFlow?.update { current -> + val withIncompleteWarning = + when { + !isReconnect && recentMessages.isNotEmpty() && result.errorCode == RecentMessagesDto.ERROR_CHANNEL_NOT_JOINED -> { + current + SystemMessageType.MessageHistoryIncomplete.toChatItem() + } + + else -> { + current + } + } + + withIncompleteWarning.addAndLimit(items, chatMessageRepository.scrollBackLength, messageProcessor::onMessageRemoved, checkForDuplications = true) + } + + val mentionItems = items.filter { it.message.highlights.hasMention() }.toMentionTabItems() + Result(mentionItems, userSuggestions) + } + + private fun handleFailure( + throwable: Throwable, + channel: UserName, + ) { + val type = + when (throwable) { + !is RecentMessagesApiException -> { + chatMessageRepository.addLoadingFailure(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable)) + SystemMessageType.MessageHistoryUnavailable(status = null) + } + + else -> { + when (throwable.error) { + RecentMessagesError.ChannelNotJoined -> { + return + } + + RecentMessagesError.ChannelIgnored -> { + SystemMessageType.MessageHistoryIgnored + } + + else -> { + chatMessageRepository.addLoadingFailure(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), throwable)) + SystemMessageType.MessageHistoryUnavailable(status = throwable.status.value.toString()) + } + } + } + } + chatMessageRepository.addSystemMessageToChannels(type, setOf(channel)) + } + + companion object { + private const val RECENT_MESSAGES_LIMIT_AFTER_RECONNECT = 100 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserState.kt index d928d5a5f..00f22fdbb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserState.kt @@ -15,10 +15,9 @@ data class UserState( val moderationChannels: Set = emptySet(), val vipChannels: Set = emptySet(), ) { - fun getSendDelay(channel: UserName): Duration = when { hasHighRateLimit(channel) -> LOW_SEND_DELAY - else -> REGULAR_SEND_DELAY + else -> REGULAR_SEND_DELAY } private fun hasHighRateLimit(channel: UserName): Boolean = channel in moderationChannels || channel in vipChannels diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserStateRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserStateRepository.kt index 9bc0fcb6d..9cadc82dd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserStateRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UserStateRepository.kt @@ -18,24 +18,24 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Single -class UserStateRepository(private val preferenceStore: DankChatPreferenceStore) { - +class UserStateRepository( + private val preferenceStore: DankChatPreferenceStore, +) { private val _userState = MutableStateFlow(UserState()) val userState = _userState.asStateFlow() suspend fun getLatestValidUserState(minChannelsSize: Int = 0): UserState = userState .filter { it.userId != null && it.userId.value.isNotBlank() && it.globalEmoteSets.isNotEmpty() && it.followerEmoteSets.size >= minChannelsSize - }.take(count = 1).single() + }.take(count = 1) + .single() suspend fun tryGetUserStateOrFallback( minChannelsSize: Int, initialTimeout: Duration = IRC_TIMEOUT_DELAY, - fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY - ): UserState? { - return withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } - ?: withTimeoutOrNull(fallbackTimeout) { getLatestValidUserState(minChannelsSize = 0) } - } + fallbackTimeout: Duration = IRC_TIMEOUT_SHORT_DELAY, + ): UserState? = withTimeoutOrNull(initialTimeout) { getLatestValidUserState(minChannelsSize) } + ?: withTimeoutOrNull(fallbackTimeout) { getLatestValidUserState(minChannelsSize = 0) } fun isModeratorInChannel(channel: UserName?): Boolean = channel != null && channel in userState.value.moderationChannels @@ -51,13 +51,17 @@ class UserStateRepository(private val preferenceStore: DankChatPreferenceStore) userId = id ?: current.userId, color = color ?: current.color, displayName = name ?: current.displayName, - globalEmoteSets = sets ?: current.globalEmoteSets + globalEmoteSets = sets ?: current.globalEmoteSets, ) } } fun handleUserState(msg: IrcMessage) { - val channel = msg.params.getOrNull(0)?.substring(1)?.toUserName() ?: return + val channel = + msg.params + .getOrNull(0) + ?.substring(1) + ?.toUserName() ?: return val id = msg.tags["user-id"]?.toUserId() val sets = msg.tags["emote-sets"]?.split(",").orEmpty() val color = msg.tags["color"]?.ifBlank { null } @@ -67,24 +71,26 @@ class UserStateRepository(private val preferenceStore: DankChatPreferenceStore) val hasModeration = (badges?.any { it.contains("broadcaster") || it.contains("moderator") } == true) || userType == "mod" preferenceStore.displayName = name _userState.update { current -> - val followerEmotes = when { - current.globalEmoteSets.isNotEmpty() -> sets - current.globalEmoteSets.toSet() - else -> emptyList() - } + val followerEmotes = + when { + current.globalEmoteSets.isNotEmpty() -> sets - current.globalEmoteSets.toSet() + else -> emptyList() + } val newFollowerEmoteSets = current.followerEmoteSets.toMutableMap() newFollowerEmoteSets[channel] = followerEmotes - val newModerationChannels = when { - hasModeration -> current.moderationChannels + channel - else -> current.moderationChannels - } + val newModerationChannels = + when { + hasModeration -> current.moderationChannels + channel + else -> current.moderationChannels + } current.copy( userId = id ?: current.userId, color = color ?: current.color, displayName = name ?: current.displayName, followerEmoteSets = newFollowerEmoteSets, - moderationChannels = newModerationChannels + moderationChannels = newModerationChannels, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UsersRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UsersRepository.kt index ebc1bc8cb..c741d341c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UsersRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/chat/UsersRepository.kt @@ -13,11 +13,19 @@ import java.util.concurrent.ConcurrentHashMap class UsersRepository { private val users = ConcurrentHashMap>() private val usersFlows = ConcurrentHashMap>>() + private val userColors = LruCache(USER_COLOR_CACHE_SIZE) fun getUsersFlow(channel: UserName): StateFlow> = usersFlows.getOrPut(channel) { MutableStateFlow(emptySet()) } - fun findDisplayName(channel: UserName, userName: UserName): DisplayName? = users[channel]?.get(userName) - fun updateUsers(channel: UserName, new: List>) { + fun findDisplayName( + channel: UserName, + userName: UserName, + ): DisplayName? = users[channel]?.get(userName) + + fun updateUsers( + channel: UserName, + new: List>, + ) { val current = users.getOrPut(channel) { LruCache(USER_CACHE_SIZE) } new.forEach { current.put(it.first, it.second) } @@ -26,7 +34,11 @@ class UsersRepository { .update { current.snapshot().values.toSet() } } - fun updateUser(channel: UserName, name: UserName, displayName: DisplayName) { + fun updateUser( + channel: UserName, + name: UserName, + displayName: DisplayName, + ) { val current = users.getOrPut(channel) { LruCache(USER_CACHE_SIZE) } current.put(name, displayName) @@ -35,7 +47,10 @@ class UsersRepository { .update { current.snapshot().values.toSet() } } - fun updateGlobalUser(name: UserName, displayName: DisplayName) = updateUser(GLOBAL_CHANNEL_TAG, name, displayName) + fun updateGlobalUser( + name: UserName, + displayName: DisplayName, + ) = updateUser(GLOBAL_CHANNEL_TAG, name, displayName) fun isGlobalChannel(channel: UserName) = channel == GLOBAL_CHANNEL_TAG @@ -49,8 +64,18 @@ class UsersRepository { usersFlows.remove(channel) } + fun cacheUserColor( + userName: UserName, + color: Int, + ) { + userColors.put(userName, color) + } + + fun getCachedUserColor(userName: UserName): Int? = userColors.get(userName) + companion object { private const val USER_CACHE_SIZE = 5000 + private const val USER_COLOR_CACHE_SIZE = 1000 private val GLOBAL_CHANNEL_TAG = UserName("*") } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/Command.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/Command.kt index dbde81214..e9575c4d4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/Command.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/Command.kt @@ -1,9 +1,12 @@ package com.flxrs.dankchat.data.repo.command -enum class Command(val trigger: String) { +enum class Command( + val trigger: String, +) { Block(trigger = "/block"), Unblock(trigger = "/unblock"), - //Chatters(trigger = "/chatters"), + + // Chatters(trigger = "/chatters"), Uptime(trigger = "/uptime"), - Help(trigger = "/help") + Help(trigger = "/help"), } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandRepository.kt index dc1e949fa..77bc70b96 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandRepository.kt @@ -1,9 +1,10 @@ package com.flxrs.dankchat.data.repo.command -import android.util.Log +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.data.api.supibot.SupibotApiClient +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.repo.IgnoresRepository import com.flxrs.dankchat.data.repo.chat.UserState import com.flxrs.dankchat.data.toUserName @@ -13,11 +14,14 @@ import com.flxrs.dankchat.data.twitch.command.TwitchCommandRepository import com.flxrs.dankchat.data.twitch.message.RoomState import com.flxrs.dankchat.data.twitch.message.WhisperMessage import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.CustomCommand +import com.flxrs.dankchat.preferences.chat.SuggestionType import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.DateTimeUtils.calculateUptime +import com.flxrs.dankchat.utils.TextResource +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -37,6 +41,8 @@ import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import kotlin.system.measureTimeMillis +private val logger = KotlinLogging.logger("CommandRepository") + @Single class CommandRepository( private val ignoresRepository: IgnoresRepository, @@ -45,10 +51,9 @@ class CommandRepository( private val supibotApiClient: SupibotApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val preferenceStore: DankChatPreferenceStore, - dispatchersProvider: DispatchersProvider, + private val authDataStore: AuthDataStore, + private val dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val customCommands = chatSettingsDataStore.commands.stateIn(scope, SharingStarted.Eagerly, emptyList()) private val supibotCommands = mutableMapOf>>() @@ -56,33 +61,52 @@ class CommandRepository( private val defaultCommands = Command.entries private val defaultCommandTriggers = defaultCommands.map { it.trigger } - private val commandTriggers = chatSettingsDataStore.commands.map { customCommands -> - defaultCommandTriggers + TwitchCommandRepository.ALL_COMMAND_TRIGGERS + customCommands.map(CustomCommand::trigger) - } + private val commandTriggers = + chatSettingsDataStore.commands.map { customCommands -> + defaultCommandTriggers + TwitchCommandRepository.ALL_COMMAND_TRIGGERS + customCommands.map(CustomCommand::trigger) + } init { scope.launch { chatSettingsDataStore.settings - .map { it.supibotSuggestions } + .map { SuggestionType.SupibotCommands in it.suggestionTypes } .distinctUntilChanged() .collect { enabled -> when { enabled -> loadSupibotCommands() - else -> clearSupibotCommands() + else -> clearSupibotCommands() } } } } + fun getReservedTriggers(): Set { + val builtIn = defaultCommandTriggers + val twitch = TwitchCommandRepository.ALL_COMMAND_TRIGGERS + val supibot = supibotCommands.values.flatMap { it.value } + return (builtIn + twitch + supibot).toSet() + } + fun getCommandTriggers(channel: UserName): Flow> = when (channel) { WhisperMessage.WHISPER_CHANNEL -> flowOf(TwitchCommandRepository.asCommandTriggers(TwitchCommand.Whisper.trigger)) - else -> commandTriggers + else -> commandTriggers + } + + fun getCustomCommandTriggers(): Flow> = chatSettingsDataStore.commands.map { commands -> + commands.map(CustomCommand::trigger) } fun getSupibotCommands(channel: UserName): StateFlow> = supibotCommands.getOrPut(channel) { MutableStateFlow(emptyList()) } - suspend fun checkForCommands(message: String, channel: UserName, roomState: RoomState, userState: UserState, skipSuspendingCommands: Boolean = false): CommandResult { - if (!preferenceStore.isLoggedIn) { + @Suppress("ReturnCount") + suspend fun checkForCommands( + message: String, + channel: UserName, + roomState: RoomState, + userState: UserState, + skipSuspendingCommands: Boolean = false, + ): CommandResult { + if (!authDataStore.isLoggedIn) { return CommandResult.NotFound } @@ -111,18 +135,24 @@ class CommandRepository( } return when (defaultCommand) { - Command.Block -> blockUserCommand(args) + Command.Block -> blockUserCommand(args) + Command.Unblock -> unblockUserCommand(args) - //Command.Chatters -> chattersCommand(channel) - Command.Uptime -> uptimeCommand(channel) - Command.Help -> helpCommand(roomState, userState) + + // Command.Chatters -> chattersCommand(channel) + Command.Uptime -> uptimeCommand(channel) + + Command.Help -> helpCommand(roomState, userState) } } return checkUserCommands(trigger) } - suspend fun checkForWhisperCommand(message: String, skipSuspendingCommands: Boolean): CommandResult { + suspend fun checkForWhisperCommand( + message: String, + skipSuspendingCommands: Boolean, + ): CommandResult { if (skipSuspendingCommands) { return CommandResult.Blocked } @@ -130,21 +160,24 @@ class CommandRepository( val (trigger, args) = triggerAndArgsOrNull(message) ?: return CommandResult.NotFound return when (val twitchCommand = twitchCommandRepository.findTwitchCommand(trigger)) { TwitchCommand.Whisper -> { - val currentUserId = preferenceStore.userIdString - ?.takeIf { preferenceStore.isLoggedIn } - ?: return CommandResult.AcceptedTwitchCommand( - command = twitchCommand, - response = "You must be logged in to use the $trigger command" - ) + val currentUserId = + authDataStore.userIdString + ?.takeIf { authDataStore.isLoggedIn } + ?: return CommandResult.AcceptedTwitchCommand( + command = twitchCommand, + response = TextResource.Res(R.string.cmd_error_not_logged_in, persistentListOf(trigger)), + ) twitchCommandRepository.sendWhisper(twitchCommand, currentUserId, trigger, args) } - else -> CommandResult.NotFound + else -> { + CommandResult.NotFound + } } } - suspend fun loadSupibotCommands() = withContext(Dispatchers.Default) { - if (!preferenceStore.isLoggedIn || !chatSettingsDataStore.settings.first().supibotSuggestions) { + suspend fun loadSupibotCommands() = withContext(dispatchersProvider.default) { + if (!authDataStore.isLoggedIn || SuggestionType.SupibotCommands !in chatSettingsDataStore.settings.first().suggestionTypes) { return@withContext } @@ -162,7 +195,7 @@ class CommandRepository( .getOrPut(it) { MutableStateFlow(emptyList()) } .update { commands + aliases } } - }.let { Log.i(TAG, "Loaded Supibot commands in $it ms") } + }.let { logger.info { "Loaded Supibot commands in $it ms" } } } private fun triggerAndArgsOrNull(message: String): Pair>? { @@ -179,27 +212,26 @@ class CommandRepository( return trigger to words.drop(1) } - private suspend fun getSupibotChannels(): List { - return supibotApiClient.getSupibotChannels() - .getOrNull() - ?.let { (data) -> - data.filter { it.isActive }.map { it.name } - }.orEmpty() - } - - private suspend fun getSupibotCommands(): List { - return supibotApiClient.getSupibotCommands() - .getOrNull() - ?.let { (data) -> - data.flatMap { command -> - listOf("$${command.name}") + command.aliases.map { "$$it" } - } - }.orEmpty() - } + private suspend fun getSupibotChannels(): List = supibotApiClient + .getSupibotChannels() + .getOrNull() + ?.let { (data) -> + data.filter { it.isActive }.map { it.name } + }.orEmpty() + + private suspend fun getSupibotCommands(): List = supibotApiClient + .getSupibotCommands() + .getOrNull() + ?.let { (data) -> + data.flatMap { command -> + listOf("$${command.name}") + command.aliases.map { "$$it" } + } + }.orEmpty() private suspend fun getSupibotUserAliases(): List { - val user = preferenceStore.userName ?: return emptyList() - return supibotApiClient.getSupibotUserAliases(user) + val user = authDataStore.userName ?: return emptyList() + return supibotApiClient + .getSupibotUserAliases(user) .getOrNull() ?.let { (data) -> data.map { alias -> "$$${alias.name}" } @@ -212,60 +244,72 @@ class CommandRepository( private suspend fun blockUserCommand(args: List): CommandResult.AcceptedWithResponse { if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedWithResponse("Usage: /block ") + return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_block_usage)) } val target = args.first().toUserName() - val targetId = helixApiClient.getUserIdByName(target) - .getOrNull() ?: return CommandResult.AcceptedWithResponse("User $target couldn't be blocked, no user with that name found!") + val targetId = + helixApiClient + .getUserIdByName(target) + .getOrNull() ?: return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_block_not_found, persistentListOf(target.toString()))) val result = helixApiClient.blockUser(targetId) return when { result.isSuccess -> { ignoresRepository.addUserBlock(targetId, target) - CommandResult.AcceptedWithResponse("You successfully blocked user $target") + CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_block_success, persistentListOf(target.toString()))) } - else -> CommandResult.AcceptedWithResponse("User $target couldn't be blocked, an unknown error occurred!") + else -> { + CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_block_error, persistentListOf(target.toString()))) + } } } private suspend fun unblockUserCommand(args: List): CommandResult.AcceptedWithResponse { if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedWithResponse("Usage: /unblock ") + return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_unblock_usage)) } val target = args.first().toUserName() - val targetId = helixApiClient.getUserIdByName(target) - .getOrNull() ?: return CommandResult.AcceptedWithResponse("User $target couldn't be unblocked, no user with that name found!") - - val result = runCatching { - ignoresRepository.removeUserBlock(targetId, target) - CommandResult.AcceptedWithResponse("You successfully unblocked user $target") - } + val targetId = + helixApiClient + .getUserIdByName(target) + .getOrNull() ?: return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_unblock_not_found, persistentListOf(target.toString()))) + + val result = + runCatching { + ignoresRepository.removeUserBlock(targetId, target) + CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_unblock_success, persistentListOf(target.toString()))) + } return result.getOrElse { - CommandResult.AcceptedWithResponse("User $target couldn't be unblocked, an unknown error occurred!") + CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_unblock_error, persistentListOf(target.toString()))) } } private suspend fun uptimeCommand(channel: UserName): CommandResult.AcceptedWithResponse { - val result = helixApiClient.getStreams(listOf(channel)) - .getOrNull() - ?.getOrNull(0) ?: return CommandResult.AcceptedWithResponse("Channel is not live.") + val result = + helixApiClient + .getStreams(listOf(channel)) + .getOrNull() + ?.getOrNull(0) ?: return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_uptime_not_live)) val uptime = calculateUptime(result.startedAt) - return CommandResult.AcceptedWithResponse("Uptime: $uptime") + return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_uptime_response, persistentListOf(uptime))) } - private fun helpCommand(roomState: RoomState, userState: UserState): CommandResult.AcceptedWithResponse { - val commands = twitchCommandRepository - .getAvailableCommandTriggers(roomState, userState) - .plus(defaultCommandTriggers) - .joinToString(separator = " ") - - val response = "Commands available to you in this room: $commands" - return CommandResult.AcceptedWithResponse(response) + private fun helpCommand( + roomState: RoomState, + userState: UserState, + ): CommandResult.AcceptedWithResponse { + val commands = + twitchCommandRepository + .getAvailableCommandTriggers(roomState, userState) + .plus(defaultCommandTriggers) + .joinToString(separator = " ") + + return CommandResult.AcceptedWithResponse(TextResource.Res(R.string.cmd_help_response, persistentListOf(commands))) } private fun checkUserCommands(trigger: String): CommandResult { @@ -274,8 +318,4 @@ class CommandRepository( return CommandResult.Message(foundCommand.command) } - - companion object { - private val TAG = CommandRepository::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandResult.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandResult.kt index acc5ea6d9..495c575e0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandResult.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/command/CommandResult.kt @@ -1,13 +1,27 @@ package com.flxrs.dankchat.data.repo.command import com.flxrs.dankchat.data.twitch.command.TwitchCommand +import com.flxrs.dankchat.utils.TextResource sealed interface CommandResult { data object Accepted : CommandResult - data class AcceptedTwitchCommand(val command: TwitchCommand, val response: String? = null) : CommandResult - data class AcceptedWithResponse(val response: String) : CommandResult - data class Message(val message: String) : CommandResult + + data class AcceptedTwitchCommand( + val command: TwitchCommand, + val response: TextResource? = null, + ) : CommandResult + + data class AcceptedWithResponse( + val response: TextResource, + ) : CommandResult + + data class Message( + val message: String, + ) : CommandResult + data object NotFound : CommandResult + data object IrcCommand : CommandResult + data object Blocked : CommandResult } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashData.kt new file mode 100644 index 000000000..dbaa06631 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashData.kt @@ -0,0 +1,10 @@ +package com.flxrs.dankchat.data.repo.crash + +import kotlinx.serialization.Serializable + +@Serializable +data class CrashData( + val crashes: List = emptyList(), + val hasUnshownCrash: Boolean = false, + val knownFingerprints: Set = emptySet(), +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashEntry.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashEntry.kt new file mode 100644 index 000000000..dd1e82032 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashEntry.kt @@ -0,0 +1,18 @@ +package com.flxrs.dankchat.data.repo.crash + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Immutable +@Serializable +data class CrashEntry( + val id: Long, + val fingerprint: String, + val timestamp: String, + val version: String, + val androidInfo: String, + val device: String, + val threadName: String, + val exceptionHeader: String, + val stackTrace: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashHandler.kt new file mode 100644 index 000000000..cc6cd1ef1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashHandler.kt @@ -0,0 +1,106 @@ +package com.flxrs.dankchat.data.repo.crash + +import android.content.Context +import android.os.Build +import androidx.datastore.core.DataStore +import com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.utils.datastore.createDataStore +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import java.io.PrintWriter +import java.io.StringWriter +import java.time.Instant + +private val logger = KotlinLogging.logger("CrashHandler") + +class CrashHandler( + context: Context, + private val isCrashReportingEnabled: () -> Boolean, +) : Thread.UncaughtExceptionHandler { + private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + @Suppress("InjectDispatcher") + val dataStore: DataStore = createDataStore( + fileName = DATA_STORE_FILE, + context = context, + defaultValue = CrashData(), + serializer = CrashData.serializer(), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + ) + + fun install() { + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException( + thread: Thread, + throwable: Throwable, + ) { + // Always log to file via logback + logger.error(throwable) { "Uncaught exception on ${thread.name}" } + + // Only persist crash report data when debug mode is enabled + if (isCrashReportingEnabled()) { + try { + persistCrash(thread, throwable) + } catch (_: Throwable) { + // Best effort — must never throw from the crash handler + } + } + + defaultHandler?.uncaughtException(thread, throwable) + } + + private fun persistCrash( + thread: Thread, + throwable: Throwable, + ) { + val timestamp = System.currentTimeMillis() + val stackTrace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString() + + var rootCause: Throwable = throwable + while (rootCause.cause != null) { + rootCause = rootCause.cause ?: break + } + val exceptionHeader = "${rootCause.javaClass.name}: ${rootCause.message.orEmpty()}" + + val fingerprint = computeFingerprint(throwable) + val entry = CrashEntry( + id = timestamp, + fingerprint = fingerprint, + timestamp = Instant.ofEpochMilli(timestamp).toString(), + version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + androidInfo = "${Build.VERSION.SDK_INT} (${Build.VERSION.RELEASE})", + device = "${Build.MANUFACTURER} ${Build.MODEL}", + threadName = thread.name, + exceptionHeader = exceptionHeader, + stackTrace = stackTrace, + ) + + runBlocking { + dataStore.updateData { current -> + val isNew = fingerprint !in current.knownFingerprints + current.copy( + crashes = (listOf(entry) + current.crashes).take(MAX_CRASHES), + hasUnshownCrash = isNew, + knownFingerprints = current.knownFingerprints + fingerprint, + ) + } + } + } + + private fun computeFingerprint(throwable: Throwable): String { + val frames = throwable.stackTrace.take(FINGERPRINT_FRAME_COUNT).joinToString("|") { it.toString() } + val key = "${throwable.javaClass.name}:${throwable.message}|$frames" + return key.hashCode().toString() + } + + companion object { + private const val DATA_STORE_FILE = "crash_data" + private const val FINGERPRINT_FRAME_COUNT = 5 + private const val MAX_CRASHES = 10 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashRepository.kt new file mode 100644 index 000000000..195fb01f5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/crash/CrashRepository.kt @@ -0,0 +1,102 @@ +package com.flxrs.dankchat.data.repo.crash + +import androidx.datastore.core.DataStore +import com.flxrs.dankchat.BuildConfig +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.koin.core.annotation.Provided + +@Provided +class CrashRepository( + private val dataStore: DataStore, +) { + private fun currentData(): CrashData = runBlocking { dataStore.data.first() } + + fun hasUnshownCrash(): Boolean = currentData().hasUnshownCrash + + fun markCrashShown() { + runBlocking { dataStore.updateData { it.copy(hasUnshownCrash = false) } } + } + + fun getRecentCrashes(): List = currentData().crashes + + fun getMostRecentCrash(): CrashEntry? = getRecentCrashes().firstOrNull() + + fun getCrash(id: Long): CrashEntry? = getRecentCrashes().firstOrNull { it.id == id } + + fun deleteCrash(id: Long) { + runBlocking { + dataStore.updateData { current -> + val removed = current.crashes.firstOrNull { it.id == id } + val updated = current.crashes.filter { it.id != id } + when { + updated.isEmpty() -> CrashData() + + else -> current.copy( + crashes = updated, + knownFingerprints = when (removed) { + null -> current.knownFingerprints + else -> current.knownFingerprints - removed.fingerprint + }, + ) + } + } + } + } + + fun deleteAllCrashes() { + runBlocking { + dataStore.updateData { + CrashData() + } + } + } + + fun buildCrashReportMessage(entry: CrashEntry): String { + val header = entry.exceptionHeader.take(MAX_EXCEPTION_HEADER_LENGTH) + val frames = entry.stackTrace + .lines() + .filter { it.trimStart().startsWith("at ") } + .take(REPORT_FRAME_COUNT) + .joinToString(" | ") { it.trim() } + + val message = "[Crash] v${BuildConfig.VERSION_NAME} | $header | Thread: ${entry.threadName} | $frames" + val codePoints = message.codePointCount(0, message.length) + return when { + codePoints > MAX_REPORT_CODE_POINTS -> { + val endIndex = message.offsetByCodePoints(0, MAX_REPORT_CODE_POINTS) + message.substring(0, endIndex) + } + + else -> { + message + } + } + } + + fun buildEmailBody( + entry: CrashEntry, + userName: String?, + userId: String?, + ): String = buildString { + appendLine("DankChat Crash Report") + appendLine("=====================") + appendLine("Version: ${entry.version}") + appendLine("Android: ${entry.androidInfo}") + appendLine("Device: ${entry.device}") + appendLine("Thread: ${entry.threadName}") + appendLine("Time: ${entry.timestamp}") + if (userName != null) { + appendLine("User: $userName (ID: ${userId.orEmpty()})") + } + appendLine() + appendLine("Stack Trace:") + appendLine(entry.stackTrace) + } + + companion object { + private const val MAX_REPORT_CODE_POINTS = 350 + private const val MAX_EXCEPTION_HEADER_LENGTH = 150 + private const val REPORT_FRAME_COUNT = 3 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingFailure.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingFailure.kt index c5cf2c79d..18391ddcb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingFailure.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingFailure.kt @@ -1,3 +1,6 @@ package com.flxrs.dankchat.data.repo.data -data class DataLoadingFailure(val step: DataLoadingStep, val failure: Throwable) +data class DataLoadingFailure( + val step: DataLoadingStep, + val failure: Throwable, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt index 2298bbd53..3219aaf44 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataLoadingStep.kt @@ -1,50 +1,102 @@ package com.flxrs.dankchat.data.repo.data +import android.content.res.Resources +import androidx.annotation.StringRes +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.utils.extensions.partitionIsInstance sealed interface DataLoadingStep { + @get:StringRes + val displayNameRes: Int - data object DankChatBadges : DataLoadingStep + data object DankChatBadges : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_dankchat_badges + } + + data object GlobalBadges : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_global_badges + } + + data object GlobalFFZEmotes : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_global_ffz_emotes + } + + data object GlobalBTTVEmotes : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_global_bttv_emotes + } + + data object GlobalSevenTVEmotes : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_global_7tv_emotes + } - data object GlobalBadges : DataLoadingStep + data object TwitchEmotes : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_twitch_emotes + } - data object GlobalFFZEmotes : DataLoadingStep + data class ChannelBadges( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_channel_badges + } - data object GlobalBTTVEmotes : DataLoadingStep + data class ChannelFFZEmotes( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_ffz_emotes + } - data object GlobalSevenTVEmotes : DataLoadingStep + data class ChannelBTTVEmotes( + val channel: UserName, + val channelDisplayName: DisplayName, + val channelId: UserId, + ) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_bttv_emotes + } - data class ChannelBadges(val channel: UserName, val channelId: UserId) : DataLoadingStep - data class ChannelFFZEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep - data class ChannelBTTVEmotes(val channel: UserName, val channelDisplayName: DisplayName, val channelId: UserId) : DataLoadingStep - data class ChannelSevenTVEmotes(val channel: UserName, val channelId: UserId) : DataLoadingStep + data class ChannelSevenTVEmotes( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_7tv_emotes + } + + data class ChannelCheermotes( + val channel: UserName, + val channelId: UserId, + ) : DataLoadingStep { + override val displayNameRes = R.string.data_loading_step_cheermotes + } } -fun List.toMergedStrings(): List { +fun List.toDisplayStrings(resources: Resources): List { val (badges, notBadges) = partitionIsInstance() val (ffz, notFfz) = notBadges.partitionIsInstance() val (bttv, notBttv) = notFfz.partitionIsInstance() val (sevenTv, rest) = notBttv.partitionIsInstance() return buildList { - addAll(rest.map(DataLoadingStep::toString)) + addAll(rest.map { resources.getString(it.displayNameRes) }) if (badges.isNotEmpty()) { - add("ChannelBadges(${badges.joinToString(separator = ",") { it.channel.value }})") + val channels = badges.joinToString { it.channel.value } + add(resources.getString(R.string.data_loading_step_with_channels, resources.getString(R.string.data_loading_step_channel_badges), channels)) } if (ffz.isNotEmpty()) { - add("ChannelFFZEmotes(${ffz.joinToString(separator = ",") { it.channel.value }})") + val channels = ffz.joinToString { it.channel.value } + add(resources.getString(R.string.data_loading_step_with_channels, resources.getString(R.string.data_loading_step_ffz_emotes), channels)) } if (bttv.isNotEmpty()) { - add("ChannelBTTVEmotes(${bttv.joinToString(separator = ",") { it.channel.value }})") + val channels = bttv.joinToString { it.channel.value } + add(resources.getString(R.string.data_loading_step_with_channels, resources.getString(R.string.data_loading_step_bttv_emotes), channels)) } if (sevenTv.isNotEmpty()) { - add("ChannelSevenTVEmotes(${sevenTv.joinToString(separator = ",") { it.channel.value }})") + val channels = sevenTv.joinToString { it.channel.value } + add(resources.getString(R.string.data_loading_step_with_channels, resources.getString(R.string.data_loading_step_7tv_emotes), channels)) } } } - - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt index 550e3a806..4a4cf7822 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataRepository.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.data.repo.data -import android.util.Log import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -16,22 +15,23 @@ import com.flxrs.dankchat.data.api.seventv.SevenTVApiClient import com.flxrs.dankchat.data.api.seventv.eventapi.SevenTVEventApiClient import com.flxrs.dankchat.data.api.seventv.eventapi.SevenTVEventMessage import com.flxrs.dankchat.data.api.upload.UploadClient +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.repo.RecentUploadsRepository import com.flxrs.dankchat.data.repo.emote.EmoteRepository import com.flxrs.dankchat.data.repo.emote.Emotes import com.flxrs.dankchat.data.twitch.badge.toBadgeSets import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.VisibleThirdPartyEmotes import com.flxrs.dankchat.utils.extensions.measureTimeAndLog +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -41,7 +41,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import java.io.File -import kotlin.system.measureTimeMillis + +private val logger = KotlinLogging.logger("DataRepository") @Single class DataRepository( @@ -55,11 +56,10 @@ class DataRepository( private val uploadClient: UploadClient, private val emoteRepository: EmoteRepository, private val recentUploadsRepository: RecentUploadsRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, private val chatSettingsDataStore: ChatSettingsDataStore, - dispatchersProvider: DispatchersProvider, + private val dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) private val _dataLoadingFailures = MutableStateFlow(emptySet()) private val _dataUpdateEvents = MutableSharedFlow() @@ -69,7 +69,7 @@ class DataRepository( scope.launch { sevenTVEventApiClient.messages.collect { event -> when (event) { - is SevenTVEventMessage.UserUpdated -> { + is SevenTVEventMessage.UserUpdated -> { val channel = emoteRepository.getChannelForSevenTVEmoteSet(event.oldEmoteSetId) ?: return@collect val details = emoteRepository.getSevenTVUserDetails(channel) ?: return@collect if (details.connectionIndex != event.connectionIndex) { @@ -100,11 +100,17 @@ class DataRepository( fun clearDataLoadingFailures() = _dataLoadingFailures.update { emptySet() } - fun getEmotes(channel: UserName): StateFlow = emoteRepository.getEmotes(channel) + fun getEmotes(channel: UserName): Flow = emoteRepository.getEmotes(channel) + fun createFlowsIfNecessary(channels: List) = emoteRepository.createFlowsIfNecessary(channels) suspend fun getUser(userId: UserId): UserDto? = helixApiClient.getUser(userId).getOrNull() - suspend fun getChannelFollowers(broadcasterId: UserId, targetId: UserId): UserFollowsDto? = helixApiClient.getChannelFollowers(broadcasterId, targetId).getOrNull() + + suspend fun getChannelFollowers( + broadcasterId: UserId, + targetId: UserId, + ): UserFollowsDto? = helixApiClient.getChannelFollowers(broadcasterId, targetId).getOrNull() + suspend fun getStreams(channels: List): List? = helixApiClient.getStreams(channels).getOrNull() suspend fun reconnect() { @@ -128,130 +134,178 @@ class DataRepository( it.imageLink } - suspend fun loadGlobalBadges() = withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "global badges") { - val badges = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } - System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getGlobalBadges().map { it.toBadgeSets() } - else -> return@withContext - }.getOrEmitFailure { DataLoadingStep.GlobalBadges } - badges?.also { emoteRepository.setGlobalBadges(it) } + suspend fun loadGlobalBadges(): Result = withContext(dispatchersProvider.io) { + measureTimeAndLog(logger, "global badges") { + val result = + when { + authDataStore.isLoggedIn -> helixApiClient.getGlobalBadges().map { it.toBadgeSets() } + System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getGlobalBadges().map { it.toBadgeSets() } + else -> return@withContext Result.success(Unit) + }.getOrEmitFailure { DataLoadingStep.GlobalBadges } + result.onSuccess { emoteRepository.setGlobalBadges(it) }.map { } } } - suspend fun loadDankChatBadges() = withContext(Dispatchers.IO) { - measureTimeMillis { - dankChatApiClient.getDankChatBadges() + suspend fun loadDankChatBadges(): Result = withContext(dispatchersProvider.io) { + measureTimeAndLog(logger, "DankChat badges") { + dankChatApiClient + .getDankChatBadges() .getOrEmitFailure { DataLoadingStep.DankChatBadges } - ?.let { emoteRepository.setDankChatBadges(it) } - }.let { Log.i(TAG, "Loaded DankChat badges in $it ms") } + .onSuccess { emoteRepository.setDankChatBadges(it) } + .map { } + } } - suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) { - emoteRepository.loadUserStateEmotes(globalEmoteSetIds, followerEmoteSetIds) - } + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = emoteRepository + .loadUserEmotes(userId, onFirstPageLoaded) + .getOrEmitFailure { DataLoadingStep.TwitchEmotes } suspend fun sendShutdownCommand() { serviceEventChannel.send(ServiceEvent.Shutdown) } - suspend fun loadChannelBadges(channel: UserName, id: UserId) = withContext(Dispatchers.IO) { - measureTimeAndLog(TAG, "channel badges for #$id") { - val badges = when { - dankChatPreferenceStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } - System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getChannelBadges(id).map { it.toBadgeSets() } - else -> return@withContext - }.getOrEmitFailure { DataLoadingStep.ChannelBadges(channel, id) } - badges?.also { emoteRepository.setChannelBadges(channel, it) } + suspend fun loadChannelBadges( + channel: UserName, + id: UserId, + ): Result = withContext(dispatchersProvider.io) { + measureTimeAndLog(logger, "channel badges for #$id") { + val result = + when { + authDataStore.isLoggedIn -> helixApiClient.getChannelBadges(id).map { it.toBadgeSets() } + System.currentTimeMillis() < BADGES_SUNSET_MILLIS -> badgesApiClient.getChannelBadges(id).map { it.toBadgeSets() } + else -> return@withContext Result.success(Unit) + }.getOrEmitFailure { DataLoadingStep.ChannelBadges(channel, id) } + result.onSuccess { emoteRepository.setChannelBadges(channel, it) }.map { } } } - suspend fun loadChannelFFZEmotes(channel: UserName, channelId: UserId) = withContext(Dispatchers.IO) { + suspend fun loadChannelFFZEmotes( + channel: UserName, + channelId: UserId, + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - ffzApiClient.getFFZChannelEmotes(channelId) + measureTimeAndLog(logger, "FFZ emotes for #$channel") { + ffzApiClient + .getFFZChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelFFZEmotes(channel, channelId) } - ?.let { emoteRepository.setFFZEmotes(channel, it) } - }.let { Log.i(TAG, "Loaded FFZ emotes for #$channel in $it ms") } + .onSuccess { emotes -> emotes?.let { emoteRepository.setFFZEmotes(channel, it) } } + .map { } + } } - suspend fun loadChannelBTTVEmotes(channel: UserName, channelDisplayName: DisplayName, channelId: UserId) = withContext(Dispatchers.IO) { + suspend fun loadChannelBTTVEmotes( + channel: UserName, + channelDisplayName: DisplayName, + channelId: UserId, + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - bttvApiClient.getBTTVChannelEmotes(channelId) + measureTimeAndLog(logger, "BTTV emotes for #$channel") { + bttvApiClient + .getBTTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelBTTVEmotes(channel, channelDisplayName, channelId) } - ?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } - }.let { Log.i(TAG, "Loaded BTTV emotes for #$channel in $it ms") } + .onSuccess { emotes -> emotes?.let { emoteRepository.setBTTVEmotes(channel, channelDisplayName, it) } } + .map { } + } } - suspend fun loadChannelSevenTVEmotes(channel: UserName, channelId: UserId) = withContext(Dispatchers.IO) { + suspend fun loadChannelSevenTVEmotes( + channel: UserName, + channelId: UserId, + ): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - sevenTVApiClient.getSevenTVChannelEmotes(channelId) + measureTimeAndLog(logger, "7TV emotes for #$channel") { + sevenTVApiClient + .getSevenTVChannelEmotes(channelId) .getOrEmitFailure { DataLoadingStep.ChannelSevenTVEmotes(channel, channelId) } - ?.let { result -> + .onSuccess { result -> + result ?: return@onSuccess if (result.emoteSet?.id != null) { sevenTVEventApiClient.subscribeEmoteSet(result.emoteSet.id) } sevenTVEventApiClient.subscribeUser(result.user.id) emoteRepository.setSevenTVEmotes(channel, result) - } - }.let { Log.i(TAG, "Loaded 7TV emotes for #$channel in $it ms") } + }.map { } + } } - suspend fun loadGlobalFFZEmotes() = withContext(Dispatchers.IO) { + suspend fun loadChannelCheermotes( + channel: UserName, + channelId: UserId, + ): Result = withContext(dispatchersProvider.io) { + if (!authDataStore.isLoggedIn) { + return@withContext Result.success(Unit) + } + + measureTimeAndLog(logger, "cheermotes for #$channel") { + helixApiClient + .getCheermotes(channelId) + .getOrEmitFailure { DataLoadingStep.ChannelCheermotes(channel, channelId) } + .onSuccess { emoteRepository.setCheermotes(channel, it) } + .map { } + } + } + + suspend fun loadGlobalFFZEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.FFZ !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - ffzApiClient.getFFZGlobalEmotes() + measureTimeAndLog(logger, "global FFZ emotes") { + ffzApiClient + .getFFZGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalFFZEmotes } - ?.let { emoteRepository.setFFZGlobalEmotes(it) } - }.let { Log.i(TAG, "Loaded global FFZ emotes in $it ms") } + .onSuccess { emoteRepository.setFFZGlobalEmotes(it) } + .map { } + } } - suspend fun loadGlobalBTTVEmotes() = withContext(Dispatchers.IO) { + suspend fun loadGlobalBTTVEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.BTTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - bttvApiClient.getBTTVGlobalEmotes() + measureTimeAndLog(logger, "global BTTV emotes") { + bttvApiClient + .getBTTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalBTTVEmotes } - ?.let { emoteRepository.setBTTVGlobalEmotes(it) } - }.let { Log.i(TAG, "Loaded global BTTV emotes in $it ms") } + .onSuccess { emoteRepository.setBTTVGlobalEmotes(it) } + .map { } + } } - suspend fun loadGlobalSevenTVEmotes() = withContext(Dispatchers.IO) { + suspend fun loadGlobalSevenTVEmotes(): Result = withContext(dispatchersProvider.io) { if (VisibleThirdPartyEmotes.SevenTV !in chatSettingsDataStore.settings.first().visibleEmotes) { - return@withContext + return@withContext Result.success(Unit) } - measureTimeMillis { - sevenTVApiClient.getSevenTVGlobalEmotes() + measureTimeAndLog(logger, "global 7TV emotes") { + sevenTVApiClient + .getSevenTVGlobalEmotes() .getOrEmitFailure { DataLoadingStep.GlobalSevenTVEmotes } - ?.let { emoteRepository.setSevenTVGlobalEmotes(it) } - }.let { Log.i(TAG, "Loaded global 7TV emotes in $it ms") } + .onSuccess { emoteRepository.setSevenTVGlobalEmotes(it) } + .map { } + } } - private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): T? = getOrElse { throwable -> - Log.e(TAG, "Data request failed:", throwable) - _dataLoadingFailures.update { it + DataLoadingFailure(step(), throwable) } - null + private fun Result.getOrEmitFailure(step: () -> DataLoadingStep): Result = onFailure { throwable -> + val loadingStep = step() + logger.error(throwable) { "Data request failed [$loadingStep]" } + _dataLoadingFailures.update { it + DataLoadingFailure(loadingStep, throwable) } } companion object { - private val TAG = DataRepository::class.java.simpleName private const val BADGES_SUNSET_MILLIS = 1685637000000L // 2023-06-01 16:30:00 } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataUpdateEventMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataUpdateEventMessage.kt index 61b3bc8b4..5b1eba478 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataUpdateEventMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/data/DataUpdateEventMessage.kt @@ -7,6 +7,14 @@ import com.flxrs.dankchat.data.api.seventv.eventapi.SevenTVEventMessage sealed interface DataUpdateEventMessage { val channel: UserName - data class EmoteSetUpdated(override val channel: UserName, val event: SevenTVEventMessage.EmoteSetUpdated) : DataUpdateEventMessage - data class ActiveEmoteSetChanged(override val channel: UserName, val actorName: DisplayName, val emoteSetName: String) : DataUpdateEventMessage + data class EmoteSetUpdated( + override val channel: UserName, + val event: SevenTVEventMessage.EmoteSetUpdated, + ) : DataUpdateEventMessage + + data class ActiveEmoteSetChanged( + override val channel: UserName, + val actorName: DisplayName, + val emoteSetName: String, + ) : DataUpdateEventMessage } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmojiRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmojiRepository.kt new file mode 100644 index 000000000..f52ee3cae --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmojiRepository.kt @@ -0,0 +1,43 @@ +package com.flxrs.dankchat.data.repo.emote + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.R +import com.flxrs.dankchat.di.DispatchersProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Single + +@Immutable +@Serializable +data class EmojiData( + val code: String, + val unicode: String, +) + +@Single +class EmojiRepository( + private val context: Context, + private val json: Json, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val _emojis = MutableStateFlow>(emptyList()) + val emojis: StateFlow> = _emojis.asStateFlow() + + init { + scope.launch { + runCatching { + val input = context.resources.openRawResource(R.raw.emoji_data) + val text = input.bufferedReader().use { it.readText() } + _emojis.value = json.decodeFromString>(text) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt index f4a55f4fa..feec0247d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepository.kt @@ -1,23 +1,24 @@ package com.flxrs.dankchat.data.repo.emote -import android.annotation.SuppressLint +import android.graphics.Color import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable -import android.os.Build import android.util.LruCache import androidx.annotation.VisibleForTesting +import androidx.core.graphics.toColorInt import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.bttv.dto.BTTVChannelDto import com.flxrs.dankchat.data.api.bttv.dto.BTTVEmoteDto import com.flxrs.dankchat.data.api.bttv.dto.BTTVGlobalEmoteDto -import com.flxrs.dankchat.data.api.dankchat.DankChatApiClient import com.flxrs.dankchat.data.api.dankchat.dto.DankChatBadgeDto -import com.flxrs.dankchat.data.api.dankchat.dto.DankChatEmoteDto import com.flxrs.dankchat.data.api.ffz.dto.FFZChannelDto import com.flxrs.dankchat.data.api.ffz.dto.FFZEmoteDto import com.flxrs.dankchat.data.api.ffz.dto.FFZGlobalDto +import com.flxrs.dankchat.data.api.helix.HelixApiClient +import com.flxrs.dankchat.data.api.helix.dto.CheermoteSetDto +import com.flxrs.dankchat.data.api.helix.dto.UserEmoteDto import com.flxrs.dankchat.data.api.seventv.SevenTVUserDetails import com.flxrs.dankchat.data.api.seventv.dto.SevenTVEmoteDto import com.flxrs.dankchat.data.api.seventv.dto.SevenTVEmoteFileDto @@ -26,13 +27,14 @@ import com.flxrs.dankchat.data.api.seventv.dto.SevenTVUserConnection import com.flxrs.dankchat.data.api.seventv.dto.SevenTVUserDto import com.flxrs.dankchat.data.api.seventv.eventapi.SevenTVEventMessage import com.flxrs.dankchat.data.repo.channel.ChannelRepository -import com.flxrs.dankchat.data.repo.chat.ChatRepository import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.badge.BadgeSet import com.flxrs.dankchat.data.twitch.badge.BadgeType import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +import com.flxrs.dankchat.data.twitch.emote.CheermoteSet +import com.flxrs.dankchat.data.twitch.emote.CheermoteTier import com.flxrs.dankchat.data.twitch.emote.EmoteType import com.flxrs.dankchat.data.twitch.emote.GenericEmote import com.flxrs.dankchat.data.twitch.emote.toChatMessageEmoteType @@ -41,71 +43,150 @@ import com.flxrs.dankchat.data.twitch.message.Message import com.flxrs.dankchat.data.twitch.message.PrivMessage import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.utils.MultiCallback +import com.flxrs.dankchat.utils.extensions.analyzeCodePoints import com.flxrs.dankchat.utils.extensions.appendSpacesBetweenEmojiGroup import com.flxrs.dankchat.utils.extensions.chunkedBy +import com.flxrs.dankchat.utils.extensions.codePointAsString import com.flxrs.dankchat.utils.extensions.concurrentMap -import com.flxrs.dankchat.utils.extensions.removeDuplicateWhitespace -import com.flxrs.dankchat.utils.extensions.supplementaryCodePointPositions -import kotlinx.coroutines.Dispatchers +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.koin.core.annotation.Single +import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList +private val logger = KotlinLogging.logger("EmoteRepository") + @Single class EmoteRepository( - private val dankChatApiClient: DankChatApiClient, + private val helixApiClient: HelixApiClient, private val chatSettingsDataStore: ChatSettingsDataStore, private val channelRepository: ChannelRepository, + private val dispatchersProvider: DispatchersProvider, ) { - private val ffzModBadges = ConcurrentHashMap() - private val ffzVipBadges = ConcurrentHashMap() + private val ffzModBadges = ConcurrentHashMap() + private val ffzVipBadges = ConcurrentHashMap() private val channelBadges = ConcurrentHashMap>() private val globalBadges = ConcurrentHashMap() private val dankChatBadges = CopyOnWriteArrayList() private val sevenTvChannelDetails = ConcurrentHashMap() - private val emotes = ConcurrentHashMap>() + private val globalEmoteState = MutableStateFlow(GlobalEmoteState()) + private val channelEmoteStates = ConcurrentHashMap>() + + /** + * Per-channel cache of the merged 3rd-party emote lookup map (without Twitch emotes). + * Invalidated via referential identity checks on the global/channel state snapshots. + */ + private val cachedEmoteMaps = ConcurrentHashMap() - val badgeCache = LruCache(64) - val layerCache = LruCache(256) - val gifCallback = MultiCallback() + fun getEmotes(channel: UserName): Flow { + val channelFlow = channelEmoteStates.getOrPut(channel) { MutableStateFlow(ChannelEmoteState()) } + return combine(globalEmoteState, channelFlow, ::mergeEmotes) + } - fun getEmotes(channel: UserName): StateFlow = emotes.getOrPut(channel) { MutableStateFlow(Emotes()) } fun createFlowsIfNecessary(channels: List) { - channels.forEach { emotes.putIfAbsent(it, MutableStateFlow(Emotes())) } + channels.forEach { channelEmoteStates.putIfAbsent(it, MutableStateFlow(ChannelEmoteState())) } } fun removeChannel(channel: UserName) { - emotes.remove(channel) + channelEmoteStates.remove(channel) + cachedEmoteMaps.remove(channel) } - fun parse3rdPartyEmotes(message: String, channel: UserName, withTwitch: Boolean = false): List { - val splits = message.split(WHITESPACE_REGEX) - val available = emotes[channel]?.value ?: return emptyList() + fun clearTwitchEmotes() { + globalEmoteState.update { it.copy(twitchEmotes = emptyList()) } + channelEmoteStates.values.forEach { state -> + state.update { it.copy(twitchEmotes = emptyList()) } + } + } + fun parse3rdPartyEmotes( + message: String, + channel: UserName, + withTwitch: Boolean = false, + ): List { + val emoteMap = getOrBuildEmoteMap(channel, withTwitch) + + // Single pass through words + var currentPosition = 0 return buildList { - if (withTwitch) { - addAll(available.twitchEmotes.flatMap { parseMessageForEmote(it, splits) }) + message.split(WHITESPACE_REGEX).forEach { word -> + emoteMap[word]?.let { emote -> + this += + ChatMessageEmote( + position = currentPosition..currentPosition + word.length, + url = emote.url, + id = emote.id, + code = emote.code, + scale = emote.scale, + type = emote.emoteType.toChatMessageEmoteType() ?: ChatMessageEmoteType.TwitchEmote, + isOverlayEmote = emote.isOverlayEmote, + ) + } + currentPosition += word.length + 1 } + } + } - if (channel != WhisperMessage.WHISPER_CHANNEL) { - addAll(available.ffzChannelEmotes.flatMap { parseMessageForEmote(it, splits) }) - addAll(available.bttvChannelEmotes.flatMap { parseMessageForEmote(it, splits) }) - addAll(available.sevenTvChannelEmotes.flatMap { parseMessageForEmote(it, splits) }) + fun findEmoteIdsInMessage( + message: String, + channel: UserName, + ): Set { + val emoteMap = getOrBuildEmoteMap(channel, withTwitch = true) + return buildSet { + message.split(WHITESPACE_REGEX).forEach { word -> + emoteMap[word]?.let { add(it.id) } } + } + } + + private fun getOrBuildEmoteMap( + channel: UserName, + withTwitch: Boolean, + ): Map { + val globalState = globalEmoteState.value + val channelState = channelEmoteStates[channel]?.value ?: ChannelEmoteState() + + // Use cached map for the hot path (without Twitch emotes) + if (!withTwitch) { + val cached = cachedEmoteMaps[channel] + if (cached != null && cached.globalState === globalState && cached.channelState === channelState) { + return cached.map + } + } - addAll(available.ffzGlobalEmotes.flatMap { parseMessageForEmote(it, splits) }) - addAll(available.bttvGlobalEmotes.flatMap { parseMessageForEmote(it, splits) }) - addAll(available.sevenTvGlobalEmotes.flatMap { parseMessageForEmote(it, splits) }) - }.distinctBy { it.code to it.position } + val isWhisper = channel == WhisperMessage.WHISPER_CHANNEL + + // Build lookup map: lowest priority first, highest last (last write wins) + // Priority: Twitch > Channel FFZ > Channel BTTV > Channel 7TV > Global FFZ > Global BTTV > Global 7TV + val map = HashMap() + globalState.sevenTvEmotes.associateByTo(map) { it.code } + globalState.bttvEmotes.associateByTo(map) { it.code } + globalState.ffzEmotes.associateByTo(map) { it.code } + if (!isWhisper) { + channelState.sevenTvEmotes.associateByTo(map) { it.code } + channelState.bttvEmotes.associateByTo(map) { it.code } + channelState.ffzEmotes.associateByTo(map) { it.code } + } + if (withTwitch) { + globalState.twitchEmotes.associateByTo(map) { it.code } + channelState.twitchEmotes.associateByTo(map) { it.code } + } + + if (!withTwitch) { + cachedEmoteMaps[channel] = CachedEmoteMap(globalState, channelState, map) + } + + return map } suspend fun parseEmotesAndBadges(message: Message): Message { @@ -113,42 +194,61 @@ class EmoteRepository( val emoteData = message.emoteData ?: return message val (messageString, channel, emotesWithPositions) = emoteData - val withEmojiFix = messageString.replace( - ChatRepository.ESCAPE_TAG_REGEX, - ChatRepository.ZERO_WIDTH_JOINER - ) + val withEmojiFix = + messageString.replace( + ESCAPE_TAG_REGEX, + ZERO_WIDTH_JOINER, + ) - // Twitch counts characters with supplementary codepoints as one while java based strings counts them as two. - // We need to find these codepoints and adjust emote positions to not break text-emote replacing - val supplementaryCodePointPositions = withEmojiFix.supplementaryCodePointPositions - val (duplicateSpaceAdjustedMessage, removedSpaces) = withEmojiFix.removeDuplicateWhitespace() + // Combined single-pass: find supplementary codepoint positions AND remove duplicate whitespace + val (supplementaryCodePointPositions, duplicateSpaceAdjustedMessage, removedSpaces) = withEmojiFix.analyzeCodePoints() val (appendedSpaceAdjustedMessage, appendedSpaces) = duplicateSpaceAdjustedMessage.appendSpacesBetweenEmojiGroup() - val twitchEmotes = parseTwitchEmotes( - emotesWithPositions = emotesWithPositions, + val twitchEmotes = + parseTwitchEmotes( + emotesWithPositions = emotesWithPositions, + message = appendedSpaceAdjustedMessage, + supplementaryCodePointPositions = supplementaryCodePointPositions, + appendedSpaces = appendedSpaces, + removedSpaces = removedSpaces, + replyMentionOffset = replyMentionOffset, + ) + val twitchEmoteCodes = twitchEmotes.mapTo(mutableSetOf()) { it.code } + val hasBits = message is PrivMessage && message.tags["bits"] != null + val (thirdPartyEmotes, cheermotes) = parseNonTwitchEmotes( message = appendedSpaceAdjustedMessage, - supplementaryCodePointPositions = supplementaryCodePointPositions, - appendedSpaces = appendedSpaces, - removedSpaces = removedSpaces, - replyMentionOffset = replyMentionOffset + channel = channel, + excludeCodes = twitchEmoteCodes, + hasBits = hasBits, ) - val thirdPartyEmotes = parse3rdPartyEmotes(appendedSpaceAdjustedMessage, channel).filterNot { e -> twitchEmotes.any { it.code == e.code } } - val emotes = (twitchEmotes + thirdPartyEmotes) + val emotes = twitchEmotes + thirdPartyEmotes + cheermotes val (adjustedMessage, adjustedEmotes) = adjustOverlayEmotes(appendedSpaceAdjustedMessage, emotes) - val messageWithEmotes = when (message) { - is PrivMessage -> message.copy(message = adjustedMessage, emotes = adjustedEmotes, originalMessage = withEmojiFix) - is WhisperMessage -> message.copy(message = adjustedMessage, emotes = adjustedEmotes, originalMessage = withEmojiFix) - is UserNoticeMessage -> message.copy( - childMessage = message.childMessage?.copy( - message = adjustedMessage, - emotes = adjustedEmotes, - originalMessage = withEmojiFix, - ) - ) + val messageWithEmotes = + when (message) { + is PrivMessage -> { + message.copy(message = adjustedMessage, emotes = adjustedEmotes, originalMessage = withEmojiFix) + } - else -> message - } + is WhisperMessage -> { + message.copy(message = adjustedMessage, emotes = adjustedEmotes, originalMessage = withEmojiFix) + } + + is UserNoticeMessage -> { + message.copy( + childMessage = + message.childMessage?.copy( + message = adjustedMessage, + emotes = adjustedEmotes, + originalMessage = withEmojiFix, + ), + ) + } + + else -> { + message + } + } return parseBadges(messageWithEmotes) } @@ -157,77 +257,108 @@ class EmoteRepository( val badgeData = message.badgeData ?: return message val (userId, channel, badgeTag, badgeInfoTag) = badgeData - val badgeInfos = badgeInfoTag - ?.parseTagList() - ?.associate { it.key to it.value } - .orEmpty() - - val badges = badgeTag - ?.parseTagList() - ?.mapNotNull { (badgeKey, badgeValue, tag) -> - val badgeInfo = badgeInfos[badgeKey] - - val globalBadgeUrl = getGlobalBadgeUrl(badgeKey, badgeValue) - val channelBadgeUrl = getChannelBadgeUrl(channel, badgeKey, badgeValue) - val ffzModBadgeUrl = getFfzModBadgeUrl(channel) - val ffzVipBadgeUrl = getFfzVipBadgeUrl(channel) - - val title = getBadgeTitle(channel, badgeKey, badgeValue) - val type = BadgeType.parseFromBadgeId(badgeKey) - when { - badgeKey.startsWith("moderator") && ffzModBadgeUrl != null -> Badge.FFZModBadge( - title = title, - badgeTag = tag, - badgeInfo = badgeInfo, - url = ffzModBadgeUrl, - type = type - ) + val badgeInfos = + badgeInfoTag + ?.parseTagList() + ?.associate { it.key to it.value } + .orEmpty() + + val badges = + badgeTag + ?.parseTagList() + ?.mapNotNull { (badgeKey, badgeValue, tag) -> + val badgeInfo = badgeInfos[badgeKey] + + val globalBadgeUrl = getGlobalBadgeUrl(badgeKey, badgeValue) + val channelBadgeUrl = getChannelBadgeUrl(channel, badgeKey, badgeValue) + val ffzModBadgeUrl = getFfzModBadgeUrl(channel) + val ffzVipBadgeUrl = getFfzVipBadgeUrl(channel) + + val title = getBadgeTitle(channel, badgeKey, badgeValue) + val type = BadgeType.parseFromBadgeId(badgeKey) + when { + badgeKey.startsWith("moderator") && ffzModBadgeUrl != null -> { + Badge.FFZModBadge( + title = title, + badgeTag = tag, + badgeInfo = badgeInfo, + url = ffzModBadgeUrl, + type = type, + ) + } - badgeKey.startsWith("vip") && ffzVipBadgeUrl != null -> Badge.FFZVipBadge( - title = title, - badgeTag = tag, - badgeInfo = badgeInfo, - url = ffzVipBadgeUrl, - type = type - ) + badgeKey.startsWith("vip") && ffzVipBadgeUrl != null -> { + Badge.FFZVipBadge( + title = title, + badgeTag = tag, + badgeInfo = badgeInfo, + url = ffzVipBadgeUrl, + type = type, + ) + } - (badgeKey.startsWith("subscriber") || badgeKey.startsWith("bits")) - && channelBadgeUrl != null -> Badge.ChannelBadge( - title = title, - badgeTag = tag, - badgeInfo = badgeInfo, - url = channelBadgeUrl, - type = type - ) + (badgeKey.startsWith("subscriber") || badgeKey.startsWith("bits")) && + channelBadgeUrl != null -> { + Badge.ChannelBadge( + title = title, + badgeTag = tag, + badgeInfo = badgeInfo, + url = channelBadgeUrl, + type = type, + ) + } - else -> globalBadgeUrl?.let { Badge.GlobalBadge(title, tag, badgeInfo, it, type) } - } - }.orEmpty() + else -> { + globalBadgeUrl?.let { Badge.GlobalBadge(title, tag, badgeInfo, it, type) } + } + } + }.orEmpty() val sharedChatBadge = getSharedChatBadge(message) - val allBadges = buildList { - if (sharedChatBadge != null) { - add(sharedChatBadge) - } - addAll(badges) - val badge = getDankChatBadgeTitleAndUrl(userId) - if (badge != null) { - add(Badge.DankChatBadge(title = badge.first, badgeTag = null, badgeInfo = null, url = badge.second, type = BadgeType.DankChat)) + val allBadges = + buildList { + if (sharedChatBadge != null) { + add(sharedChatBadge) + } + addAll(badges) + val badge = getDankChatBadgeTitleAndUrl(userId) + if (badge != null) { + add(Badge.DankChatBadge(title = badge.first, badgeTag = null, badgeInfo = null, url = badge.second, type = BadgeType.DankChat)) + } } - } return when (message) { - is PrivMessage -> message.copy(badges = allBadges) - is WhisperMessage -> message.copy(badges = allBadges) - is UserNoticeMessage -> message.copy( - childMessage = message.childMessage?.copy(badges = allBadges) - ) + is PrivMessage -> { + message.copy(badges = allBadges) + } + + is WhisperMessage -> { + message.copy(badges = allBadges) + } - else -> message + is UserNoticeMessage -> { + message.copy( + childMessage = message.childMessage?.copy(badges = allBadges), + ) + } + + else -> { + message + } } } - data class TagListEntry(val key: String, val value: String, val tag: String) + private data class CachedEmoteMap( + val globalState: GlobalEmoteState, + val channelState: ChannelEmoteState, + val map: Map, + ) + + data class TagListEntry( + val key: String, + val value: String, + val tag: String, + ) private fun String.parseTagList(): List = split(',') .mapNotNull { @@ -240,14 +371,35 @@ class EmoteRepository( TagListEntry(key, value, it) } - private fun getChannelBadgeUrl(channel: UserName?, set: String, version: String) = channel?.let { channelBadges[channel]?.get(set)?.versions?.get(version)?.imageUrlHigh } - - private fun getGlobalBadgeUrl(set: String, version: String) = globalBadges[set]?.versions?.get(version)?.imageUrlHigh + private fun getChannelBadgeUrl( + channel: UserName?, + set: String, + version: String, + ) = channel?.let { + channelBadges[channel] + ?.get(set) + ?.versions + ?.get(version) + ?.imageUrlHigh + } - private fun getBadgeTitle(channel: UserName?, set: String, version: String): String? { - return channel?.let { channelBadges[channel]?.get(set)?.versions?.get(version)?.title } - ?: globalBadges[set]?.versions?.get(version)?.title + private fun getGlobalBadgeUrl( + set: String, + version: String, + ) = globalBadges[set]?.versions?.get(version)?.imageUrlHigh + + private fun getBadgeTitle( + channel: UserName?, + set: String, + version: String, + ): String? = channel?.let { + channelBadges[channel] + ?.get(set) + ?.versions + ?.get(version) + ?.title } + ?: globalBadges[set]?.versions?.get(version)?.title private fun getFfzModBadgeUrl(channel: UserName?): String? = channel?.let { ffzModBadges[channel] } @@ -267,11 +419,14 @@ class EmoteRepository( } return Badge.SharedChatBadge( url = channel?.avatarUrl?.replace(oldValue = "300x300", newValue = "70x70").orEmpty(), - title = "Shared Message${channel?.displayName?.let { " from $it" }.orEmpty()}" + title = "Shared Message${channel?.displayName?.let { " from $it" }.orEmpty()}", ) } - fun setChannelBadges(channel: UserName, badges: Map) { + fun setChannelBadges( + channel: UserName, + badges: Map, + ) { channelBadges[channel] = badges } @@ -280,6 +435,7 @@ class EmoteRepository( } fun setDankChatBadges(dto: List) { + dankChatBadges.clear() dankChatBadges.addAll(dto) } @@ -290,199 +446,355 @@ class EmoteRepository( fun getSevenTVUserDetails(channel: UserName): SevenTVUserDetails? = sevenTvChannelDetails[channel] - suspend fun loadUserStateEmotes(globalEmoteSetIds: List, followerEmoteSetIds: Map>) = withContext(Dispatchers.Default) { - val sets = (globalEmoteSetIds + followerEmoteSetIds.values.flatten()) - .distinct() - .chunkedBy(maxSize = MAX_PARAMS_LENGTH) { it.length + 3 } - .concurrentMap { - dankChatApiClient.getUserSets(it) - .getOrNull() - .orEmpty() + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = runCatching { + loadUserEmotesViaHelix(userId, onFirstPageLoaded) + } + + private suspend fun loadUserEmotesViaHelix( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ) = withContext(dispatchersProvider.default) { + val seenIds = mutableSetOf() + val allEmotes = mutableListOf() + var totalCount = 0 + var isFirstPage = true + + helixApiClient.getUserEmotesFlow(userId).collect { page -> + totalCount += page.size + + val newGlobalEmotes = mutableListOf() + val newChannelDtos = mutableListOf() + + for (emote in page) { + if (!seenIds.add(emote.id)) continue + + if (emote.emoteType in CHANNEL_EMOTE_TYPES) { + newChannelDtos.add(emote) + } else { + newGlobalEmotes.add(emote.toGenericEmote(EmoteType.GlobalTwitchEmote)) + } } - .flatten() - - val twitchEmotes = sets.flatMap { emoteSet -> - val type = when (val set = emoteSet.id) { - "0", "42" -> EmoteType.GlobalTwitchEmote // 42 == monkey emote set, move them to the global emote section - else -> { - followerEmoteSetIds.entries - .find { (_, sets) -> - set in sets + + // Resolve channel emotes from this page — getChannelsByIds caches results, + // so repeated owner IDs across pages are cheap lookups + if (newChannelDtos.isNotEmpty()) { + val ownerIds = + newChannelDtos + .filter { it.ownerId.isNotBlank() } + .map { it.ownerId.toUserId() } + .distinct() + + val channelsByIdMap = + channelRepository + .getChannelsByIds(ownerIds) + .associateBy { it.id } + + for (emote in newChannelDtos) { + val type = + when (emote.emoteType) { + "subscriptions" -> { + val channel = channelsByIdMap[emote.ownerId.toUserId()] + channel?.name?.let { EmoteType.ChannelTwitchEmote(it) } ?: EmoteType.GlobalTwitchEmote + } + + "bitstier" -> { + val channel = channelsByIdMap[emote.ownerId.toUserId()] + channel?.name?.let { EmoteType.ChannelTwitchBitEmote(it) } ?: EmoteType.GlobalTwitchEmote + } + + "follower" -> { + val channel = channelsByIdMap[emote.ownerId.toUserId()] + channel?.name?.let { EmoteType.ChannelTwitchFollowerEmote(it) } ?: EmoteType.GlobalTwitchEmote + } + + else -> { + EmoteType.GlobalTwitchEmote + } } - ?.let { EmoteType.ChannelTwitchFollowerEmote(it.key) } - ?: emoteSet.channelName.twitchEmoteType + newGlobalEmotes.add(emote.toGenericEmote(type)) } } - emoteSet.emotes.mapToGenericEmotes(type) - } - emotes.forEach { (channel, flow) -> - flow.update { - it.copy( - twitchEmotes = twitchEmotes.filterNot { emote -> emote.emoteType is EmoteType.ChannelTwitchFollowerEmote && emote.emoteType.channel != channel } - ) + if (newGlobalEmotes.isNotEmpty()) { + allEmotes.addAll(newGlobalEmotes) + globalEmoteState.update { it.copy(twitchEmotes = allEmotes.toList()) } + } + + if (isFirstPage) { + isFirstPage = false + onFirstPageLoaded?.invoke() } } + + logger.debug { "Helix getUserEmotes: $totalCount total, ${seenIds.size} unique, ${allEmotes.size} resolved" } } - suspend fun setFFZEmotes(channel: UserName, ffzResult: FFZChannelDto) = withContext(Dispatchers.Default) { - val ffzEmotes = ffzResult.sets - .flatMap { set -> - set.value.emotes.mapNotNull { - parseFFZEmote(it, channel) + suspend fun setFFZEmotes( + channel: UserName, + ffzResult: FFZChannelDto, + ) = withContext(dispatchersProvider.default) { + val ffzEmotes = + ffzResult.sets + .flatMap { set -> + set.value.emotes.mapNotNull { + parseFFZEmote(it, channel) + } } - } - emotes[channel]?.update { - it.copy(ffzChannelEmotes = ffzEmotes) + channelEmoteStates[channel]?.update { + it.copy(ffzEmotes = ffzEmotes) } ffzResult.room.modBadgeUrls?.let { - val url = it["4"] ?: it["2"] ?: it["1"] - ffzModBadges[channel] = url?.withLeadingHttps + val url = it["4"] ?: it["2"] ?: it["1"] ?: return@let + ffzModBadges[channel] = url.withLeadingHttps } ffzResult.room.vipBadgeUrls?.let { - val url = it["4"] ?: it["2"] ?: it["1"] - ffzVipBadges[channel] = url?.withLeadingHttps + val url = it["4"] ?: it["2"] ?: it["1"] ?: return@let + ffzVipBadges[channel] = url.withLeadingHttps } } - suspend fun setFFZGlobalEmotes(ffzResult: FFZGlobalDto) = withContext(Dispatchers.Default) { - val ffzGlobalEmotes = ffzResult.sets - .filter { it.key in ffzResult.defaultSets } - .flatMap { (_, emoteSet) -> - emoteSet.emotes.mapNotNull { emote -> - parseFFZEmote(emote, channel = null) + suspend fun setFFZGlobalEmotes(ffzResult: FFZGlobalDto) = withContext(dispatchersProvider.default) { + val ffzGlobalEmotes = + ffzResult.sets + .filter { it.key in ffzResult.defaultSets } + .flatMap { (_, emoteSet) -> + emoteSet.emotes.mapNotNull { emote -> + parseFFZEmote(emote, channel = null) + } } - } - emotes.values.forEach { flow -> - flow.update { - it.copy(ffzGlobalEmotes = ffzGlobalEmotes) - } - } + globalEmoteState.update { it.copy(ffzEmotes = ffzGlobalEmotes) } } - suspend fun setBTTVEmotes(channel: UserName, channelDisplayName: DisplayName, bttvResult: BTTVChannelDto) = withContext(Dispatchers.Default) { + suspend fun setBTTVEmotes( + channel: UserName, + channelDisplayName: DisplayName, + bttvResult: BTTVChannelDto, + ) = withContext(dispatchersProvider.default) { val bttvEmotes = (bttvResult.emotes + bttvResult.sharedEmotes).map { parseBTTVEmote(it, channelDisplayName) } - emotes[channel]?.update { - it.copy(bttvChannelEmotes = bttvEmotes) + channelEmoteStates[channel]?.update { + it.copy(bttvEmotes = bttvEmotes) } } - suspend fun setBTTVGlobalEmotes(globalEmotes: List) = withContext(Dispatchers.Default) { + suspend fun setBTTVGlobalEmotes(globalEmotes: List) = withContext(dispatchersProvider.default) { val bttvGlobalEmotes = globalEmotes.map { parseBTTVGlobalEmote(it) } - emotes.values.forEach { flow -> - flow.update { - it.copy(bttvGlobalEmotes = bttvGlobalEmotes) - } - } + globalEmoteState.update { it.copy(bttvEmotes = bttvGlobalEmotes) } } - suspend fun setSevenTVEmotes(channel: UserName, userDto: SevenTVUserDto) = withContext(Dispatchers.Default) { + suspend fun setSevenTVEmotes( + channel: UserName, + userDto: SevenTVUserDto, + ) = withContext(dispatchersProvider.default) { val emoteSetId = userDto.emoteSet?.id ?: return@withContext val emoteList = userDto.emoteSet.emotes.orEmpty() - sevenTvChannelDetails[channel] = SevenTVUserDetails( - id = userDto.user.id, - activeEmoteSetId = emoteSetId, - connectionIndex = userDto.user.connections.indexOfFirst { it.platform == SevenTVUserConnection.twitch } - ) - val sevenTvEmotes = emoteList - .filterUnlistedIfEnabled() - .mapNotNull { emote -> - parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) - } + sevenTvChannelDetails[channel] = + SevenTVUserDetails( + id = userDto.user.id, + activeEmoteSetId = emoteSetId, + connectionIndex = userDto.user.connections.indexOfFirst { it.platform == SevenTVUserConnection.twitch }, + ) + val sevenTvEmotes = + emoteList + .filterUnlistedIfEnabled() + .mapNotNull { emote -> + parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) + } - emotes[channel]?.update { - it.copy(sevenTvChannelEmotes = sevenTvEmotes) + channelEmoteStates[channel]?.update { + it.copy(sevenTvEmotes = sevenTvEmotes) } } - suspend fun setSevenTVEmoteSet(channel: UserName, emoteSet: SevenTVEmoteSetDto) = withContext(Dispatchers.Default) { + suspend fun setSevenTVEmoteSet( + channel: UserName, + emoteSet: SevenTVEmoteSetDto, + ) = withContext(dispatchersProvider.default) { sevenTvChannelDetails[channel]?.let { details -> sevenTvChannelDetails[channel] = details.copy(activeEmoteSetId = emoteSet.id) } - val sevenTvEmotes = emoteSet.emotes - .orEmpty() - .filterUnlistedIfEnabled() - .mapNotNull { emote -> - parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) - } + val sevenTvEmotes = + emoteSet.emotes + .orEmpty() + .filterUnlistedIfEnabled() + .mapNotNull { emote -> + parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) + } - emotes[channel]?.update { - it.copy(sevenTvChannelEmotes = sevenTvEmotes) + channelEmoteStates[channel]?.update { + it.copy(sevenTvEmotes = sevenTvEmotes) } } - suspend fun updateSevenTVEmotes(channel: UserName, event: SevenTVEventMessage.EmoteSetUpdated) = withContext(Dispatchers.Default) { - val addedEmotes = event.added - .filterUnlistedIfEnabled() - .mapNotNull { emote -> - parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) - } - - emotes[channel]?.update { emotes -> - val updated = emotes.sevenTvChannelEmotes.mapNotNull { emote -> + suspend fun updateSevenTVEmotes( + channel: UserName, + event: SevenTVEventMessage.EmoteSetUpdated, + ) = withContext(dispatchersProvider.default) { + val addedEmotes = + event.added + .filterUnlistedIfEnabled() + .mapNotNull { emote -> + parseSevenTVEmote(emote, EmoteType.ChannelSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) + } - if (event.removed.any { emote.id == it.id }) { - null - } else { - event.updated.find { emote.id == it.id }?.let { update -> - val mapNewBaseName = { oldBase: String? -> (oldBase ?: emote.code).takeIf { it != update.name } } - val newType = when (emote.emoteType) { - is EmoteType.ChannelSevenTVEmote -> emote.emoteType.copy(baseName = mapNewBaseName(emote.emoteType.baseName)) - is EmoteType.GlobalSevenTVEmote -> emote.emoteType.copy(baseName = mapNewBaseName(emote.emoteType.baseName)) - else -> emote.emoteType - } - emote.copy(code = update.name, emoteType = newType) - } ?: emote + channelEmoteStates[channel]?.update { state -> + val updated = + state.sevenTvEmotes.mapNotNull { emote -> + + if (event.removed.any { emote.id == it.id }) { + null + } else { + event.updated.find { emote.id == it.id }?.let { update -> + val mapNewBaseName = { oldBase: String? -> (oldBase ?: emote.code).takeIf { it != update.name } } + val newType = + when (emote.emoteType) { + is EmoteType.ChannelSevenTVEmote -> emote.emoteType.copy(baseName = mapNewBaseName(emote.emoteType.baseName)) + is EmoteType.GlobalSevenTVEmote -> emote.emoteType.copy(baseName = mapNewBaseName(emote.emoteType.baseName)) + else -> emote.emoteType + } + emote.copy(code = update.name, emoteType = newType) + } ?: emote + } } - } - emotes.copy(sevenTvChannelEmotes = updated + addedEmotes) + state.copy(sevenTvEmotes = updated + addedEmotes) } } - suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(Dispatchers.Default) { + suspend fun setSevenTVGlobalEmotes(sevenTvResult: List) = withContext(dispatchersProvider.default) { if (sevenTvResult.isEmpty()) return@withContext - val sevenTvGlobalEmotes = sevenTvResult - .filterUnlistedIfEnabled() - .mapNotNull { emote -> - parseSevenTVEmote(emote, EmoteType.GlobalSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) - } + val sevenTvGlobalEmotes = + sevenTvResult + .filterUnlistedIfEnabled() + .mapNotNull { emote -> + parseSevenTVEmote(emote, EmoteType.GlobalSevenTVEmote(emote.data?.owner?.displayName, emote.data?.baseName?.takeIf { emote.name != it })) + } + + globalEmoteState.update { it.copy(sevenTvEmotes = sevenTvGlobalEmotes) } + } - emotes.values.forEach { flow -> - flow.update { - it.copy(sevenTvGlobalEmotes = sevenTvGlobalEmotes) + suspend fun setCheermotes( + channel: UserName, + cheermoteDtos: List, + ) = withContext(dispatchersProvider.default) { + val cheermoteSets = + cheermoteDtos.map { dto -> + CheermoteSet( + prefix = dto.prefix, + regex = Regex("^${Regex.escape(dto.prefix)}([1-9][0-9]*)$", RegexOption.IGNORE_CASE), + tiers = + dto.tiers + .sortedByDescending { it.minBits } + .map { tier -> + CheermoteTier( + minBits = tier.minBits, + color = + try { + tier.color.toColorInt() + } catch (_: IllegalArgumentException) { + Color.GRAY + }, + animatedUrl = + tier.images.dark.animated["2"] ?: tier.images.dark.animated["1"] + .orEmpty(), + staticUrl = + tier.images.dark.static["2"] ?: tier.images.dark.static["1"] + .orEmpty(), + ) + }, + ) } + channelEmoteStates[channel]?.update { + it.copy(cheermoteSets = cheermoteSets) } } - private val UserName?.twitchEmoteType: EmoteType - get() = when { - this == null || isGlobalTwitchChannel -> EmoteType.GlobalTwitchEmote - else -> EmoteType.ChannelTwitchEmote(this) + private fun parseNonTwitchEmotes( + message: String, + channel: UserName, + excludeCodes: Set, + hasBits: Boolean, + ): Pair, List> { + val emoteMap = getOrBuildEmoteMap(channel, withTwitch = false) + val cheermoteSets = if (hasBits) { + channelEmoteStates[channel]?.value?.cheermoteSets.orEmpty() + } else { + emptyList() } - private val UserName.isGlobalTwitchChannel: Boolean - get() = value.equals("qa_TW_Partner", ignoreCase = true) || value.equals("Twitch", ignoreCase = true) + val thirdPartyEmotes = mutableListOf() + val cheermotes = mutableListOf() + var currentPosition = 0 - private fun List?.mapToGenericEmotes(type: EmoteType): List = this?.map { (name, id) -> - val code = when (type) { - is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name - else -> name + message.split(WHITESPACE_REGEX).forEach { word -> + var matchedCheermote = false + if (cheermoteSets.isNotEmpty()) { + for (set in cheermoteSets) { + val match = set.regex.matchEntire(word) + if (match != null) { + val bits = match.groupValues[1].toIntOrNull() ?: break + val tier = set.tiers.firstOrNull { bits >= it.minBits } ?: break + cheermotes += + ChatMessageEmote( + position = currentPosition..currentPosition + word.length, + url = tier.animatedUrl, + id = "${set.prefix}_$bits", + code = word, + scale = 1, + type = ChatMessageEmoteType.Cheermote, + cheerAmount = bits, + cheerColor = tier.color, + ) + matchedCheermote = true + break + } + } + } + if (!matchedCheermote && word !in excludeCodes) { + emoteMap[word]?.let { emote -> + thirdPartyEmotes += + ChatMessageEmote( + position = currentPosition..currentPosition + word.length, + url = emote.url, + id = emote.id, + code = emote.code, + scale = emote.scale, + type = emote.emoteType.toChatMessageEmoteType() ?: ChatMessageEmoteType.TwitchEmote, + isOverlayEmote = emote.isOverlayEmote, + ) + } + } + currentPosition += word.length + 1 } - GenericEmote( + + return thirdPartyEmotes to cheermotes + } + + private fun UserEmoteDto.toGenericEmote(type: EmoteType): GenericEmote { + val code = + when (type) { + is EmoteType.GlobalTwitchEmote -> EMOTE_REPLACEMENTS[name] ?: name + else -> name + } + return GenericEmote( code = code, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), - lowResUrl = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_LOW_RES_EMOTE_SIZE), + url = TWITCH_EMOTE_TEMPLATE.format(Locale.ROOT, id, TWITCH_EMOTE_SIZE), + lowResUrl = TWITCH_EMOTE_TEMPLATE.format(Locale.ROOT, id, TWITCH_LOW_RES_EMOTE_SIZE), id = id, scale = 1, - emoteType = type + emoteType = type, ) - }.orEmpty() + } @VisibleForTesting - fun adjustOverlayEmotes(message: String, emotes: List): Pair> { + fun adjustOverlayEmotes( + message: String, + emotes: List, + ): Pair> { var adjustedMessage = message val adjustedEmotes = emotes.sortedBy { it.position.first }.toMutableList() @@ -503,17 +815,18 @@ class EmoteRepository( val actualDistanceToRegularEmote = emote.position.first - previousEmote.position.last // The "distance" between the found non-overlay emote and the current overlay emote does not match the expected, valid distance - // This means, that there are non-emote "words" in-between and we should not adjust this overlay emote + // This means, that there are non-emote "words" in-between, and we should not adjust this overlay emote // Example: FeelsDankMan asd cvHazmat RainTime // actualDistanceToRegularEmote = 14 != distanceToRegularEmote = 10 -> break if (actualDistanceToRegularEmote != distanceToRegularEmote) { break } - adjustedMessage = when (emote.position.last) { - adjustedMessage.length -> adjustedMessage.substring(0, emote.position.first) - else -> adjustedMessage.removeRange(emote.position) - } + adjustedMessage = + when (emote.position.last) { + adjustedMessage.length -> adjustedMessage.substring(0, emote.position.first) + else -> adjustedMessage.removeRange(emote.position) + } adjustedEmotes[i] = emote.copy(position = previousEmote.position) foundEmote = true @@ -539,28 +852,25 @@ class EmoteRepository( return adjustedMessage to adjustedEmotes } - @SuppressLint("BuildListAdds") - private fun parseMessageForEmote(emote: GenericEmote, words: List): List { - var currentPosition = 0 - return buildList { - words.forEach { word -> - if (emote.code == word) { - this += ChatMessageEmote( - position = currentPosition..currentPosition + word.length, - url = emote.url, - id = emote.id, - code = emote.code, - scale = emote.scale, - type = emote.emoteType.toChatMessageEmoteType() ?: ChatMessageEmoteType.TwitchEmote, - isOverlayEmote = emote.isOverlayEmote - ) - } - currentPosition += word.length + 1 - } + /** + * Counts elements in a sorted list that are strictly less than [value] using binary search. + */ + @VisibleForTesting + internal fun countLessThan( + sortedList: List, + value: Int, + ): Int { + var low = 0 + var high = sortedList.size + while (low < high) { + val mid = (low + high) ushr 1 + if (sortedList[mid] < value) low = mid + 1 else high = mid } + return low } - private fun parseTwitchEmotes( + @VisibleForTesting + internal fun parseTwitchEmotes( emotesWithPositions: List, message: String, supplementaryCodePointPositions: List, @@ -569,32 +879,39 @@ class EmoteRepository( replyMentionOffset: Int, ): List = emotesWithPositions.flatMap { (id, positions) -> positions.map { range -> - val removedSpaceExtra = removedSpaces.count { it < range.first } - val unicodeExtra = supplementaryCodePointPositions.count { it < range.first - removedSpaceExtra } - val spaceExtra = appendedSpaces.count { it < range.first + unicodeExtra } - val fixedStart = range.first + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset - val fixedEnd = range.last + unicodeExtra + spaceExtra - removedSpaceExtra - replyMentionOffset + // Twitch positions include the reply mention prefix, but our message/positions are stripped. + // Subtract replyMentionOffset first so lookups align with the stripped message. + val adjustedFirst = range.first - replyMentionOffset + val adjustedLast = range.last - replyMentionOffset + val removedSpaceExtra = countLessThan(removedSpaces, adjustedFirst) + val unicodeExtra = countLessThan(supplementaryCodePointPositions, adjustedFirst - removedSpaceExtra) + val spaceExtra = countLessThan(appendedSpaces, adjustedFirst + unicodeExtra) + val fixedStart = adjustedFirst + unicodeExtra + spaceExtra - removedSpaceExtra + val fixedEnd = adjustedLast + unicodeExtra + spaceExtra - removedSpaceExtra // be extra safe in case twitch sends invalid emote ranges :) val fixedPos = fixedStart.coerceAtLeast(minimumValue = 0)..(fixedEnd + 1).coerceAtMost(message.length) val code = message.substring(fixedPos.first, fixedPos.last) ChatMessageEmote( position = fixedPos, - url = TWITCH_EMOTE_TEMPLATE.format(id, TWITCH_EMOTE_SIZE), + url = TWITCH_EMOTE_TEMPLATE.format(Locale.ROOT, id, TWITCH_EMOTE_SIZE), id = id, code = code, scale = 1, type = ChatMessageEmoteType.TwitchEmote, - isTwitch = true + isTwitch = true, ) } } - private fun parseBTTVEmote(emote: BTTVEmoteDto, channelDisplayName: DisplayName): GenericEmote { + private fun parseBTTVEmote( + emote: BTTVEmoteDto, + channelDisplayName: DisplayName, + ): GenericEmote { val name = emote.code val id = emote.id - val url = BTTV_EMOTE_TEMPLATE.format(id, BTTV_EMOTE_SIZE) - val lowResUrl = BTTV_EMOTE_TEMPLATE.format(id, BTTV_LOW_RES_EMOTE_SIZE) + val url = BTTV_EMOTE_TEMPLATE.format(Locale.ROOT, id, BTTV_EMOTE_SIZE) + val lowResUrl = BTTV_EMOTE_TEMPLATE.format(Locale.ROOT, id, BTTV_LOW_RES_EMOTE_SIZE) return GenericEmote( code = name, url = url, @@ -609,8 +926,8 @@ class EmoteRepository( private fun parseBTTVGlobalEmote(emote: BTTVGlobalEmoteDto): GenericEmote { val name = emote.code val id = emote.id - val url = BTTV_EMOTE_TEMPLATE.format(id, BTTV_EMOTE_SIZE) - val lowResUrl = BTTV_EMOTE_TEMPLATE.format(id, BTTV_LOW_RES_EMOTE_SIZE) + val url = BTTV_EMOTE_TEMPLATE.format(Locale.ROOT, id, BTTV_EMOTE_SIZE) + val lowResUrl = BTTV_EMOTE_TEMPLATE.format(Locale.ROOT, id, BTTV_LOW_RES_EMOTE_SIZE) return GenericEmote( code = name, url = url, @@ -622,40 +939,47 @@ class EmoteRepository( ) } - private fun parseFFZEmote(emote: FFZEmoteDto, channel: UserName?): GenericEmote? { + private fun parseFFZEmote( + emote: FFZEmoteDto, + channel: UserName?, + ): GenericEmote? { val name = emote.name val id = emote.id - val urlMap = emote.animated - ?.mapValues { (_, url) -> url.takeIf { SUPPORTS_WEBP } ?: "$url.gif" } - ?: emote.urls - - val (scale, url) = when { - urlMap["4"] != null -> 1 to urlMap.getValue("4") - urlMap["2"] != null -> 2 to urlMap.getValue("2") - else -> 4 to urlMap["1"] - } + val urlMap = emote.animated ?: emote.urls + + val (scale, url) = + when { + urlMap["4"] != null -> 1 to urlMap.getValue("4") + urlMap["2"] != null -> 2 to urlMap.getValue("2") + else -> 4 to urlMap["1"] + } url ?: return null val lowResUrl = urlMap["2"] ?: urlMap["1"] ?: return null - val type = when (channel) { - null -> EmoteType.GlobalFFZEmote(emote.owner?.displayName) - else -> EmoteType.ChannelFFZEmote(emote.owner?.displayName) - } + val type = + when (channel) { + null -> EmoteType.GlobalFFZEmote(emote.owner?.displayName) + else -> EmoteType.ChannelFFZEmote(emote.owner?.displayName) + } return GenericEmote(name, url.withLeadingHttps, lowResUrl.withLeadingHttps, "$id", scale, type) } - private fun parseSevenTVEmote(emote: SevenTVEmoteDto, type: EmoteType): GenericEmote? { + private fun parseSevenTVEmote( + emote: SevenTVEmoteDto, + type: EmoteType, + ): GenericEmote? { val data = emote.data ?: return null if (data.isTwitchDisallowed) { return null } val base = "${data.host.url}/".withLeadingHttps - val urls = data.host.files - .filter { it.format == "WEBP" } - .associate { - val size = it.name.substringBeforeLast('.') - size to it.emoteUrlWithFallback(base, size, data.animated) - } + val urls = + data.host.files + .filter { it.format == "WEBP" } + .associate { + val size = it.name.substringBeforeLast('.') + size to it.emoteUrlWithFallback(base) + } return GenericEmote( code = emote.name, @@ -668,30 +992,26 @@ class EmoteRepository( ) } - private fun SevenTVEmoteFileDto.emoteUrlWithFallback(base: String, size: String, animated: Boolean): String { - return when { - animated && !SUPPORTS_WEBP -> "$base$size.gif" - else -> "$base$name" - } - } + private fun SevenTVEmoteFileDto.emoteUrlWithFallback(base: String): String = "$base$name" private suspend fun List.filterUnlistedIfEnabled(): List = when { chatSettingsDataStore.settings.first().allowUnlistedSevenTvEmotes -> this - else -> filter { it.data?.listed == true } + else -> filter { it.data?.listed == true } } private val String.withLeadingHttps: String - get() = when { - startsWith(prefix = "https:") -> this - else -> "https:$this" - } + get() = + when { + startsWith(prefix = "https:") -> this + else -> "https:$this" + } companion object { - private val SUPPORTS_WEBP = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - fun Badge.cacheKey(baseHeight: Int): String = "$url-$baseHeight" - fun List.cacheKey(baseHeight: Int): String = joinToString(separator = "-") { it.id } + "-$baseHeight" + private val ESCAPE_TAG = 0x000E0002.codePointAsString + val ESCAPE_TAG_REGEX = "(?(", - "\\:-?(o|O)" to ":O", - "\\:-?[\\\\/]" to ":/", - "\\:-?\\(" to ":(", - "\\:-?D" to ":D", - "\\;-?\\)" to ";)", - "B-?\\)" to "B)", - "#-?[\\/]" to "#/", - ":-?(?:7|L)" to ":7", - "\\<\\;\\]" to "<]", - "\\:-?(S|s)" to ":s", - "\\:\\>\\;" to ":>" - ) - private val OVERLAY_EMOTES = listOf( - "SoSnowy", "IceCold", "SantaHat", "TopHat", - "ReinDeer", "CandyCane", "cvMask", "cvHazmat", - ) + private val EMOTE_REPLACEMENTS = + mapOf( + "[oO](_|\\.)[oO]" to "O_o", + "\\<\\;3" to "<3", + "\\:-?(p|P)" to ":P", + "\\:-?[z|Z|\\|]" to ":Z", + "\\:-?\\)" to ":)", + "\\;-?(p|P)" to ";P", + "R-?\\)" to "R)", + "\\>\\;\\(" to ">(", + "\\:-?(o|O)" to ":O", + "\\:-?[\\\\/]" to ":/", + "\\:-?\\(" to ":(", + "\\:-?D" to ":D", + "\\;-?\\)" to ";)", + "B-?\\)" to "B)", + "#-?[\\/]" to "#/", + ":-?(?:7|L)" to ":7", + "\\<\\;\\]" to "<]", + "\\:-?(S|s)" to ":s", + "\\:\\>\\;" to ":>", + ) + private val OVERLAY_EMOTES = + listOf( + "SoSnowy", + "IceCold", + "SantaHat", + "TopHat", + "ReinDeer", + "CandyCane", + "cvMask", + "cvHazmat", + ) } } - -private operator fun IntRange.inc() = first + 1..last + 1 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteUsageRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteUsageRepository.kt index 5ee22b129..151426a85 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteUsageRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteUsageRepository.kt @@ -5,6 +5,10 @@ import com.flxrs.dankchat.data.database.entity.EmoteUsageEntity import com.flxrs.dankchat.di.DispatchersProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.annotation.Single import java.time.Instant @@ -14,9 +18,14 @@ class EmoteUsageRepository( private val emoteUsageDao: EmoteUsageDao, dispatchersProvider: DispatchersProvider, ) { - private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + val recentEmoteIds: StateFlow> = + emoteUsageDao + .getRecentUsages() + .map { usages -> usages.mapTo(mutableSetOf()) { it.emoteId } } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + init { scope.launch { runCatching { @@ -26,13 +35,15 @@ class EmoteUsageRepository( } suspend fun addEmoteUsage(emoteId: String) { - val entity = EmoteUsageEntity( - emoteId = emoteId, - lastUsed = Instant.now() - ) + val entity = + EmoteUsageEntity( + emoteId = emoteId, + lastUsed = Instant.now(), + ) emoteUsageDao.addUsage(entity) } suspend fun clearUsages() = emoteUsageDao.clearUsages() + fun getRecentUsages() = emoteUsageDao.getRecentUsages() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/Emotes.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/Emotes.kt index 965cce523..5ee4b40b8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/Emotes.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/emote/Emotes.kt @@ -1,7 +1,41 @@ package com.flxrs.dankchat.data.repo.emote +import com.flxrs.dankchat.data.twitch.emote.CheermoteSet import com.flxrs.dankchat.data.twitch.emote.GenericEmote +data class GlobalEmoteState( + val twitchEmotes: List = emptyList(), + val ffzEmotes: List = emptyList(), + val bttvEmotes: List = emptyList(), + val sevenTvEmotes: List = emptyList(), +) + +data class ChannelEmoteState( + val twitchEmotes: List = emptyList(), + val ffzEmotes: List = emptyList(), + val bttvEmotes: List = emptyList(), + val sevenTvEmotes: List = emptyList(), + val cheermoteSets: List = emptyList(), +) + +fun mergeEmotes( + global: GlobalEmoteState, + channel: ChannelEmoteState, +): Emotes { + // Deduplicate twitch emotes by ID — channel (follower) emotes take precedence + val channelEmoteIds = channel.twitchEmotes.mapTo(mutableSetOf()) { it.id } + val deduplicatedGlobalTwitchEmotes = global.twitchEmotes.filterNot { it.id in channelEmoteIds } + return Emotes( + twitchEmotes = deduplicatedGlobalTwitchEmotes + channel.twitchEmotes, + ffzChannelEmotes = channel.ffzEmotes, + ffzGlobalEmotes = global.ffzEmotes, + bttvChannelEmotes = channel.bttvEmotes, + bttvGlobalEmotes = global.bttvEmotes, + sevenTvChannelEmotes = channel.sevenTvEmotes, + sevenTvGlobalEmotes = global.sevenTvEmotes, + ) +} + data class Emotes( val twitchEmotes: List = emptyList(), val ffzChannelEmotes: List = emptyList(), @@ -11,18 +45,18 @@ data class Emotes( val sevenTvChannelEmotes: List = emptyList(), val sevenTvGlobalEmotes: List = emptyList(), ) { + val sorted: List = + buildList { + addAll(twitchEmotes) - val sorted: List = buildList { - addAll(twitchEmotes) - - addAll(ffzChannelEmotes) - addAll(bttvChannelEmotes) - addAll(sevenTvChannelEmotes) + addAll(ffzChannelEmotes) + addAll(bttvChannelEmotes) + addAll(sevenTvChannelEmotes) - addAll(ffzGlobalEmotes) - addAll(bttvGlobalEmotes) - addAll(sevenTvGlobalEmotes) - }.sortedBy(GenericEmote::code) + addAll(ffzGlobalEmotes) + addAll(bttvGlobalEmotes) + addAll(sevenTvGlobalEmotes) + }.sortedBy(GenericEmote::code) val suggestions: List = sorted.distinctBy(GenericEmote::code) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogLine.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogLine.kt new file mode 100644 index 000000000..5380a4987 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogLine.kt @@ -0,0 +1,66 @@ +package com.flxrs.dankchat.data.repo.log + +import androidx.compose.runtime.Immutable + +enum class LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR, + ; + + companion object { + fun fromString(value: String): LogLevel = when (value.trim().uppercase()) { + "TRACE" -> TRACE + "DEBUG" -> DEBUG + "INFO" -> INFO + "WARN" -> WARN + "ERROR" -> ERROR + else -> DEBUG + } + } +} + +@Immutable +data class LogLine( + val raw: String, + val timestamp: String, + val level: LogLevel, + val thread: String, + val logger: String, + val message: String, +) + +object LogLineParser { + // Matches: 2026-04-04 20:15:30.123 [main] DEBUG c.f.dankchat.SomeClass - Some message + private val LOG_LINE_REGEX = Regex( + """^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \[(.+?)] (\w+)\s+(\S+) - (.*)$""", + ) + + fun parseAll(lines: List): List { + val result = mutableListOf() + for (line in lines) { + val match = LOG_LINE_REGEX.matchEntire(line) + if (match != null) { + val (timestamp, thread, level, logger, message) = match.destructured + result += LogLine( + raw = line, + timestamp = timestamp, + level = LogLevel.fromString(level), + thread = thread, + logger = logger, + message = message, + ) + } else if (result.isNotEmpty()) { + // Continuation line (e.g. stacktrace) — append to previous entry + val previous = result.last() + result[result.lastIndex] = previous.copy( + raw = previous.raw + "\n" + line, + message = previous.message + "\n" + line, + ) + } + } + return result + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt new file mode 100644 index 000000000..2cc2cd415 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/log/LogRepository.kt @@ -0,0 +1,40 @@ +package com.flxrs.dankchat.data.repo.log + +import android.content.Context +import org.koin.core.annotation.Single +import java.io.File + +@Single +class LogRepository( + context: Context, +) { + private val logDir = File(context.filesDir, "logs") + + fun getLogFiles(): List { + if (!logDir.exists()) return emptyList() + return logDir + .listFiles() + ?.filter { it.isFile && it.name.endsWith(".log") } + ?.sortedByDescending { it.lastModified() } + .orEmpty() + } + + fun readLogFile(fileName: String): List { + val file = File(logDir, fileName) + if (!file.exists()) return emptyList() + return file.readLines() + } + + fun getLogFile(fileName: String): File? { + val file = File(logDir, fileName) + return file.takeIf { it.exists() } + } + + fun getLatestLogFile(): File? = getLogFiles().firstOrNull() + + fun deleteAllLogs() { + if (logDir.exists()) { + logDir.listFiles()?.forEach { it.delete() } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt new file mode 100644 index 000000000..4d71096dc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamData.kt @@ -0,0 +1,11 @@ +package com.flxrs.dankchat.data.repo.stream + +import com.flxrs.dankchat.data.UserName + +data class StreamData( + val channel: UserName, + val formattedData: String, + val viewerCount: Int = 0, + val startedAt: String = "", + val category: String? = null, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt new file mode 100644 index 000000000..45a9f4a48 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/repo/stream/StreamDataRepository.kt @@ -0,0 +1,94 @@ +package com.flxrs.dankchat.data.repo.stream + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore +import com.flxrs.dankchat.utils.DateTimeUtils +import com.flxrs.dankchat.utils.extensions.timer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.plusAssign +import kotlin.time.Duration.Companion.seconds + +@Single +class StreamDataRepository( + private val dataRepository: DataRepository, + private val authDataStore: AuthDataStore, + private val dankChatPreferenceStore: DankChatPreferenceStore, + private val streamsSettingsDataStore: StreamsSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + private var fetchTimerJob: Job? = null + private val _streamData = MutableStateFlow>(persistentListOf()) + val streamData: StateFlow> = _streamData.asStateFlow() + private val _fetchCount = AtomicInt(0) + val fetchCount: Int get() = _fetchCount.load() + + fun fetchStreamData(channels: List) { + cancelStreamData() + channels.ifEmpty { return } + + scope.launch { + val settings = streamsSettingsDataStore.settings.first() + if (!authDataStore.isLoggedIn || !settings.fetchStreams) { + return@launch + } + + fetchTimerJob = + timer(STREAM_REFRESH_RATE) { + fetchOnce(channels) + } + } + } + + suspend fun fetchOnce(channels: List) { + val currentSettings = streamsSettingsDataStore.settings.first() + _fetchCount += 1 + val data = + dataRepository + .getStreams(channels) + ?.map { + val uptime = DateTimeUtils.calculateUptime(it.startedAt) + val category = + it.category + ?.takeIf { currentSettings.showStreamCategory } + ?.ifBlank { null } + val formatted = dankChatPreferenceStore.formatViewersString(it.viewerCount, uptime, category) + + StreamData( + channel = it.userLogin, + formattedData = formatted, + viewerCount = it.viewerCount, + startedAt = it.startedAt, + category = it.category, + ) + }.orEmpty() + + _streamData.value = data.toImmutableList() + } + + fun cancelStreamData() { + fetchTimerJob?.cancel() + fetchTimerJob = null + _streamData.value = persistentListOf() + } + + companion object { + private val STREAM_REFRESH_RATE = 30.seconds + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt new file mode 100644 index 000000000..530d64899 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ChannelLoadingState.kt @@ -0,0 +1,67 @@ +package com.flxrs.dankchat.data.state + +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure +import com.flxrs.dankchat.data.repo.data.DataLoadingFailure + +sealed interface ChannelLoadingState { + data object Idle : ChannelLoadingState + + data object Loading : ChannelLoadingState + + data object Loaded : ChannelLoadingState + + data class Failed( + val failures: List, + ) : ChannelLoadingState +} + +sealed interface ChannelLoadingFailure { + val channel: UserName + val error: Throwable + + data class Badges( + override val channel: UserName, + val channelId: UserId, + override val error: Throwable, + ) : ChannelLoadingFailure + + data class BTTVEmotes( + override val channel: UserName, + override val error: Throwable, + ) : ChannelLoadingFailure + + data class FFZEmotes( + override val channel: UserName, + override val error: Throwable, + ) : ChannelLoadingFailure + + data class SevenTVEmotes( + override val channel: UserName, + override val error: Throwable, + ) : ChannelLoadingFailure + + data class Cheermotes( + override val channel: UserName, + override val error: Throwable, + ) : ChannelLoadingFailure + + data class RecentMessages( + override val channel: UserName, + override val error: Throwable, + ) : ChannelLoadingFailure +} + +sealed interface GlobalLoadingState { + data object Idle : GlobalLoadingState + + data object Loading : GlobalLoadingState + + data object Loaded : GlobalLoadingState + + data class Failed( + val failures: Set = emptySet(), + val chatFailures: Set = emptySet(), + ) : GlobalLoadingState +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/state/DataLoadingState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/state/DataLoadingState.kt index 4f1930a91..897f56171 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/DataLoadingState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/DataLoadingState.kt @@ -5,13 +5,17 @@ import com.flxrs.dankchat.data.repo.data.DataLoadingFailure sealed interface DataLoadingState { data object None : DataLoadingState + data object Finished : DataLoadingState + data object Reloaded : DataLoadingState + data object Loading : DataLoadingState + data class Failed( val errorMessage: String, val errorCount: Int, val dataFailures: Set, - val chatFailures: Set + val chatFailures: Set, ) : DataLoadingState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ImageUploadState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ImageUploadState.kt index 7e3507ffa..1840f236e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/state/ImageUploadState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/state/ImageUploadState.kt @@ -3,9 +3,17 @@ package com.flxrs.dankchat.data.state import java.io.File sealed interface ImageUploadState { - data object None : ImageUploadState + data object Loading : ImageUploadState - data class Finished(val url: String) : ImageUploadState - data class Failed(val errorMessage: String?, val mediaFile: File, val imageCapture: Boolean) : ImageUploadState + + data class Finished( + val url: String, + ) : ImageUploadState + + data class Failed( + val errorMessage: String?, + val mediaFile: File, + val imageCapture: Boolean, + ) : ImageUploadState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/Badge.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/Badge.kt index d8aac1225..49887e5a4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/Badge.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/Badge.kt @@ -11,11 +11,46 @@ sealed class Badge : Parcelable { abstract val badgeInfo: String? abstract val title: String? - data class ChannelBadge(override val title: String?, override val badgeTag: String?, override val badgeInfo: String?, override val url: String, override val type: BadgeType) : Badge() - data class GlobalBadge(override val title: String?, override val badgeTag: String?, override val badgeInfo: String?, override val url: String, override val type: BadgeType) : Badge() - data class FFZModBadge(override val title: String?, override val badgeTag: String?, override val badgeInfo: String?, override val url: String, override val type: BadgeType) : Badge() - data class FFZVipBadge(override val title: String?, override val badgeTag: String?, override val badgeInfo: String?, override val url: String, override val type: BadgeType) : Badge() - data class DankChatBadge(override val title: String?, override val badgeTag: String?, override val badgeInfo: String?, override val url: String, override val type: BadgeType) : Badge() + data class ChannelBadge( + override val title: String?, + override val badgeTag: String?, + override val badgeInfo: String?, + override val url: String, + override val type: BadgeType, + ) : Badge() + + data class GlobalBadge( + override val title: String?, + override val badgeTag: String?, + override val badgeInfo: String?, + override val url: String, + override val type: BadgeType, + ) : Badge() + + data class FFZModBadge( + override val title: String?, + override val badgeTag: String?, + override val badgeInfo: String?, + override val url: String, + override val type: BadgeType, + ) : Badge() + + data class FFZVipBadge( + override val title: String?, + override val badgeTag: String?, + override val badgeInfo: String?, + override val url: String, + override val type: BadgeType, + ) : Badge() + + data class DankChatBadge( + override val title: String?, + override val badgeTag: String?, + override val badgeInfo: String?, + override val url: String, + override val type: BadgeType, + ) : Badge() + data class SharedChatBadge( override val url: String, override val title: String?, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeSet.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeSet.kt index 682d6c542..4b921c3d8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeSet.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeSet.kt @@ -5,7 +5,7 @@ import com.flxrs.dankchat.data.api.helix.dto.BadgeSetDto data class BadgeSet( val id: String, - val versions: Map + val versions: Map, ) data class BadgeVersion( @@ -13,35 +13,39 @@ data class BadgeVersion( val title: String, val imageUrlLow: String, val imageUrlMedium: String, - val imageUrlHigh: String + val imageUrlHigh: String, ) fun TwitchBadgeSetsDto.toBadgeSets(): Map = sets.mapValues { (id, set) -> BadgeSet( id = id, - versions = set.versions.mapValues { (badgeId, badge) -> - BadgeVersion( - id = badgeId, - title = badge.title, - imageUrlLow = badge.imageUrlLow, - imageUrlMedium = badge.imageUrlMedium, - imageUrlHigh = badge.imageUrlHigh - ) - } + versions = + set.versions.mapValues { (badgeId, badge) -> + BadgeVersion( + id = badgeId, + title = badge.title, + imageUrlLow = badge.imageUrlLow, + imageUrlMedium = badge.imageUrlMedium, + imageUrlHigh = badge.imageUrlHigh, + ) + }, ) } fun List.toBadgeSets(): Map = associate { (id, versions) -> - id to BadgeSet( - id = id, - versions = versions.associate { badge -> - badge.id to BadgeVersion( - id = badge.id, - title = badge.title, - imageUrlLow = badge.imageUrlLow, - imageUrlMedium = badge.imageUrlMedium, - imageUrlHigh = badge.imageUrlHigh - ) - } - ) + id to + BadgeSet( + id = id, + versions = + versions.associate { badge -> + badge.id to + BadgeVersion( + id = badge.id, + title = badge.title, + imageUrlLow = badge.imageUrlLow, + imageUrlMedium = badge.imageUrlMedium, + imageUrlHigh = badge.imageUrlHigh, + ) + }, + ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeType.kt index 078e534e2..6203069d2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/badge/BadgeType.kt @@ -7,16 +7,18 @@ enum class BadgeType { Subscriber, Vanity, DankChat, - SharedChat; - //FrankerFaceZ; + SharedChat, + ; + + // FrankerFaceZ; companion object { fun parseFromBadgeId(id: String): BadgeType = when (id) { - "staff", "admin", "global_admin" -> Authority - "predictions" -> Predictions + "staff", "admin", "global_admin" -> Authority + "predictions" -> Predictions "lead_moderator", "moderator", "vip", "broadcaster" -> Channel - "subscriber", "founder" -> Subscriber - else -> Vanity + "subscriber", "founder" -> Subscriber + else -> Vanity } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/chat/ChatConnection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/chat/ChatConnection.kt index 76ec8662f..0c5f761af 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/chat/ChatConnection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/chat/ChatConnection.kt @@ -1,80 +1,97 @@ package com.flxrs.dankchat.data.twitch.chat -import android.util.Log -import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.irc.IrcMessage import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.utils.extensions.timer -import io.ktor.http.HttpHeaders +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocket import io.ktor.util.collections.ConcurrentSet +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener import kotlin.random.Random import kotlin.random.nextLong -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.times +private val logger = KotlinLogging.logger("ChatConnection") + enum class ChatConnectionType { Read, - Write + Write, } sealed interface ChatEvent { - data class Message(val message: IrcMessage) : ChatEvent - data class Connected(val channel: UserName, val isAnonymous: Boolean) : ChatEvent - data class ChannelNonExistent(val channel: UserName) : ChatEvent - data class Error(val throwable: Throwable) : ChatEvent + data class Message( + val message: IrcMessage, + ) : ChatEvent + + data class Connected( + val channel: UserName, + val isAnonymous: Boolean, + ) : ChatEvent + + data class ChannelNonExistent( + val channel: UserName, + ) : ChatEvent + + data class Error( + val throwable: Throwable, + ) : ChatEvent + data object LoginFailed : ChatEvent + data object Closed : ChatEvent val isDisconnected: Boolean get() = this is Error || this is Closed } +@OptIn(DelicateCoroutinesApi::class) class ChatConnection( private val chatConnectionType: ChatConnectionType, - private val client: OkHttpClient, - private val preferences: DankChatPreferenceStore, + httpClient: HttpClient, + private val authDataStore: AuthDataStore, dispatchersProvider: DispatchersProvider, + private val url: String = IRC_URL, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) - private var socket: WebSocket? = null - private val request = Request.Builder() - .url("wss://irc-ws.chat.twitch.tv") - .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") - .build() + private val client = + httpClient.config { + install(WebSockets) + } + + @Volatile + private var session: DefaultClientWebSocketSession? = null + private var connectionJob: Job? = null private val receiveChannel = Channel(capacity = Channel.BUFFERED) - private var connecting = false private var awaitingPong = false - private var reconnectAttempts = 1 - private val currentReconnectDelay: Duration - get() { - val jitter = randomJitter() - val reconnectDelay = RECONNECT_BASE_DELAY * (1 shl (reconnectAttempts - 1)) - reconnectAttempts = (reconnectAttempts + 1).coerceAtMost(RECONNECT_MAX_ATTEMPTS) - - return reconnectDelay + jitter - } private val channels = mutableSetOf() private val channelsAttemptedToJoin = ConcurrentSet() @@ -84,22 +101,25 @@ class ChatConnection( private val isAnonymous: Boolean get() = (currentUserName?.value.isNullOrBlank() || currentOAuth.isNullOrBlank() || currentOAuth?.startsWith("oauth:") == false) - var connected = false - private set + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() - val messages = receiveChannel.receiveAsFlow().distinctUntilChanged { old, new -> - (old.isDisconnected && new.isDisconnected) || old == new - } + val messages = + receiveChannel.receiveAsFlow().distinctUntilChanged { old, new -> + (old.isDisconnected && new.isDisconnected) || old == new + } init { scope.launch { channelsToJoin.consumeAsFlow().collect { channelsToJoin -> - if (!connected) return@collect + if (!_connected.value) return@collect + val currentSession = session ?: return@collect - channelsToJoin.filter { it in channels } + channelsToJoin + .filter { it in channels } .chunked(JOIN_CHUNK_SIZE) .forEach { chunk -> - socket?.joinChannels(chunk) + currentSession.joinChannels(chunk) channelsAttemptedToJoin.addAll(chunk) setupJoinCheckInterval(chunk) delay(duration = chunk.size * JOIN_DELAY) @@ -108,17 +128,17 @@ class ChatConnection( } } - fun sendMessage(msg: String) { - if (connected) { - socket?.sendMessage(msg) - } + suspend fun sendMessage(msg: String) { + val currentSession = session ?: return + if (!_connected.value) return + currentSession.sendIrc(msg) } fun joinChannels(channelList: List) { val newChannels = channelList - channels channels.addAll(newChannels) - if (connected) { + if (_connected.value) { scope.launch { channelsToJoin.send(newChannels) } @@ -129,80 +149,204 @@ class ChatConnection( if (channel in channels) return channels += channel - if (connected) { + if (_connected.value) { scope.launch { channelsToJoin.send(listOf(channel)) } } } - fun partChannel(channel: UserName) { + suspend fun partChannel(channel: UserName) { if (channel !in channels) return channels.remove(channel) - if (connected) { - socket?.sendMessage("PART #$channel") + if (_connected.value) { + val currentSession = session ?: return + currentSession.sendIrc("PART #$channel") } } fun connect() { - if (connected || connecting) return + if (session?.isActive == true) return + connectionJob?.cancel() - currentUserName = preferences.userName - currentOAuth = preferences.oAuthKey + currentUserName = authDataStore.userName + currentOAuth = authDataStore.oAuthKey awaitingPong = false - connecting = true - socket = client.newWebSocket(request, TwitchWebSocketListener()) + + connectionJob = + scope.launch { + var retryCount = 1 + while (retryCount <= RECONNECT_MAX_ATTEMPTS) { + var serverRequestedReconnect = false + try { + client.webSocket(url) { + session = this + _connected.value = true + retryCount = 1 + + val auth = currentOAuth?.takeIf { !isAnonymous } ?: "NaM" + val nick = currentUserName?.takeIf { !isAnonymous } ?: "justinfan12781923" + sendIrc("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership") + sendIrc("PASS $auth") + sendIrc("NICK $nick") + + var pingJob: Job? = null + try { + while (isActive) { + val result = incoming.receiveCatching() + val text = + when (val frame = result.getOrNull()) { + null -> { + val cause = result.exceptionOrNull() ?: return@webSocket + throw cause + } + + else -> { + (frame as? Frame.Text)?.readText() ?: continue + } + } + + text.removeSuffix("\r\n").split("\r\n").forEach { line -> + val ircMessage = IrcMessage.parse(line) + if (ircMessage.isLoginFailed()) { + logger.error { "[$chatConnectionType] authentication failed with expired token, closing connection.." } + receiveChannel.send(ChatEvent.LoginFailed) + return@webSocket + } + + when (ircMessage.command) { + "376" -> { + logger.info { "[$chatConnectionType] connected to irc" } + pingJob = setupPingInterval() + channelsToJoin.send(channels) + } + + "JOIN" -> { + val channel = + ircMessage.params + .getOrNull(0) + ?.substring(1) + ?.toUserName() ?: return@forEach + if (channelsAttemptedToJoin.remove(channel)) { + logger.info { "[$chatConnectionType] Joined #$channel" } + } + } + + "366" -> { + receiveChannel.send(ChatEvent.Connected(ircMessage.params[1].substring(1).toUserName(), isAnonymous)) + } + + "PING" -> { + sendIrc("PONG :tmi.twitch.tv") + } + + "PONG" -> { + awaitingPong = false + } + + "RECONNECT" -> { + logger.info { "[$chatConnectionType] server requested reconnect" } + serverRequestedReconnect = true + return@webSocket + } + + else -> { + if (ircMessage.command == "NOTICE" && ircMessage.tags["msg-id"] == "msg_channel_suspended") { + channelsAttemptedToJoin.remove(ircMessage.params[0].substring(1).toUserName()) + } + receiveChannel.send(ChatEvent.Message(ircMessage)) + } + } + } + } + } finally { + pingJob?.cancel() + } + } + + _connected.value = false + session = null + channelsAttemptedToJoin.clear() + receiveChannel.send(ChatEvent.Closed) + + if (!serverRequestedReconnect) { + logger.info { "[$chatConnectionType] connection closed" } + return@launch + } + logger.info { "[$chatConnectionType] reconnecting after server request" } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error { "[$chatConnectionType] connection failed: $t" } + logger.error { "[$chatConnectionType] attempting to reconnect #$retryCount.." } + _connected.value = false + session = null + channelsAttemptedToJoin.clear() + receiveChannel.send(ChatEvent.Closed) + + val jitter = randomJitter() + val reconnectDelay = RECONNECT_BASE_DELAY * (1 shl (retryCount - 1)) + delay(reconnectDelay + jitter) + retryCount = (retryCount + 1).coerceAtMost(RECONNECT_MAX_ATTEMPTS) + } + } + + logger.error { "[$chatConnectionType] connection failed after $RECONNECT_MAX_ATTEMPTS retries" } + _connected.value = false + session = null + } } fun close() { - connected = false - socket?.close(1000, null) ?: socket?.cancel() + _connected.value = false + val currentSession = session + session = null + channelsAttemptedToJoin.clear() + receiveChannel.trySend(ChatEvent.Closed) + connectionJob?.cancel() + scope.launch { + runCatching { + currentSession?.close() + currentSession?.cancel() + } + } } fun reconnect() { - reconnectAttempts = 1 - attemptReconnect() + close() + connect() } fun reconnectIfNecessary() { - if (connected || connecting) return + if (session?.isActive == true && session?.incoming?.isClosedForReceive == false) return reconnect() } - private fun attemptReconnect() { - scope.launch { - delay(currentReconnectDelay) - close() - connect() - } - } - private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { - val webSocket = socket - if (awaitingPong || webSocket == null) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { cancel() reconnect() return@timer } - if (connected) { + if (_connected.value) { awaitingPong = true - webSocket.sendMessage("PING") + runCatching { currentSession.send(Frame.Text("PING\r\n")) } } } private fun setupJoinCheckInterval(channelsToCheck: List) = scope.launch { - Log.d(TAG, "[$chatConnectionType] setting up join check for $channelsToCheck") - // only send a ChannelNonExistent event if we are actually connected or there are attempted joins - if (socket == null || !connected || channelsAttemptedToJoin.isEmpty()) { + logger.debug { "[$chatConnectionType] setting up join check for $channelsToCheck" } + if (session?.isActive != true || !_connected.value || channelsAttemptedToJoin.isEmpty()) { return@launch } delay(JOIN_CHECK_DELAY) - if (socket == null || !connected) { + if (session?.isActive != true || !_connected.value) { channelsAttemptedToJoin.removeAll(channelsToCheck.toSet()) return@launch } @@ -215,97 +359,18 @@ class ChatConnection( } } - private inner class TwitchWebSocketListener : WebSocketListener() { - private var pingJob: Job? = null - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - connected = false - pingJob?.cancel() - channelsAttemptedToJoin.clear() - scope.launch { receiveChannel.send(ChatEvent.Closed) } - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "[$chatConnectionType] connection failed: $t") - Log.e(TAG, "[$chatConnectionType] attempting to reconnect #${reconnectAttempts}..") - connected = false - connecting = false - pingJob?.cancel() - channelsAttemptedToJoin.clear() - scope.launch { receiveChannel.send(ChatEvent.Closed) } - - attemptReconnect() - } - - override fun onOpen(webSocket: WebSocket, response: Response) { - connected = true - connecting = false - reconnectAttempts = 1 - - val auth = currentOAuth?.takeIf { !isAnonymous } ?: "NaM" - val nick = currentUserName?.takeIf { !isAnonymous } ?: "justinfan12781923" - - webSocket.sendMessage("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership") - webSocket.sendMessage("PASS $auth") - webSocket.sendMessage("NICK $nick") - } - - override fun onMessage(webSocket: WebSocket, text: String) { - text.removeSuffix("\r\n").split("\r\n").forEach { line -> - val ircMessage = IrcMessage.parse(line) - if (ircMessage.isLoginFailed()) { - Log.e(TAG, "[$chatConnectionType] authentication failed with expired token, closing connection..") - scope.launch { receiveChannel.send(ChatEvent.LoginFailed) } - close() - } - when (ircMessage.command) { - "376" -> { - Log.i(TAG, "[$chatConnectionType] connected to irc") - pingJob = setupPingInterval() - - scope.launch { - channelsToJoin.send(channels) - } - } - - "JOIN" -> { - val channel = ircMessage.params.getOrNull(0)?.substring(1)?.toUserName() ?: return - if (channelsAttemptedToJoin.remove(channel)) { - Log.i(TAG, "[$chatConnectionType] Joined #$channel") - } - } - - "366" -> scope.launch { receiveChannel.send(ChatEvent.Connected(ircMessage.params[1].substring(1).toUserName(), isAnonymous)) } - "PING" -> webSocket.handlePing() - "PONG" -> awaitingPong = false - "RECONNECT" -> reconnect() - else -> { - if (ircMessage.command == "NOTICE" && ircMessage.tags["msg-id"] == "msg_channel_suspended") { - channelsAttemptedToJoin.remove(ircMessage.params[0].substring(1).toUserName()) - } - - scope.launch { receiveChannel.send(ChatEvent.Message(ircMessage)) } - } - } - } - } - } - - private fun WebSocket.sendMessage(msg: String) { - send("${msg.trimEnd()}\r\n") - } - - private fun WebSocket.handlePing() { - sendMessage("PONG :tmi.twitch.tv") + private suspend fun DefaultClientWebSocketSession.sendIrc(msg: String) { + send(Frame.Text("${msg.trimEnd()}\r\n")) } - private fun WebSocket.joinChannels(channels: Collection) { + private suspend fun DefaultClientWebSocketSession.joinChannels(channels: Collection) { if (channels.isNotEmpty()) { - sendMessage("JOIN ${channels.joinToString(separator = ",") { "#$it" }}") + sendIrc("JOIN ${channels.joinToString(separator = ",") { "#$it" }}") } } companion object { + private const val IRC_URL = "wss://irc-ws.chat.twitch.tv" private const val MAX_JITTER = 250L private val RECONNECT_BASE_DELAY = 1.seconds private const val RECONNECT_MAX_ATTEMPTS = 4 @@ -313,6 +378,5 @@ class ChatConnection( private val JOIN_CHECK_DELAY = 10.seconds private val JOIN_DELAY = 600.milliseconds private const val JOIN_CHUNK_SIZE = 5 - private val TAG = ChatConnection::class.java.simpleName } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/CommandContext.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/CommandContext.kt index 39d47e5d0..1fbea3b09 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/CommandContext.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/CommandContext.kt @@ -10,5 +10,5 @@ data class CommandContext( val channelId: UserId, val roomState: RoomState, val originalMessage: String, - val args: List + val args: List, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommand.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommand.kt index 6c20d7529..57915ac1c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommand.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommand.kt @@ -1,7 +1,9 @@ package com.flxrs.dankchat.data.twitch.command @Suppress("SpellCheckingInspection") -enum class TwitchCommand(val trigger: String) { +enum class TwitchCommand( + val trigger: String, +) { Announce(trigger = "announce"), AnnounceBlue(trigger = "announceblue"), AnnounceGreen(trigger = "announcegreen"), @@ -39,7 +41,8 @@ enum class TwitchCommand(val trigger: String) { Unvip(trigger = "unvip"), Vip(trigger = "vip"), Vips(trigger = "vips"), - Whisper(trigger = "w"); + Whisper(trigger = "w"), + ; companion object { val ALL_COMMANDS = TwitchCommand.entries diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt index 3583d4d61..19864f5ef 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/command/TwitchCommandRepository.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.data.twitch.command -import android.util.Log +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.api.helix.HelixApiClient @@ -15,29 +15,38 @@ import com.flxrs.dankchat.data.api.helix.dto.CommercialRequestDto import com.flxrs.dankchat.data.api.helix.dto.MarkerRequestDto import com.flxrs.dankchat.data.api.helix.dto.ShieldModeRequestDto import com.flxrs.dankchat.data.api.helix.dto.WhisperRequestDto +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.repo.ShieldModeRepository import com.flxrs.dankchat.data.repo.chat.UserState import com.flxrs.dankchat.data.repo.command.CommandResult import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.message.RoomState -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.utils.DateTimeUtils +import com.flxrs.dankchat.utils.TextResource +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.collections.immutable.persistentListOf import org.koin.core.annotation.Single import java.util.UUID +private val logger = KotlinLogging.logger("TwitchCommandRepository") + @Single class TwitchCommandRepository( private val helixApiClient: HelixApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore, + private val authDataStore: AuthDataStore, + private val shieldModeRepository: ShieldModeRepository, ) { - fun isIrcCommand(trigger: String): Boolean = trigger in ALLOWED_IRC_COMMAND_TRIGGERS - fun getAvailableCommandTriggers(room: RoomState, userState: UserState): List { - val currentUserId = dankChatPreferenceStore.userIdString ?: return emptyList() + fun getAvailableCommandTriggers( + room: RoomState, + userState: UserState, + ): List { + val currentUserId = authDataStore.userIdString ?: return emptyList() return when { - room.channelId == currentUserId -> TwitchCommand.ALL_COMMANDS + room.channelId == currentUserId -> TwitchCommand.ALL_COMMANDS room.channel in userState.moderationChannels -> TwitchCommand.MODERATOR_COMMANDS - else -> TwitchCommand.USER_COMMANDS + else -> TwitchCommand.USER_COMMANDS }.map(TwitchCommand::trigger) .plus(ALLOWED_IRC_COMMANDS) .map { "/$it" } @@ -52,239 +61,311 @@ class TwitchCommandRepository( return TwitchCommand.ALL_COMMANDS.find { it.trigger == withoutFirstChar } } - suspend fun handleTwitchCommand(command: TwitchCommand, context: CommandContext): CommandResult { - val currentUserId = dankChatPreferenceStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( - command = command, - response = "You must be logged in to use the ${context.trigger} command" - ) + suspend fun handleTwitchCommand( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { + val currentUserId = + authDataStore.userIdString ?: return CommandResult.AcceptedTwitchCommand( + command = command, + response = TextResource.Res(R.string.cmd_error_not_logged_in, persistentListOf(context.trigger)), + ) return when (command) { TwitchCommand.Announce, TwitchCommand.AnnounceBlue, TwitchCommand.AnnounceGreen, TwitchCommand.AnnounceOrange, - TwitchCommand.AnnouncePurple -> sendAnnouncement(command, currentUserId, context) - - TwitchCommand.Ban -> banUser(command, currentUserId, context) - TwitchCommand.Clear -> clearChat(command, currentUserId, context) - TwitchCommand.Color -> updateColor(command, currentUserId, context) - TwitchCommand.Commercial -> startCommercial(command, context) - TwitchCommand.Delete -> deleteMessage(command, currentUserId, context) - TwitchCommand.EmoteOnly -> enableEmoteMode(command, currentUserId, context) - TwitchCommand.EmoteOnlyOff -> disableEmoteMode(command, currentUserId, context) - TwitchCommand.Followers -> enableFollowersMode(command, currentUserId, context) - TwitchCommand.FollowersOff -> disableFollowersMode(command, currentUserId, context) - TwitchCommand.Marker -> createMarker(command, context) - TwitchCommand.Mod -> addModerator(command, context) - TwitchCommand.Mods -> getModerators(command, context) - TwitchCommand.R9kBeta -> enableUniqueChatMode(command, currentUserId, context) - TwitchCommand.R9kBetaOff -> disableUniqueChatMode(command, currentUserId, context) - TwitchCommand.Raid -> startRaid(command, context) + TwitchCommand.AnnouncePurple, + -> sendAnnouncement(command, currentUserId, context) + + TwitchCommand.Ban -> banUser(command, currentUserId, context) + + TwitchCommand.Clear -> clearChat(command, currentUserId, context) + + TwitchCommand.Color -> updateColor(command, currentUserId, context) + + TwitchCommand.Commercial -> startCommercial(command, context) + + TwitchCommand.Delete -> deleteMessage(command, currentUserId, context) + + TwitchCommand.EmoteOnly -> enableEmoteMode(command, currentUserId, context) + + TwitchCommand.EmoteOnlyOff -> disableEmoteMode(command, currentUserId, context) + + TwitchCommand.Followers -> enableFollowersMode(command, currentUserId, context) + + TwitchCommand.FollowersOff -> disableFollowersMode(command, currentUserId, context) + + TwitchCommand.Marker -> createMarker(command, context) + + TwitchCommand.Mod -> addModerator(command, context) + + TwitchCommand.Mods -> getModerators(command, context) + + TwitchCommand.R9kBeta -> enableUniqueChatMode(command, currentUserId, context) + + TwitchCommand.R9kBetaOff -> disableUniqueChatMode(command, currentUserId, context) + + TwitchCommand.Raid -> startRaid(command, context) + TwitchCommand.Shield, - TwitchCommand.ShieldOff -> toggleShieldMode(command, currentUserId, context) + TwitchCommand.ShieldOff, + -> toggleShieldMode(command, currentUserId, context) + + TwitchCommand.Slow -> enableSlowMode(command, currentUserId, context) + + TwitchCommand.SlowOff -> disableSlowMode(command, currentUserId, context) + + TwitchCommand.Subscribers -> enableSubscriberMode(command, currentUserId, context) - TwitchCommand.Slow -> enableSlowMode(command, currentUserId, context) - TwitchCommand.SlowOff -> disableSlowMode(command, currentUserId, context) - TwitchCommand.Subscribers -> enableSubscriberMode(command, currentUserId, context) TwitchCommand.SubscribersOff -> disableSubscriberMode(command, currentUserId, context) - TwitchCommand.Timeout -> timeoutUser(command, currentUserId, context) - TwitchCommand.Unban -> unbanUser(command, currentUserId, context) - TwitchCommand.UniqueChat -> enableUniqueChatMode(command, currentUserId, context) - TwitchCommand.UniqueChatOff -> disableUniqueChatMode(command, currentUserId, context) - TwitchCommand.Unmod -> removeModerator(command, context) - TwitchCommand.Unraid -> cancelRaid(command, context) - TwitchCommand.Untimeout -> unbanUser(command, currentUserId, context) - TwitchCommand.Unvip -> removeVip(command, context) - TwitchCommand.Vip -> addVip(command, context) - TwitchCommand.Vips -> getVips(command, context) - TwitchCommand.Whisper -> sendWhisper(command, currentUserId, context.trigger, context.args) - TwitchCommand.Shoutout -> sendShoutout(command, currentUserId, context) + + TwitchCommand.Timeout -> timeoutUser(command, currentUserId, context) + + TwitchCommand.Unban -> unbanUser(command, currentUserId, context) + + TwitchCommand.UniqueChat -> enableUniqueChatMode(command, currentUserId, context) + + TwitchCommand.UniqueChatOff -> disableUniqueChatMode(command, currentUserId, context) + + TwitchCommand.Unmod -> removeModerator(command, context) + + TwitchCommand.Unraid -> cancelRaid(command, context) + + TwitchCommand.Untimeout -> unbanUser(command, currentUserId, context) + + TwitchCommand.Unvip -> removeVip(command, context) + + TwitchCommand.Vip -> addVip(command, context) + + TwitchCommand.Vips -> getVips(command, context) + + TwitchCommand.Whisper -> sendWhisper(command, currentUserId, context.trigger, context.args) + + TwitchCommand.Shoutout -> sendShoutout(command, currentUserId, context) } } - suspend fun sendWhisper(command: TwitchCommand, currentUserId: UserId, trigger: String, args: List): CommandResult { + suspend fun sendWhisper( + command: TwitchCommand, + currentUserId: UserId, + trigger: String, + args: List, + ): CommandResult { if (args.size < 2 || args[0].isBlank() || args[1].isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: $trigger .") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_whisper, persistentListOf(trigger))) } val targetName = args[0] - val targetId = helixApiClient.getUserIdByName(targetName.toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val targetId = + helixApiClient.getUserIdByName(targetName.toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val request = WhisperRequestDto(args.drop(1).joinToString(separator = " ")) val result = helixApiClient.postWhisper(currentUserId, targetId, request) return result.fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "Whisper sent.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_whisper_sent)) }, onFailure = { - val response = "Failed to send whisper - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_whisper, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun sendAnnouncement(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun sendAnnouncement( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Call attention to your message with a highlight.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_announce, persistentListOf(context.trigger))) } val message = args.joinToString(" ") - val color = when (command) { - TwitchCommand.AnnounceBlue -> AnnouncementColor.Blue - TwitchCommand.AnnounceGreen -> AnnouncementColor.Green - TwitchCommand.AnnounceOrange -> AnnouncementColor.Orange - TwitchCommand.AnnouncePurple -> AnnouncementColor.Purple - else -> AnnouncementColor.Primary - } + val color = + when (command) { + TwitchCommand.AnnounceBlue -> AnnouncementColor.Blue + TwitchCommand.AnnounceGreen -> AnnouncementColor.Green + TwitchCommand.AnnounceOrange -> AnnouncementColor.Orange + TwitchCommand.AnnouncePurple -> AnnouncementColor.Purple + else -> AnnouncementColor.Primary + } val request = AnnouncementRequestDto(message, color) val result = helixApiClient.postAnnouncement(context.channelId, currentUserId, request) return result.fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to send announcement - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_announce, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun getModerators(command: TwitchCommand, context: CommandContext): CommandResult { - return helixApiClient.getModerators(context.channelId).fold( - onSuccess = { result -> - when { - result.isEmpty() -> CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any moderators.") - else -> { - val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = "The moderators of this channel are $users.") - } + private suspend fun getModerators( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = helixApiClient.getModerators(context.channelId).fold( + onSuccess = { result -> + when { + result.isEmpty() -> { + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_moderators)) } - val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = "The moderators of this channel are $users.") - }, - onFailure = { - val response = "Failed to list moderators - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - } - ) - } - private suspend fun addModerator(command: TwitchCommand, context: CommandContext): CommandResult { + else -> { + val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_moderators_list, persistentListOf(users))) + } + } + }, + onFailure = { + val response = TextResource.Res(R.string.cmd_fail_list_moderators, persistentListOf(it.toErrorMessage(command))) + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) + + private suspend fun addModerator( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Grant moderation status to a user.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_mod, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val targetId = target.id val targetUser = target.displayName return helixApiClient.postModerator(context.channelId, targetId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You have added $targetUser as a moderator of this channel.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_mod_added, persistentListOf(targetUser.toString()))) }, onFailure = { - val response = "Failed to add channel moderator - ${it.toErrorMessage(command, targetUser)}" + val response = TextResource.Res(R.string.cmd_fail_mod_add, persistentListOf(it.toErrorMessage(command, targetUser))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun removeModerator(command: TwitchCommand, context: CommandContext): CommandResult { + private suspend fun removeModerator( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Revoke moderation status from a user.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_unmod, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val targetId = target.id val targetUser = target.displayName return helixApiClient.deleteModerator(context.channelId, targetId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You have removed $targetUser as a moderator of this channel.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_mod_removed, persistentListOf(targetUser.toString()))) }, onFailure = { - val response = "Failed to remove channel moderator - ${it.toErrorMessage(command, targetUser)}" + val response = TextResource.Res(R.string.cmd_fail_mod_remove, persistentListOf(it.toErrorMessage(command, targetUser))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun getVips(command: TwitchCommand, context: CommandContext): CommandResult { - return helixApiClient.getVips(context.channelId).fold( - onSuccess = { result -> - when { - result.isEmpty() -> CommandResult.AcceptedTwitchCommand(command, response = "This channel does not have any VIPs.") - else -> { - val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } - CommandResult.AcceptedTwitchCommand(command, response = "The vips of this channel are $users.") - } + private suspend fun getVips( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = helixApiClient.getVips(context.channelId).fold( + onSuccess = { result -> + when { + result.isEmpty() -> { + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_no_vips)) } - }, - onFailure = { - val response = "Failed to list VIPs - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - } - ) - } - private suspend fun addVip(command: TwitchCommand, context: CommandContext): CommandResult { + else -> { + val users = result.joinToString { it.userLogin.formatWithDisplayName(it.userName) } + CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_vips_list, persistentListOf(users))) + } + } + }, + onFailure = { + val response = TextResource.Res(R.string.cmd_fail_list_vips, persistentListOf(it.toErrorMessage(command))) + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) + + private suspend fun addVip( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Grant VIP status to a user.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_vip, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val targetId = target.id val targetUser = target.displayName return helixApiClient.postVip(context.channelId, targetId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You have added $targetUser as a VIP of this channel.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_vip_added, persistentListOf(targetUser.toString()))) }, onFailure = { - val response = "Failed to add VIP - ${it.toErrorMessage(command, targetUser)}" + val response = TextResource.Res(R.string.cmd_fail_vip_add, persistentListOf(it.toErrorMessage(command, targetUser))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun removeVip(command: TwitchCommand, context: CommandContext): CommandResult { + private suspend fun removeVip( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Revoke VIP status from a user.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_unvip, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val targetId = target.id val targetUser = target.displayName return helixApiClient.deleteVip(context.channelId, targetId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You have removed $targetUser as a VIP of this channel.") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_vip_removed, persistentListOf(targetUser.toString()))) }, onFailure = { - val response = "Failed to remove VIP - ${it.toErrorMessage(command, targetUser)}" + val response = TextResource.Res(R.string.cmd_fail_vip_remove, persistentListOf(it.toErrorMessage(command, targetUser))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun banUser(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun banUser( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - val usageResponse = "Usage: ${context.trigger} [reason] - Permanently prevent a user from chatting. " + - "Reason is optional and will be shown to the target user and other moderators. Use /unban to remove a ban." - return CommandResult.AcceptedTwitchCommand(command, usageResponse) + return CommandResult.AcceptedTwitchCommand(command, TextResource.Res(R.string.cmd_usage_ban, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } if (target.id == currentUserId) { - return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot ban yourself.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_ban_cannot_self)) } else if (target.id == context.channelId) { - return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot ban the broadcaster.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_ban_cannot_broadcaster)) } val reason = args.drop(1).joinToString(separator = " ").ifBlank { null } @@ -295,60 +376,64 @@ class TwitchCommandRepository( return helixApiClient.postBan(context.channelId, currentUserId, request).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to ban user - ${it.toErrorMessage(command, targetUser)}" + val response = TextResource.Res(R.string.cmd_fail_ban, persistentListOf(it.toErrorMessage(command, targetUser))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun unbanUser(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun unbanUser( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - val usageResponse = "Usage: ${context.trigger} - Removes a ban on a user." - return CommandResult.AcceptedTwitchCommand(command, usageResponse) + return CommandResult.AcceptedTwitchCommand(command, TextResource.Res(R.string.cmd_usage_unban, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } val targetId = target.id return helixApiClient.deleteBan(context.channelId, currentUserId, targetId).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to unban user - ${it.toErrorMessage(command, target.displayName)}" + val response = TextResource.Res(R.string.cmd_fail_unban, persistentListOf(it.toErrorMessage(command, target.displayName))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun timeoutUser(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun timeoutUser( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args - val usageResponse = "Usage: ${context.trigger} [duration][time unit] [reason] - " + - "Temporarily prevent a user from chatting. Duration (optional, " + - "default=10 minutes) must be a positive integer; time unit " + - "(optional, default=s) must be one of s, m, h, d, w; maximum " + - "duration is 2 weeks. Combinations like 1d2h are also allowed. " + - "Reason is optional and will be shown to the target user and other " + - "moderators. Use /untimeout to remove a timeout." + val usageResponse = TextResource.Res(R.string.cmd_usage_timeout, persistentListOf(context.trigger)) if (args.isEmpty() || args.first().isBlank()) { return CommandResult.AcceptedTwitchCommand(command, usageResponse) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } if (target.id == currentUserId) { - return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot timeout yourself.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_timeout_cannot_self)) } else if (target.id == context.channelId) { - return CommandResult.AcceptedTwitchCommand(command, response = "Failed to ban user - You cannot timeout the broadcaster.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_timeout_cannot_broadcaster)) } - val durationInSeconds = when { - args.size > 1 && args[1].isNotBlank() -> DateTimeUtils.durationToSeconds(args[1].trim()) ?: return CommandResult.AcceptedTwitchCommand(command, usageResponse) - else -> 60 * 10 - } + val durationInSeconds = + when { + args.size > 1 && args[1].isNotBlank() -> DateTimeUtils.durationToSeconds(args[1].trim()) ?: return CommandResult.AcceptedTwitchCommand(command, usageResponse) + else -> 60 * 10 + } val reason = args.drop(2).joinToString(separator = " ").ifBlank { null } val targetId = target.id @@ -356,82 +441,101 @@ class TwitchCommandRepository( return helixApiClient.postBan(context.channelId, currentUserId, request).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to timeout user - ${it.toErrorMessage(command, target.displayName)}" + val response = TextResource.Res(R.string.cmd_fail_timeout, persistentListOf(it.toErrorMessage(command, target.displayName))) CommandResult.AcceptedTwitchCommand(command, response) - } - ) - } - - private suspend fun clearChat(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { - return helixApiClient.deleteMessages(context.channelId, currentUserId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, - onFailure = { - val response = "Failed to delete chat messages - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun deleteMessage(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun clearChat( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult = helixApiClient.deleteMessages(context.channelId, currentUserId).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, + onFailure = { + val response = TextResource.Res(R.string.cmd_fail_clear, persistentListOf(it.toErrorMessage(command))) + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) + + private suspend fun deleteMessage( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: /delete - Deletes the specified message.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_delete)) } val messageId = args.first() val parsedId = runCatching { UUID.fromString(messageId) } if (parsedId.isFailure) { - return CommandResult.AcceptedTwitchCommand(command, response = "Invalid msg-id: \"$messageId\".") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_delete_invalid_id, persistentListOf(messageId))) } return helixApiClient.deleteMessages(context.channelId, currentUserId, messageId).fold( onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, onFailure = { - val response = "Failed to delete chat messages - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_delete, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun updateColor(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun updateColor( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - val usage = "Usage: /color - Color must be one of Twitch's supported colors (${VALID_HELIX_COLORS.joinToString()}) or a hex code (#000000) if you have Turbo or Prime." - return CommandResult.AcceptedTwitchCommand(command, response = usage) + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_color, persistentListOf(VALID_HELIX_COLORS.joinToString()))) } val colorArg = args.first().lowercase() val color = HELIX_COLOR_REPLACEMENTS[colorArg] ?: colorArg return helixApiClient.putUserChatColor(currentUserId, color).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "Your color has been changed to $color") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_color, persistentListOf(color))) }, onFailure = { - val response = "Failed to change color to $color - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_color, persistentListOf(color, it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun createMarker(command: TwitchCommand, context: CommandContext): CommandResult { - val description = context.args.joinToString(separator = " ").take(140).ifBlank { null } + private suspend fun createMarker( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { + val description = + context.args + .joinToString(separator = " ") + .take(140) + .ifBlank { null } val request = MarkerRequestDto(context.channelId, description) return helixApiClient.postMarker(request).fold( onSuccess = { result -> val markerDescription = result.description?.let { ": \"$it\"" }.orEmpty() - val response = "Successfully added a stream marker at ${DateTimeUtils.formatSeconds(result.positionSeconds)}$markerDescription." + val response = TextResource.Res(R.string.cmd_success_marker, persistentListOf(DateTimeUtils.formatSeconds(result.positionSeconds), markerDescription)) CommandResult.AcceptedTwitchCommand(command, response) }, onFailure = { - val response = "Failed to create stream marker - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_marker, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun startCommercial(command: TwitchCommand, context: CommandContext): CommandResult { + private suspend fun startCommercial( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args - val usage = "Usage: /commercial - Starts a commercial with the specified duration for the current channel. Valid length options are 30, 60, 90, 120, 150, and 180 seconds." + val usage = TextResource.Res(R.string.cmd_usage_commercial) if (args.isEmpty() || args.first().isBlank()) { return CommandResult.AcceptedTwitchCommand(command, response = usage) } @@ -440,61 +544,66 @@ class TwitchCommandRepository( val request = CommercialRequestDto(context.channelId, length) return helixApiClient.postCommercial(request).fold( onSuccess = { result -> - val response = "Starting ${result.length} second long commercial break. " + - "Keep in mind you are still live and not all viewers will receive a commercial. " + - "You may run another commercial in ${result.retryAfter} seconds." + val response = TextResource.PluralRes(R.plurals.cmd_success_commercial, result.length, persistentListOf(result.length, result.retryAfter)) CommandResult.AcceptedTwitchCommand(command, response) }, onFailure = { - val response = "Failed to start commercial - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_commercial, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun startRaid(command: TwitchCommand, context: CommandContext): CommandResult { + private suspend fun startRaid( + command: TwitchCommand, + context: CommandContext, + ): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - val usage = "Usage: /raid - Raid a user. Only the broadcaster can start a raid." - return CommandResult.AcceptedTwitchCommand(command, response = usage) - } - - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "Invalid username: ${args.first()}") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_raid)) } - return helixApiClient.postRaid(context.channelId, target.id).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You started to raid ${target.displayName}.") }, - onFailure = { - val response = "Failed to start a raid - ${it.toErrorMessage(command)}" - CommandResult.AcceptedTwitchCommand(command, response) + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_raid_invalid_username, persistentListOf(args.first()))) } - ) - } - private suspend fun cancelRaid(command: TwitchCommand, context: CommandContext): CommandResult { - return helixApiClient.deleteRaid(context.channelId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "You cancelled the raid.") }, + return helixApiClient.postRaid(context.channelId, target.id).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_raid, persistentListOf(target.displayName.toString()))) }, onFailure = { - val response = "Failed to cancel the raid - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_raid, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun enableFollowersMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun cancelRaid( + command: TwitchCommand, + context: CommandContext, + ): CommandResult = helixApiClient.deleteRaid(context.channelId).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_unraid)) }, + onFailure = { + val response = TextResource.Res(R.string.cmd_fail_unraid, persistentListOf(it.toErrorMessage(command))) + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) + + private suspend fun enableFollowersMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args - val usage = "Usage: /followers [duration] - Enables followers-only mode (only users who have followed for 'duration' may chat). " + - "Duration is optional and must be specified in the format like \"30m\", \"1w\", \"5d 12h\". " + - "Must be less than 3 months. The default is \"0\" (no restriction)." + val usage = TextResource.Res(R.string.cmd_usage_followers, persistentListOf(context.trigger)) val durationArg = args.joinToString(separator = " ").ifBlank { null } - val duration = durationArg?.let { - val seconds = DateTimeUtils.durationToSeconds(it) ?: return CommandResult.AcceptedTwitchCommand(command, response = usage) - seconds / 60 - } + val duration = + durationArg?.let { + val seconds = DateTimeUtils.durationToSeconds(it) ?: return CommandResult.AcceptedTwitchCommand(command, response = usage) + seconds / 60 + } if (duration != null && duration == context.roomState.followerModeDuration) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is already in ${DateTimeUtils.formatSeconds(duration * 60)} followers-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_already_followers, persistentListOf(DateTimeUtils.formatSeconds(duration * 60)))) } val request = ChatSettingsRequestDto(followerMode = true, followerModeDuration = duration) @@ -505,80 +614,110 @@ class TwitchCommandRepository( } } - private suspend fun disableFollowersMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun disableFollowersMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (!context.roomState.isFollowMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is not in followers-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_not_followers)) } val request = ChatSettingsRequestDto(followerMode = false) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun enableEmoteMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun enableEmoteMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (context.roomState.isEmoteMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is already in emote-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_already_emote_only)) } val request = ChatSettingsRequestDto(emoteMode = true) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun disableEmoteMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun disableEmoteMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (!context.roomState.isEmoteMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is not in emote-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_not_emote_only)) } val request = ChatSettingsRequestDto(emoteMode = false) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun enableSubscriberMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun enableSubscriberMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (context.roomState.isSubscriberMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is already in subscribers-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_already_subscribers)) } val request = ChatSettingsRequestDto(subscriberMode = true) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun disableSubscriberMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun disableSubscriberMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (!context.roomState.isSubscriberMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is not in subscribers-only mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_not_subscribers)) } val request = ChatSettingsRequestDto(subscriberMode = false) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun enableUniqueChatMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun enableUniqueChatMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (context.roomState.isUniqueChatMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is already in unique-chat mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_already_unique_chat)) } val request = ChatSettingsRequestDto(uniqueChatMode = true) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun disableUniqueChatMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun disableUniqueChatMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (!context.roomState.isUniqueChatMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is not in unique-chat mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_not_unique_chat)) } val request = ChatSettingsRequestDto(uniqueChatMode = false) return updateChatSettings(command, currentUserId, context, request) } - private suspend fun enableSlowMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun enableSlowMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val args = context.args.firstOrNull() ?: "30" val duration = args.toIntOrNull() if (duration == null) { - val usage = "Usage: /slow [duration] - Enables slow mode (limit how often users may send messages). " + - "Duration (optional, default=30) must be a positive number of seconds. Use /slowoff to disable." - return CommandResult.AcceptedTwitchCommand(command, usage) + return CommandResult.AcceptedTwitchCommand(command, TextResource.Res(R.string.cmd_usage_slow, persistentListOf(context.trigger))) } if (duration == context.roomState.slowModeWaitTime) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is already in $duration-second slow mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_already_slow, persistentListOf(duration))) } val request = ChatSettingsRequestDto(slowMode = true, slowModeWaitTime = duration) @@ -587,9 +726,13 @@ class TwitchCommandRepository( } } - private suspend fun disableSlowMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun disableSlowMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { if (!context.roomState.isSlowMode) { - return CommandResult.AcceptedTwitchCommand(command, response = "This room is not in slow mode.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_room_not_slow)) } val request = ChatSettingsRequestDto(slowMode = false) @@ -601,98 +744,208 @@ class TwitchCommandRepository( currentUserId: UserId, context: CommandContext, request: ChatSettingsRequestDto, - formatRange: ((IntRange) -> String)? = null + formatRange: ((IntRange) -> String)? = null, + ): CommandResult = helixApiClient.patchChatSettings(context.channelId, currentUserId, request).fold( + onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, + onFailure = { + val response = TextResource.Res(R.string.cmd_fail_chat_settings, persistentListOf(it.toErrorMessage(command, formatRange = formatRange))) + CommandResult.AcceptedTwitchCommand(command, response) + }, + ) + + private suspend fun sendShoutout( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, ): CommandResult { - return helixApiClient.patchChatSettings(context.channelId, currentUserId, request).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command) }, - onFailure = { - val response = "Failed to update - ${it.toErrorMessage(command, formatRange = formatRange)}" - CommandResult.AcceptedTwitchCommand(command, response) - } - ) - } - - private suspend fun sendShoutout(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { val args = context.args if (args.isEmpty() || args.first().isBlank()) { - return CommandResult.AcceptedTwitchCommand(command, response = "Usage: ${context.trigger} - Sends a shoutout to the specified Twitch user.") + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_usage_shoutout, persistentListOf(context.trigger))) } - val target = helixApiClient.getUserByName(args.first().toUserName()).getOrElse { - return CommandResult.AcceptedTwitchCommand(command, response = "No user matching that username.") - } + val target = + helixApiClient.getUserByName(args.first().toUserName()).getOrElse { + return CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_error_no_user_matching)) + } return helixApiClient.postShoutout(context.channelId, target.id, currentUserId).fold( - onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = "Sent shoutout to ${target.displayName}") }, + onSuccess = { CommandResult.AcceptedTwitchCommand(command, response = TextResource.Res(R.string.cmd_success_shoutout, persistentListOf(target.displayName.toString()))) }, onFailure = { - val response = "Failed to send shoutout - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_shoutout, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private suspend fun toggleShieldMode(command: TwitchCommand, currentUserId: UserId, context: CommandContext): CommandResult { + private suspend fun toggleShieldMode( + command: TwitchCommand, + currentUserId: UserId, + context: CommandContext, + ): CommandResult { val enable = command == TwitchCommand.Shield val request = ShieldModeRequestDto(isActive = enable) return helixApiClient.putShieldMode(context.channelId, currentUserId, request).fold( onSuccess = { - val response = when { - it.isActive -> "Shield mode was activated." - else -> "Shield mode was deactivated." - } + shieldModeRepository.setState(context.channel, it.isActive) + val response = + when { + it.isActive -> TextResource.Res(R.string.cmd_shield_activated) + else -> TextResource.Res(R.string.cmd_shield_deactivated) + } CommandResult.AcceptedTwitchCommand(command, response) }, onFailure = { - val response = "Failed to update shield mode - ${it.toErrorMessage(command)}" + val response = TextResource.Res(R.string.cmd_fail_shield, persistentListOf(it.toErrorMessage(command))) CommandResult.AcceptedTwitchCommand(command, response) - } + }, ) } - private fun Throwable.toErrorMessage(command: TwitchCommand, targetUser: DisplayName? = null, formatRange: ((IntRange) -> String)? = null): String { - Log.v(TAG, "Command failed: $this") + private fun Throwable.toErrorMessage( + command: TwitchCommand, + targetUser: DisplayName? = null, + formatRange: ((IntRange) -> String)? = null, + ): TextResource { + logger.trace { "Command failed: $this" } if (this !is HelixApiException) { - return GENERIC_ERROR_MESSAGE + return TextResource.Res(R.string.cmd_error_unknown) } + val target = targetUser?.let { TextResource.Plain(it.toString()) } ?: TextResource.Res(R.string.cmd_error_target_default) return when (error) { - HelixError.UserNotAuthorized -> "You don't have permission to perform that action." - HelixError.Forwarded -> message ?: GENERIC_ERROR_MESSAGE - HelixError.MissingScopes -> "Missing required scope. Re-login with your account and try again." - HelixError.NotLoggedIn -> "Missing login credentials. Re-login with your account and try again." - HelixError.WhisperSelf -> "You cannot whisper yourself." - HelixError.NoVerifiedPhone -> "Due to Twitch restrictions, you are now required to have a verified phone number to send whispers. You can add a phone number in Twitch settings. https://www.twitch.tv/settings/security" - HelixError.RecipientBlockedUser -> "The recipient doesn't allow whispers from strangers or you directly." - HelixError.RateLimited -> "You are being rate-limited by Twitch. Try again in a few seconds." - HelixError.WhisperRateLimited -> "You may only whisper a maximum of 40 unique recipients per day. Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute." - HelixError.BroadcasterTokenRequired -> "Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead." - HelixError.TargetAlreadyModded -> "${targetUser ?: "The target user"} is already a moderator of this channel." - HelixError.TargetIsVip -> "${targetUser ?: "The target user"} is currently a VIP, /unvip them and retry this command." - HelixError.TargetNotModded -> "${targetUser ?: "The target user"} is not a moderator of this channel." - HelixError.TargetNotBanned -> "${targetUser ?: "The target user"} is not banned from this channel." - HelixError.TargetAlreadyBanned -> "${targetUser ?: "The target user"} is already banned in this channel." - HelixError.TargetCannotBeBanned -> "You cannot ${command.trigger} ${targetUser ?: "this user"}." - HelixError.ConflictingBanOperation -> "There was a conflicting ban operation on this user. Please try again." - HelixError.InvalidColor -> "Color must be one of Twitch's supported colors (${VALID_HELIX_COLORS.joinToString()}) or a hex code (#000000) if you have Turbo or Prime." - is HelixError.MarkerError -> error.message ?: GENERIC_ERROR_MESSAGE - HelixError.CommercialNotStreaming -> "You must be streaming live to run commercials." - HelixError.CommercialRateLimited -> "You must wait until your cool-down period expires before you can run another commercial." - HelixError.MissingLengthParameter -> "Command must include a desired commercial break length that is greater than zero." - HelixError.NoRaidPending -> "You don't have an active raid." - HelixError.RaidSelf -> "A channel cannot raid itself." - HelixError.ShoutoutSelf -> "The broadcaster may not give themselves a Shoutout." - HelixError.ShoutoutTargetNotStreaming -> "The broadcaster is not streaming live or does not have one or more viewers." - is HelixError.NotInRange -> { + HelixError.UserNotAuthorized -> { + TextResource.Res(R.string.cmd_error_no_permission) + } + + HelixError.Forwarded -> { + message?.let { TextResource.Plain(it) } ?: TextResource.Res(R.string.cmd_error_unknown) + } + + HelixError.MissingScopes -> { + TextResource.Res(R.string.cmd_error_missing_scopes) + } + + HelixError.NotLoggedIn -> { + TextResource.Res(R.string.cmd_error_not_logged_in_credentials) + } + + HelixError.WhisperSelf -> { + TextResource.Res(R.string.cmd_error_whisper_self) + } + + HelixError.NoVerifiedPhone -> { + TextResource.Res(R.string.cmd_error_no_verified_phone) + } + + HelixError.RecipientBlockedUser -> { + TextResource.Res(R.string.cmd_error_recipient_blocked) + } + + HelixError.RateLimited -> { + TextResource.Res(R.string.cmd_error_rate_limited) + } + + HelixError.WhisperRateLimited -> { + TextResource.Res(R.string.cmd_error_whisper_rate_limited) + } + + HelixError.BroadcasterTokenRequired -> { + TextResource.Res(R.string.cmd_error_broadcaster_required) + } + + HelixError.TargetAlreadyModded -> { + TextResource.Res(R.string.cmd_error_already_modded, persistentListOf(target)) + } + + HelixError.TargetIsVip -> { + TextResource.Res(R.string.cmd_error_target_is_vip, persistentListOf(target)) + } + + HelixError.TargetNotModded -> { + TextResource.Res(R.string.cmd_error_not_modded, persistentListOf(target)) + } + + HelixError.TargetNotBanned -> { + TextResource.Res(R.string.cmd_error_not_banned, persistentListOf(target)) + } + + HelixError.TargetAlreadyBanned -> { + TextResource.Res(R.string.cmd_error_already_banned, persistentListOf(target)) + } + + HelixError.TargetCannotBeBanned -> { + TextResource.Res(R.string.cmd_error_cannot_perform, persistentListOf(command.trigger, target)) + } + + HelixError.ConflictingBanOperation -> { + TextResource.Res(R.string.cmd_error_conflicting_ban) + } + + HelixError.InvalidColor -> { + TextResource.Res(R.string.cmd_error_invalid_color, persistentListOf(VALID_HELIX_COLORS.joinToString())) + } + + is HelixError.MarkerError -> { + error.message?.let { TextResource.Plain(it) } ?: TextResource.Res(R.string.cmd_error_unknown) + } + + HelixError.CommercialNotStreaming -> { + TextResource.Res(R.string.cmd_error_commercial_not_streaming) + } + + HelixError.CommercialRateLimited -> { + TextResource.Res(R.string.cmd_error_commercial_rate_limited) + } + + HelixError.MissingLengthParameter -> { + TextResource.Res(R.string.cmd_error_missing_length) + } + + HelixError.NoRaidPending -> { + TextResource.Res(R.string.cmd_error_no_raid_pending) + } + + HelixError.RaidSelf -> { + TextResource.Res(R.string.cmd_error_raid_self) + } + + HelixError.ShoutoutSelf -> { + TextResource.Res(R.string.cmd_error_shoutout_self) + } + + HelixError.ShoutoutTargetNotStreaming -> { + TextResource.Res(R.string.cmd_error_shoutout_not_streaming) + } + + is HelixError.NotInRange -> { val range = error.validRange - when (val formatted = range?.let { formatRange?.invoke(it) }) { - null -> message ?: GENERIC_ERROR_MESSAGE - else -> "The duration is out of the valid range: $formatted." + val formatted = range?.let { formatRange?.invoke(it) } + when { + formatted != null -> TextResource.Res(R.string.cmd_error_out_of_range, persistentListOf(formatted)) + else -> message?.let { TextResource.Plain(it) } ?: TextResource.Res(R.string.cmd_error_unknown) } + } + HelixError.MessageAlreadyProcessed -> { + TextResource.Res(R.string.cmd_error_message_already_processed) } - HelixError.Unknown -> GENERIC_ERROR_MESSAGE + HelixError.MessageNotFound -> { + TextResource.Res(R.string.cmd_error_message_not_found) + } + + HelixError.MessageTooLarge -> { + TextResource.Res(R.string.cmd_error_message_too_large) + } + + HelixError.ChatMessageRateLimited -> { + TextResource.Res(R.string.cmd_error_chat_rate_limited) + } + + HelixError.Unknown -> { + TextResource.Res(R.string.cmd_error_unknown) + } } } @@ -700,39 +953,41 @@ class TwitchCommandRepository( private val ALLOWED_IRC_COMMANDS = listOf("me", "disconnect") private val ALLOWED_FIRST_TRIGGER_CHARS = listOf('/', '.') private val ALLOWED_IRC_COMMAND_TRIGGERS = ALLOWED_IRC_COMMANDS.flatMap { asCommandTriggers(it) } + fun asCommandTriggers(command: String): List = ALLOWED_FIRST_TRIGGER_CHARS.map { "$it$command" } - val ALL_COMMAND_TRIGGERS = ALLOWED_IRC_COMMAND_TRIGGERS + TwitchCommand.ALL_COMMANDS.flatMap { asCommandTriggers(it.trigger) } - private val TAG = TwitchCommandRepository::class.java.simpleName - private const val GENERIC_ERROR_MESSAGE = "An unknown error has occurred." - private val VALID_HELIX_COLORS = listOf( - "blue", - "blue_violet", - "cadet_blue", - "chocolate", - "coral", - "dodger_blue", - "firebrick", - "golden_rod", - "green", - "hot_pink", - "orange_red", - "red", - "sea_green", - "spring_green", - "yellow_green", - ) + val ALL_COMMAND_TRIGGERS = ALLOWED_IRC_COMMAND_TRIGGERS + TwitchCommand.ALL_COMMANDS.flatMap { asCommandTriggers(it.trigger) } - private val HELIX_COLOR_REPLACEMENTS = mapOf( - "blueviolet" to "blue_violet", - "cadetblue" to "cadet_blue", - "dodgerblue" to "dodger_blue", - "goldenrod" to "golden_rod", - "hotpink" to "hot_pink", - "orangered" to "orange_red", - "seagreen" to "sea_green", - "springgreen" to "spring_green", - "yellowgreen" to "yellow_green", - ) + private val VALID_HELIX_COLORS = + listOf( + "blue", + "blue_violet", + "cadet_blue", + "chocolate", + "coral", + "dodger_blue", + "firebrick", + "golden_rod", + "green", + "hot_pink", + "orange_red", + "red", + "sea_green", + "spring_green", + "yellow_green", + ) + + private val HELIX_COLOR_REPLACEMENTS = + mapOf( + "blueviolet" to "blue_violet", + "cadetblue" to "cadet_blue", + "dodgerblue" to "dodger_blue", + "goldenrod" to "golden_rod", + "hotpink" to "hot_pink", + "orangered" to "orange_red", + "seagreen" to "sea_green", + "springgreen" to "spring_green", + "yellowgreen" to "yellow_green", + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmote.kt index 592576918..bd4d4dee1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmote.kt @@ -1,10 +1,12 @@ package com.flxrs.dankchat.data.twitch.emote import android.os.Parcelable +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.utils.IntRangeParceler import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler +@Immutable @Parcelize @TypeParceler data class ChatMessageEmote( @@ -16,4 +18,6 @@ data class ChatMessageEmote( val type: ChatMessageEmoteType, val isTwitch: Boolean = false, val isOverlayEmote: Boolean = false, + val cheerAmount: Int? = null, + val cheerColor: Int? = null, ) : Parcelable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmoteType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmoteType.kt index 97fe9c027..13e665adb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmoteType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ChatMessageEmoteType.kt @@ -5,35 +5,50 @@ import com.flxrs.dankchat.data.DisplayName import kotlinx.parcelize.Parcelize sealed interface ChatMessageEmoteType : Parcelable { - @Parcelize object TwitchEmote : ChatMessageEmoteType @Parcelize - data class ChannelFFZEmote(val creator: DisplayName?) : ChatMessageEmoteType + data class ChannelFFZEmote( + val creator: DisplayName?, + ) : ChatMessageEmoteType @Parcelize - data class GlobalFFZEmote(val creator: DisplayName?) : ChatMessageEmoteType + data class GlobalFFZEmote( + val creator: DisplayName?, + ) : ChatMessageEmoteType @Parcelize - data class ChannelBTTVEmote(val creator: DisplayName?, val isShared: Boolean) : ChatMessageEmoteType + data class ChannelBTTVEmote( + val creator: DisplayName?, + val isShared: Boolean, + ) : ChatMessageEmoteType @Parcelize object GlobalBTTVEmote : ChatMessageEmoteType @Parcelize - data class ChannelSevenTVEmote(val creator: DisplayName?, val baseName: String?) : ChatMessageEmoteType + data class ChannelSevenTVEmote( + val creator: DisplayName?, + val baseName: String?, + ) : ChatMessageEmoteType + + @Parcelize + data class GlobalSevenTVEmote( + val creator: DisplayName?, + val baseName: String?, + ) : ChatMessageEmoteType @Parcelize - data class GlobalSevenTVEmote(val creator: DisplayName?, val baseName: String?) : ChatMessageEmoteType + data object Cheermote : ChatMessageEmoteType } fun EmoteType.toChatMessageEmoteType(): ChatMessageEmoteType? = when (this) { - is EmoteType.ChannelBTTVEmote -> ChatMessageEmoteType.ChannelBTTVEmote(creator, isShared) - is EmoteType.ChannelFFZEmote -> ChatMessageEmoteType.ChannelFFZEmote(creator) + is EmoteType.ChannelBTTVEmote -> ChatMessageEmoteType.ChannelBTTVEmote(creator, isShared) + is EmoteType.ChannelFFZEmote -> ChatMessageEmoteType.ChannelFFZEmote(creator) is EmoteType.ChannelSevenTVEmote -> ChatMessageEmoteType.ChannelSevenTVEmote(creator, baseName) - EmoteType.GlobalBTTVEmote -> ChatMessageEmoteType.GlobalBTTVEmote - is EmoteType.GlobalFFZEmote -> ChatMessageEmoteType.GlobalFFZEmote(creator) - is EmoteType.GlobalSevenTVEmote -> ChatMessageEmoteType.GlobalSevenTVEmote(creator, baseName) - else -> null + EmoteType.GlobalBTTVEmote -> ChatMessageEmoteType.GlobalBTTVEmote + is EmoteType.GlobalFFZEmote -> ChatMessageEmoteType.GlobalFFZEmote(creator) + is EmoteType.GlobalSevenTVEmote -> ChatMessageEmoteType.GlobalSevenTVEmote(creator, baseName) + else -> null } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/CheermoteSet.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/CheermoteSet.kt new file mode 100644 index 000000000..5dcfe1d0a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/CheermoteSet.kt @@ -0,0 +1,16 @@ +package com.flxrs.dankchat.data.twitch.emote + +import androidx.annotation.ColorInt + +data class CheermoteSet( + val prefix: String, + val regex: Regex, + val tiers: List, +) + +data class CheermoteTier( + val minBits: Int, + @param:ColorInt val color: Int, + val animatedUrl: String, + val staticUrl: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/EmoteType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/EmoteType.kt index d0e842ce9..a4b113c00 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/EmoteType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/EmoteType.kt @@ -6,27 +6,41 @@ import com.flxrs.dankchat.data.UserName sealed interface EmoteType : Comparable { val title: String - data class ChannelTwitchEmote(val channel: UserName) : EmoteType { + data class ChannelTwitchEmote( + val channel: UserName, + ) : EmoteType { override val title = channel.value } - data class ChannelTwitchBitEmote(val channel: UserName) : EmoteType { + data class ChannelTwitchBitEmote( + val channel: UserName, + ) : EmoteType { override val title = channel.value } - data class ChannelTwitchFollowerEmote(val channel: UserName) : EmoteType { + data class ChannelTwitchFollowerEmote( + val channel: UserName, + ) : EmoteType { override val title = channel.value } - data class ChannelFFZEmote(val creator: DisplayName?) : EmoteType { + data class ChannelFFZEmote( + val creator: DisplayName?, + ) : EmoteType { override val title = "FrankerFaceZ" } - data class ChannelBTTVEmote(val creator: DisplayName, val isShared: Boolean) : EmoteType { + data class ChannelBTTVEmote( + val creator: DisplayName, + val isShared: Boolean, + ) : EmoteType { override val title = "BetterTTV" } - data class ChannelSevenTVEmote(val creator: DisplayName?, val baseName: String?) : EmoteType { + data class ChannelSevenTVEmote( + val creator: DisplayName?, + val baseName: String?, + ) : EmoteType { override val title = "SevenTV" } @@ -34,7 +48,9 @@ sealed interface EmoteType : Comparable { override val title = "Twitch" } - data class GlobalFFZEmote(val creator: DisplayName?) : EmoteType { + data class GlobalFFZEmote( + val creator: DisplayName?, + ) : EmoteType { override val title = "FrankerFaceZ" } @@ -42,7 +58,10 @@ sealed interface EmoteType : Comparable { override val title = "BetterTTV" } - data class GlobalSevenTVEmote(val creator: DisplayName?, val baseName: String?) : EmoteType { + data class GlobalSevenTVEmote( + val creator: DisplayName?, + val baseName: String?, + ) : EmoteType { override val title = "SevenTV" } @@ -51,16 +70,22 @@ sealed interface EmoteType : Comparable { } override fun compareTo(other: EmoteType): Int = when { - this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { + this is ChannelTwitchBitEmote || this is ChannelTwitchFollowerEmote -> { when (other) { is ChannelTwitchBitEmote, - is ChannelTwitchFollowerEmote -> 0 + is ChannelTwitchFollowerEmote, + -> 0 - else -> 1 + else -> 1 } } - other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> -1 - else -> 0 + other is ChannelTwitchBitEmote || other is ChannelTwitchFollowerEmote -> { + -1 + } + + else -> { + 0 + } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/GenericEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/GenericEmote.kt index 593b77c3d..073b22e4d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/GenericEmote.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/GenericEmote.kt @@ -9,11 +9,7 @@ data class GenericEmote( val emoteType: EmoteType, val isOverlayEmote: Boolean = false, ) : Comparable { - override fun toString(): String { - return code - } + override fun toString(): String = code - override fun compareTo(other: GenericEmote): Int { - return code.compareTo(other.code) - } -} \ No newline at end of file + override fun compareTo(other: GenericEmote): Int = code.compareTo(other.code) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ThirdPartyEmoteType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ThirdPartyEmoteType.kt index 5af3f93fb..acd4e449b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ThirdPartyEmoteType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/emote/ThirdPartyEmoteType.kt @@ -3,11 +3,13 @@ package com.flxrs.dankchat.data.twitch.emote enum class ThirdPartyEmoteType { FrankerFaceZ, BetterTTV, - SevenTV; + SevenTV, + ; companion object { - fun mapFromPreferenceSet(preferenceSet: Set): Set = preferenceSet.mapNotNull { - entries.find { emoteType -> emoteType.name.lowercase() == it } - }.toSet() + fun mapFromPreferenceSet(preferenceSet: Set): Set = preferenceSet + .mapNotNull { + entries.find { emoteType -> emoteType.name.lowercase() == it } + }.toSet() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt new file mode 100644 index 000000000..18fe3b9cb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/AutomodMessage.kt @@ -0,0 +1,24 @@ +package com.flxrs.dankchat.data.twitch.message + +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.badge.Badge +import com.flxrs.dankchat.utils.TextResource + +data class AutomodMessage( + override val timestamp: Long, + override val id: String, + override val highlights: Set = emptySet(), + val channel: UserName, + val heldMessageId: String, + val userName: UserName, + val userDisplayName: DisplayName, + val messageText: String?, + val reason: TextResource, + val badges: List = emptyList(), + val color: Int? = null, + val status: Status = Status.Pending, + val isUserSide: Boolean = false, +) : Message { + enum class Status { Pending, Approved, Denied, Expired } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/EmoteWithPositions.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/EmoteWithPositions.kt index de82bc42a..1f078ca04 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/EmoteWithPositions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/EmoteWithPositions.kt @@ -1,3 +1,6 @@ package com.flxrs.dankchat.data.twitch.message -data class EmoteWithPositions(val id: String, val positions: List) +data class EmoteWithPositions( + val id: String, + val positions: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt index b26220875..edb219ecc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/HighlightState.kt @@ -2,7 +2,7 @@ package com.flxrs.dankchat.data.twitch.message data class Highlight( val type: HighlightType, - val customColor: Int? = null + val customColor: Int? = null, ) { val isMention = type in MENTION_TYPES val shouldNotify = type == HighlightType.Notification @@ -13,12 +13,17 @@ data class Highlight( } fun Collection.hasMention(): Boolean = any(Highlight::isMention) + fun Collection.shouldNotify(): Boolean = any(Highlight::shouldNotify) + fun Collection.highestPriorityHighlight(): Highlight? = maxByOrNull { it.type.priority.value } -enum class HighlightType(val priority: HighlightPriority) { +enum class HighlightType( + val priority: HighlightPriority, +) { Subscription(HighlightPriority.HIGH), Announcement(HighlightPriority.HIGH), + WatchStreak(HighlightPriority.HIGH), ChannelPointRedemption(HighlightPriority.HIGH), FirstMessage(HighlightPriority.MEDIUM), ElevatedMessage(HighlightPriority.MEDIUM), @@ -29,7 +34,9 @@ enum class HighlightType(val priority: HighlightPriority) { Notification(HighlightPriority.LOW), } -enum class HighlightPriority(val value: Int) { +enum class HighlightPriority( + val value: Int, +) { LOW(0), MEDIUM(1), HIGH(2), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/Message.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/Message.kt index 5b3f534b0..6adfc471c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/Message.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/Message.kt @@ -5,56 +5,91 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.irc.IrcMessage -sealed class Message { - abstract val id: String - abstract val timestamp: Long - abstract val highlights: Set +sealed interface Message { + val id: String + val timestamp: Long + val highlights: Set - data class EmoteData(val message: String, val channel: UserName, val emotesWithPositions: List) - data class BadgeData(val userId: UserId?, val channel: UserName?, val badgeTag: String?, val badgeInfoTag: String?) + data class EmoteData( + val message: String, + val channel: UserName, + val emotesWithPositions: List, + ) - open val emoteData: EmoteData? = null - open val badgeData: BadgeData? = null + data class BadgeData( + val userId: UserId?, + val channel: UserName?, + val badgeTag: String?, + val badgeInfoTag: String?, + ) + + val emoteData: EmoteData? get() = null + val badgeData: BadgeData? get() = null companion object { private const val DEFAULT_COLOR_TAG = "#717171" val DEFAULT_COLOR = DEFAULT_COLOR_TAG.toColorInt() - fun parse(message: IrcMessage, findChannel: (UserId) -> UserName?): Message? = with(message) { + + fun parse( + message: IrcMessage, + findChannel: (UserId) -> UserName?, + ): Message? = with(message) { return when (command) { - "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) - "NOTICE" -> NoticeMessage.parseNotice(message) + "PRIVMSG" -> PrivMessage.parsePrivMessage(message, findChannel) + "NOTICE" -> NoticeMessage.parseNotice(message) "USERNOTICE" -> UserNoticeMessage.parseUserNotice(message, findChannel) - else -> null + else -> null } } - fun parseEmoteTag(message: String, tag: String): List { - return tag.split('/').mapNotNull { emote -> - val split = emote.split(':') - // bad emote data :) - if (split.size != 2) return@mapNotNull null + fun parseEmoteTag( + message: String, + tag: String, + ): List { + if (tag.isEmpty()) return emptyList() - val (id, positions) = split - val pairs = positions.split(',') - // bad emote data :) - if (pairs.isEmpty()) return@mapNotNull null + return buildList { + var emoteStart = 0 + while (emoteStart < tag.length) { + val slashIdx = tag.indexOf('/', emoteStart) + val emoteEnd = if (slashIdx == -1) tag.length else slashIdx - // skip over invalid parsed data - val parsedPositions = pairs.mapNotNull positions@ { pos -> - val pair = pos.split('-') - if (pair.size != 2) return@positions null + val colonIdx = tag.indexOf(':', emoteStart) + // bad emote data :) + if (colonIdx == -1 || colonIdx >= emoteEnd) { + emoteStart = emoteEnd + 1 + continue + } - val start = pair[0].toIntOrNull() ?: return@positions null - val end = pair[1].toIntOrNull() ?: return@positions null + val id = tag.substring(emoteStart, colonIdx) + val positions = mutableListOf() + var posStart = colonIdx + 1 + while (posStart < emoteEnd) { + val commaIdx = tag.indexOf(',', posStart) + val posEnd = if (commaIdx == -1 || commaIdx > emoteEnd) emoteEnd else commaIdx - // be extra safe in case twitch sends invalid emote ranges :) - start.coerceAtLeast(minimumValue = 0)..end.coerceAtMost(message.length) - } + val dashIdx = tag.indexOf('-', posStart) + if (dashIdx == -1 || dashIdx >= posEnd) { + posStart = posEnd + 1 + continue + } + + val start = tag.substring(posStart, dashIdx).toIntOrNull() + val end = tag.substring(dashIdx + 1, posEnd).toIntOrNull() + if (start != null && end != null) { + positions += start.coerceAtLeast(minimumValue = 0)..end.coerceAtMost(message.length) + } + + posStart = posEnd + 1 + } - EmoteWithPositions(id, parsedPositions) + if (positions.isNotEmpty()) { + this += EmoteWithPositions(id, positions) + } + + emoteStart = emoteEnd + 1 + } } } } } - - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThread.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThread.kt index 79f3063ea..66fe57460 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThread.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThread.kt @@ -4,5 +4,5 @@ data class MessageThread( val rootMessageId: String, val rootMessage: PrivMessage, val replies: List, - val participated: Boolean + val participated: Boolean, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThreadHeader.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThreadHeader.kt index 861079c07..165e7df40 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThreadHeader.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/MessageThreadHeader.kt @@ -6,5 +6,5 @@ data class MessageThreadHeader( val rootId: String, val name: UserName, val message: String, - val participated: Boolean + val participated: Boolean, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/ModerationMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/ModerationMessage.kt index afb3046ef..d56d8f333 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/ModerationMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/ModerationMessage.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.data.twitch.message +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.eventapi.dto.messages.notification.ChannelModerateAction @@ -10,11 +11,13 @@ import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModerationActionData import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModerationActionType import com.flxrs.dankchat.utils.DateTimeUtils -import kotlin.time.Instant +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.UUID +import kotlin.time.Instant data class ModerationMessage( override val timestamp: Long = System.currentTimeMillis(), @@ -27,137 +30,400 @@ data class ModerationMessage( val targetUserDisplay: DisplayName? = null, val sourceBroadcasterDisplay: DisplayName? = null, val targetMsgId: String? = null, - val durationInt: Int? = null, - val duration: String? = null, val reason: String? = null, val fromEventSource: Boolean = false, val stackCount: Int = 0, -) : Message() { - enum class Action { - Timeout, - Untimeout, - Ban, - Unban, - Mod, - Unmod, - Clear, - Delete, - Vip, - Unvip, - Warn, - Raid, - Unraid, - EmoteOnly, - EmoteOnlyOff, - Followers, - FollowersOff, - UniqueChat, - UniqueChatOff, - Slow, - SlowOff, - Subscribers, - SubscribersOff, - SharedBan, - SharedUnban, - SharedTimeout, - SharedUntimeout, - SharedDelete, +) : Message { + sealed interface Action { + fun isSameType(other: Action): Boolean = when (this) { + is Timeout -> other is Timeout + is SharedTimeout -> other is SharedTimeout + is Followers -> other is Followers + is Slow -> other is Slow + else -> this == other + } + + data class Timeout( + val duration: TextResource, + ) : Action + + data object Untimeout : Action + + data object Ban : Action + + data object Unban : Action + + data object Mod : Action + + data object Unmod : Action + + data object Clear : Action + + data object Delete : Action + + data object Vip : Action + + data object Unvip : Action + + data object Warn : Action + + data object Raid : Action + + data object Unraid : Action + + data object EmoteOnly : Action + + data object EmoteOnlyOff : Action + + data class Followers( + val durationMinutes: Int? = null, + ) : Action + + data object FollowersOff : Action + + data object UniqueChat : Action + + data object UniqueChatOff : Action + + data class Slow( + val durationSeconds: Int? = null, + ) : Action + + data object SlowOff : Action + + data object Subscribers : Action + + data object SubscribersOff : Action + + data object SharedBan : Action + + data object SharedUnban : Action + + data class SharedTimeout( + val duration: TextResource, + ) : Action + + data object SharedUntimeout : Action + + data object SharedDelete : Action + + data object AddBlockedTerm : Action + + data object AddPermittedTerm : Action + + data object RemoveBlockedTerm : Action + + data object RemovePermittedTerm : Action } - private val durationOrBlank get() = duration?.let { " for $it" }.orEmpty() - private val quotedReasonOrBlank get() = reason.takeUnless { it.isNullOrBlank() }?.let { ": \"$it\"" }.orEmpty() - private val reasonsOrBlank get() = reason.takeUnless { it.isNullOrBlank() }?.let { ": $it" }.orEmpty() - private fun getTrimmedReasonOrBlank(showDeletedMessage: Boolean): String { - if (!showDeletedMessage) return "" + private val hasReason get() = !reason.isNullOrBlank() + private val quotedTermsOrBlank get() = reason.takeUnless { it.isNullOrBlank() } ?: "terms" + private fun trimmedMessage(showDeletedMessage: Boolean): String? { + if (!showDeletedMessage) return null val fullReason = reason.orEmpty() - val trimmed = when { + return when { fullReason.length > 50 -> "${fullReason.take(50)}…" - else -> fullReason - } - return " saying: \"$trimmed\"" + else -> fullReason + }.takeIf { it.isNotEmpty() } } - private val creatorOrBlank get() = creatorUserDisplay?.let { " by $it" }.orEmpty() - private val countOrBlank - get() = when { - stackCount > 1 -> " ($stackCount times)" - else -> "" - } + private fun countSuffix(): TextResource = when { + stackCount > 1 -> TextResource.PluralRes(R.plurals.mod_count_suffix, stackCount, persistentListOf(stackCount)) + else -> TextResource.Plain("") + } + + fun getSystemMessage( + currentUser: UserName?, + showDeletedMessage: Boolean, + ): TextResource { + val creator = creatorUserDisplay.toString() + val target = targetUserDisplay.toString() + val source = sourceBroadcasterDisplay.toString() + + val message = + when (action) { + is Action.Timeout -> { + val dur = action.duration + when (targetUser) { + currentUser -> { + when (creatorUserDisplay) { + null -> { + TextResource.Res(R.string.mod_timeout_self_irc, persistentListOf(dur)) + } + + else -> { + when { + hasReason -> TextResource.Res(R.string.mod_timeout_self_reason, persistentListOf(dur, creator, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_timeout_self, persistentListOf(dur, creator)) + } + } + } + } + + else -> { + when (creatorUserDisplay) { + null -> { + TextResource.Res(R.string.mod_timeout_no_creator, persistentListOf(target, dur)) + } + + else -> { + when { + hasReason -> TextResource.Res(R.string.mod_timeout_by_creator_reason, persistentListOf(creator, target, dur, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_timeout_by_creator, persistentListOf(creator, target, dur)) + } + } + } + } + } + } + + Action.Untimeout -> { + TextResource.Res(R.string.mod_untimeout, persistentListOf(creator, target)) + } + + Action.Ban -> { + when (targetUser) { + currentUser -> { + when (creatorUserDisplay) { + null -> { + TextResource.Res(R.string.mod_ban_self_irc) + } + + else -> { + when { + hasReason -> TextResource.Res(R.string.mod_ban_self_reason, persistentListOf(creator, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_ban_self, persistentListOf(creator)) + } + } + } + } + + else -> { + when (creatorUserDisplay) { + null -> { + TextResource.Res(R.string.mod_ban_no_creator, persistentListOf(target)) + } + + else -> { + when { + hasReason -> TextResource.Res(R.string.mod_ban_by_creator_reason, persistentListOf(creator, target, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_ban_by_creator, persistentListOf(creator, target)) + } + } + } + } + } + } + + Action.Unban -> { + TextResource.Res(R.string.mod_unban, persistentListOf(creator, target)) + } + + Action.Mod -> { + TextResource.Res(R.string.mod_modded, persistentListOf(creator, target)) + } + + Action.Unmod -> { + TextResource.Res(R.string.mod_unmodded, persistentListOf(creator, target)) + } + + Action.Delete -> { + val msg = trimmedMessage(showDeletedMessage) + when (creatorUserDisplay) { + null -> { + when (msg) { + null -> TextResource.Res(R.string.mod_delete_no_creator, persistentListOf(target)) + else -> TextResource.Res(R.string.mod_delete_no_creator_message, persistentListOf(target, msg)) + } + } + + else -> { + when (msg) { + null -> TextResource.Res(R.string.mod_delete_by_creator, persistentListOf(creator, target)) + else -> TextResource.Res(R.string.mod_delete_by_creator_message, persistentListOf(creator, target, msg)) + } + } + } + } + + Action.Clear -> { + when (creatorUserDisplay) { + null -> TextResource.Res(R.string.mod_clear_no_creator) + else -> TextResource.Res(R.string.mod_clear_by_creator, persistentListOf(creator)) + } + } + + Action.Vip -> { + TextResource.Res(R.string.mod_vip_added, persistentListOf(creator, target)) + } + + Action.Unvip -> { + TextResource.Res(R.string.mod_vip_removed, persistentListOf(creator, target)) + } + + Action.Warn -> { + when { + hasReason -> TextResource.Res(R.string.mod_warn_reason, persistentListOf(creator, target, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_warn, persistentListOf(creator, target)) + } + } + + Action.Raid -> { + TextResource.Res(R.string.mod_raid, persistentListOf(creator, target)) + } + + Action.Unraid -> { + TextResource.Res(R.string.mod_unraid, persistentListOf(creator, target)) + } + + Action.EmoteOnly -> { + TextResource.Res(R.string.mod_emote_only_on, persistentListOf(creator)) + } + + Action.EmoteOnlyOff -> { + TextResource.Res(R.string.mod_emote_only_off, persistentListOf(creator)) + } + + is Action.Followers -> { + when (val mins = action.durationMinutes?.takeIf { it > 0 }) { + null -> TextResource.Res(R.string.mod_followers_on, persistentListOf(creator)) + else -> TextResource.Res(R.string.mod_followers_on_duration, persistentListOf(creator, formatMinutesDuration(mins))) + } + } + + Action.FollowersOff -> { + TextResource.Res(R.string.mod_followers_off, persistentListOf(creator)) + } + + Action.UniqueChat -> { + TextResource.Res(R.string.mod_unique_chat_on, persistentListOf(creator)) + } + + Action.UniqueChatOff -> { + TextResource.Res(R.string.mod_unique_chat_off, persistentListOf(creator)) + } + + is Action.Slow -> { + when (val secs = action.durationSeconds) { + null -> TextResource.Res(R.string.mod_slow_on, persistentListOf(creator)) + else -> TextResource.Res(R.string.mod_slow_on_duration, persistentListOf(creator, formatSecondsDuration(secs))) + } + } + + Action.SlowOff -> { + TextResource.Res(R.string.mod_slow_off, persistentListOf(creator)) + } + + Action.Subscribers -> { + TextResource.Res(R.string.mod_subscribers_on, persistentListOf(creator)) + } + + Action.SubscribersOff -> { + TextResource.Res(R.string.mod_subscribers_off, persistentListOf(creator)) + } + + is Action.SharedTimeout -> { + val dur = action.duration + when { + hasReason -> TextResource.Res(R.string.mod_shared_timeout_reason, persistentListOf(creator, target, dur, source, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_shared_timeout, persistentListOf(creator, target, dur, source)) + } + } + + Action.SharedUntimeout -> { + TextResource.Res(R.string.mod_shared_untimeout, persistentListOf(creator, target, source)) + } + + Action.SharedBan -> { + when { + hasReason -> TextResource.Res(R.string.mod_shared_ban_reason, persistentListOf(creator, target, source, reason.orEmpty())) + else -> TextResource.Res(R.string.mod_shared_ban, persistentListOf(creator, target, source)) + } + } + + Action.SharedUnban -> { + TextResource.Res(R.string.mod_shared_unban, persistentListOf(creator, target, source)) + } - // TODO localize - fun getSystemMessage(currentUser: UserName?, showDeletedMessage: Boolean): String { - return when (action) { - Action.Timeout -> when (targetUser) { - currentUser -> "You were timed out$durationOrBlank$creatorOrBlank$quotedReasonOrBlank.$countOrBlank" - else -> when (creatorUserDisplay) { - null -> "$targetUserDisplay has been timed out$durationOrBlank.$countOrBlank" // irc - else -> "$creatorUserDisplay timed out $targetUserDisplay$durationOrBlank.$countOrBlank" - } - } - - Action.Untimeout -> "$creatorUserDisplay untimedout $targetUserDisplay." - Action.Ban -> when (targetUser) { - currentUser -> "You were banned$creatorOrBlank$quotedReasonOrBlank." - else -> when (creatorUserDisplay) { - null -> "$targetUserDisplay has been permanently banned." // irc - else -> "$creatorUserDisplay banned $targetUserDisplay$quotedReasonOrBlank." - - } - } - - Action.Unban -> "$creatorUserDisplay unbanned $targetUserDisplay." - Action.Mod -> "$creatorUserDisplay modded $targetUserDisplay." - Action.Unmod -> "$creatorUserDisplay unmodded $targetUserDisplay." - Action.Delete -> when (creatorUserDisplay) { - null -> "A message from $targetUserDisplay was deleted${getTrimmedReasonOrBlank(showDeletedMessage)}." - else -> "$creatorUserDisplay deleted message from $targetUserDisplay${getTrimmedReasonOrBlank(showDeletedMessage)}." - } - - Action.Clear -> when (creatorUserDisplay) { - null -> "Chat has been cleared by a moderator." - else -> "$creatorUserDisplay cleared the chat." - } - - Action.Vip -> "$creatorUserDisplay has added $targetUserDisplay as a VIP of this channel." - Action.Unvip -> "$creatorUserDisplay has removed $targetUserDisplay as a VIP of this channel." - Action.Warn -> "$creatorUserDisplay has warned $targetUserDisplay${reasonsOrBlank.ifBlank { "." }}" - Action.Raid -> "$creatorUserDisplay initiated a raid to $targetUserDisplay." - Action.Unraid -> "$creatorUserDisplay canceled the raid to $targetUserDisplay." - Action.EmoteOnly -> "$creatorUserDisplay turned on emote-only mode." - Action.EmoteOnlyOff -> "$creatorUserDisplay turned off emote-only mode." - Action.Followers -> "$creatorUserDisplay turned on followers-only mode.${durationInt?.takeIf { it > 0 }?.let { " ($it minutes)" }.orEmpty()}" - Action.FollowersOff -> "$creatorUserDisplay turned off followers-only mode." - Action.UniqueChat -> "$creatorUserDisplay turned on unique-chat mode." - Action.UniqueChatOff -> "$creatorUserDisplay turned off unique-chat mode." - Action.Slow -> "$creatorUserDisplay turned on slow mode.${durationInt?.let { " ($it seconds)" }.orEmpty()}" - Action.SlowOff -> "$creatorUserDisplay turned off slow mode." - Action.Subscribers -> "$creatorUserDisplay turned on subscribers-only mode." - Action.SubscribersOff -> "$creatorUserDisplay turned off subscribers-only mode." - Action.SharedTimeout -> "$creatorUserDisplay timed out $targetUserDisplay$durationOrBlank in $sourceBroadcasterDisplay.$countOrBlank" - Action.SharedUntimeout -> "$creatorUserDisplay untimedout $targetUserDisplay in $sourceBroadcasterDisplay." - Action.SharedBan -> "$creatorUserDisplay banned $targetUserDisplay in $sourceBroadcasterDisplay$quotedReasonOrBlank." - Action.SharedUnban -> "$creatorUserDisplay unbanned $targetUserDisplay in $sourceBroadcasterDisplay." - Action.SharedDelete -> "$creatorUserDisplay deleted message from $targetUserDisplay in $sourceBroadcasterDisplay${getTrimmedReasonOrBlank(showDeletedMessage)}" + Action.SharedDelete -> { + when (val msg = trimmedMessage(showDeletedMessage)) { + null -> TextResource.Res(R.string.mod_shared_delete, persistentListOf(creator, target, source)) + else -> TextResource.Res(R.string.mod_shared_delete_message, persistentListOf(creator, target, source, msg)) + } + } + + Action.AddBlockedTerm -> { + TextResource.Res(R.string.automod_moderation_added_blocked_term, persistentListOf(creator, quotedTermsOrBlank)) + } + + Action.AddPermittedTerm -> { + TextResource.Res(R.string.automod_moderation_added_permitted_term, persistentListOf(creator, quotedTermsOrBlank)) + } + + Action.RemoveBlockedTerm -> { + TextResource.Res(R.string.automod_moderation_removed_blocked_term, persistentListOf(creator, quotedTermsOrBlank)) + } + + Action.RemovePermittedTerm -> { + TextResource.Res(R.string.automod_moderation_removed_permitted_term, persistentListOf(creator, quotedTermsOrBlank)) + } + } + + return when (val count = countSuffix()) { + is TextResource.Plain -> message + else -> TextResource.Res(R.string.mod_message_with_count, persistentListOf(message, count)) } } - val canClearMessages: Boolean = action in listOf(Action.Clear, Action.Ban, Action.Timeout, Action.SharedTimeout, Action.SharedBan) - val canStack: Boolean = canClearMessages && action != Action.Clear + val canClearMessages: Boolean = action is Action.Clear || action is Action.Ban || action is Action.Timeout || action is Action.SharedTimeout || action == Action.SharedBan + val canStack: Boolean = canClearMessages && action !is Action.Clear companion object { + fun formatMinutesDuration(minutes: Int): TextResource { + val parts = DateTimeUtils.decomposeMinutes(minutes).map { it.toTextResource() } + return joinDurationParts(parts, fallback = { TextResource.PluralRes(R.plurals.duration_minutes, 0, persistentListOf(0)) }) + } + + fun formatSecondsDuration(seconds: Int): TextResource { + val parts = DateTimeUtils.decomposeSeconds(seconds).map { it.toTextResource() } + return joinDurationParts(parts, fallback = { TextResource.PluralRes(R.plurals.duration_seconds, 0, persistentListOf(0)) }) + } + + private fun DateTimeUtils.DurationPart.toTextResource(): TextResource { + val pluralRes = + when (unit) { + DateTimeUtils.DurationUnit.WEEKS -> R.plurals.duration_weeks + DateTimeUtils.DurationUnit.DAYS -> R.plurals.duration_days + DateTimeUtils.DurationUnit.HOURS -> R.plurals.duration_hours + DateTimeUtils.DurationUnit.MINUTES -> R.plurals.duration_minutes + DateTimeUtils.DurationUnit.SECONDS -> R.plurals.duration_seconds + } + return TextResource.PluralRes(pluralRes, value, persistentListOf(value)) + } + + private fun joinDurationParts( + parts: List, + fallback: () -> TextResource, + ): TextResource = when (parts.size) { + 0 -> fallback() + 1 -> parts[0] + 2 -> TextResource.Res(R.string.duration_join_2, persistentListOf(parts[0], parts[1])) + else -> TextResource.Res(R.string.duration_join_3, persistentListOf(parts[0], parts[1], parts[2])) + } + fun parseClearChat(message: IrcMessage): ModerationMessage = with(message) { val channel = params[0].substring(1) val target = params.getOrNull(1) val durationSeconds = tags["ban-duration"]?.toIntOrNull() - val duration = durationSeconds?.let { DateTimeUtils.formatSeconds(it) } val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: UUID.randomUUID().toString() - val action = when { - target == null -> Action.Clear - durationSeconds == null -> Action.Ban - else -> Action.Timeout - } + val id = tags["id"] ?: "clearchat-$ts-$channel-${target ?: "all"}" + val action = + when { + target == null -> Action.Clear + durationSeconds == null -> Action.Ban + else -> Action.Timeout(duration = formatSecondsDuration(durationSeconds)) + } return ModerationMessage( timestamp = ts, @@ -166,9 +432,7 @@ data class ModerationMessage( action = action, targetUserDisplay = target?.toDisplayName(), targetUser = target?.toUserName(), - durationInt = durationSeconds, - duration = duration, - stackCount = if (target != null && duration != null) 1 else 0, + stackCount = if (target != null && action is Action.Timeout) 1 else 0, fromEventSource = false, ) } @@ -179,7 +443,7 @@ data class ModerationMessage( val targetMsgId = tags["target-msg-id"] val reason = params.getOrNull(1) val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val id = tags["id"] ?: UUID.randomUUID().toString() + val id = tags["id"] ?: "clearmsg-$ts-$channel-${target.orEmpty()}" return ModerationMessage( timestamp = ts, @@ -194,167 +458,289 @@ data class ModerationMessage( ) } - fun parseModerationAction(timestamp: Instant, channel: UserName, data: ModerationActionData): ModerationMessage { + fun parseModerationAction( + timestamp: Instant, + channel: UserName, + data: ModerationActionData, + ): ModerationMessage { val seconds = data.args?.getOrNull(1)?.toIntOrNull() - val duration = parseDuration(seconds, data) val targetUser = parseTargetUser(data) val targetMsgId = parseTargetMsgId(data) val reason = parseReason(data) val timeZone = TimeZone.currentSystemDefault() + val action = data.moderationAction.toAction(seconds) return ModerationMessage( timestamp = timestamp.toLocalDateTime(timeZone).toInstant(timeZone).toEpochMilliseconds(), id = data.msgId ?: UUID.randomUUID().toString(), channel = channel, - action = data.moderationAction.toAction(), + action = action, creatorUserDisplay = data.creator?.toDisplayName(), targetUser = targetUser, targetUserDisplay = targetUser?.toDisplayName(), targetMsgId = targetMsgId, - durationInt = seconds, - duration = duration, reason = reason, - stackCount = if (data.targetUserName != null && duration != null) 1 else 0, + stackCount = if (data.targetUserName != null && action is Action.Timeout) 1 else 0, fromEventSource = true, ) } - fun parseModerationAction(id: String, timestamp: Instant, channel: UserName, data: ChannelModerateDto): ModerationMessage { + fun parseModerationAction( + id: String, + timestamp: Instant, + channel: UserName, + data: ChannelModerateDto, + ): ModerationMessage { val timeZone = TimeZone.currentSystemDefault() val timestampMillis = timestamp.toLocalDateTime(timeZone).toInstant(timeZone).toEpochMilliseconds() - val duration = parseDuration(timestamp, data) - val formattedDuration = duration?.let { DateTimeUtils.formatSeconds(it) } val userPair = parseTargetUser(data) val targetMsgId = parseTargetMsgId(data) val reason = parseReason(data) + val action = data.action.toAction(timestamp, data) return ModerationMessage( timestamp = timestampMillis, id = id, channel = channel, - action = data.action.toAction(), + action = action, creatorUserDisplay = data.moderatorUserName, sourceBroadcasterDisplay = data.sourceBroadcasterUserName, targetUser = userPair?.first, targetUserDisplay = userPair?.second, targetMsgId = targetMsgId, - durationInt = duration, - duration = formattedDuration, reason = reason, fromEventSource = true, ) } - private fun parseDuration(seconds: Int?, data: ModerationActionData): String? = when (data.moderationAction) { - ModerationActionType.Timeout -> seconds?.let { DateTimeUtils.formatSeconds(seconds) } - else -> null - } - - private fun parseDuration(timestamp: Instant, data: ChannelModerateDto): Int? = when (data.action) { - ChannelModerateAction.Timeout -> data.timeout?.let { it.expiresAt.epochSeconds - timestamp.epochSeconds }?.toInt() + private fun parseDurationSeconds( + timestamp: Instant, + data: ChannelModerateDto, + ): Int? = when (data.action) { + ChannelModerateAction.Timeout -> data.timeout?.let { it.expiresAt.epochSeconds - timestamp.epochSeconds }?.toInt() ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.let { it.expiresAt.epochSeconds - timestamp.epochSeconds }?.toInt() - ChannelModerateAction.Followers -> data.followers?.followDurationMinutes - ChannelModerateAction.Slow -> data.slow?.waitTimeSeconds - else -> null + else -> null } private fun parseReason(data: ModerationActionData): String? = when (data.moderationAction) { ModerationActionType.Ban, - ModerationActionType.Delete -> data.args?.getOrNull(1) + ModerationActionType.Delete, + -> data.args?.getOrNull(1) ModerationActionType.Timeout -> data.args?.getOrNull(2) - else -> null + + else -> null } private fun parseReason(data: ChannelModerateDto): String? = when (data.action) { - ChannelModerateAction.Ban -> data.ban?.reason - ChannelModerateAction.Delete -> data.delete?.messageBody - ChannelModerateAction.Timeout -> data.timeout?.reason - ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody + ChannelModerateAction.Ban -> data.ban?.reason + + ChannelModerateAction.Delete -> data.delete?.messageBody + + ChannelModerateAction.Timeout -> data.timeout?.reason + + ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.reason + + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageBody + ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.reason - ChannelModerateAction.Warn -> data.warn?.let { listOfNotNull(it.reason).plus(it.chatRulesCited.orEmpty()).joinToString() } - else -> null + + ChannelModerateAction.Warn -> data.warn?.let { listOfNotNull(it.reason).plus(it.chatRulesCited.orEmpty()).joinToString() } + + ChannelModerateAction.AddBlockedTerm, + ChannelModerateAction.AddPermittedTerm, + ChannelModerateAction.RemoveBlockedTerm, + ChannelModerateAction.RemovePermittedTerm, + -> data.automodTerms?.terms?.joinToString(" and ") { "\"$it\"" } + + else -> null } private fun parseTargetUser(data: ModerationActionData): UserName? = when (data.moderationAction) { ModerationActionType.Delete -> data.args?.getOrNull(0)?.toUserName() - else -> data.targetUserName + else -> data.targetUserName } private fun parseTargetUser(data: ChannelModerateDto): Pair? = when (data.action) { - ChannelModerateAction.Timeout -> data.timeout?.let { it.userLogin to it.userName } - ChannelModerateAction.Untimeout -> data.untimeout?.let { it.userLogin to it.userName } - ChannelModerateAction.Ban -> data.ban?.let { it.userLogin to it.userName } - ChannelModerateAction.Unban -> data.unban?.let { it.userLogin to it.userName } - ChannelModerateAction.Mod -> data.mod?.let { it.userLogin to it.userName } - ChannelModerateAction.Unmod -> data.unmod?.let { it.userLogin to it.userName } - ChannelModerateAction.Delete -> data.delete?.let { it.userLogin to it.userName } - ChannelModerateAction.Vip -> data.vip?.let { it.userLogin to it.userName } - ChannelModerateAction.Unvip -> data.unvip?.let { it.userLogin to it.userName } - ChannelModerateAction.Warn -> data.warn?.let { it.userLogin to it.userName } - ChannelModerateAction.Raid -> data.raid?.let { it.userLogin to it.userName } - ChannelModerateAction.Unraid -> data.unraid?.let { it.userLogin to it.userName } - ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.let { it.userLogin to it.userName } + ChannelModerateAction.Timeout -> data.timeout?.let { it.userLogin to it.userName } + ChannelModerateAction.Untimeout -> data.untimeout?.let { it.userLogin to it.userName } + ChannelModerateAction.Ban -> data.ban?.let { it.userLogin to it.userName } + ChannelModerateAction.Unban -> data.unban?.let { it.userLogin to it.userName } + ChannelModerateAction.Mod -> data.mod?.let { it.userLogin to it.userName } + ChannelModerateAction.Unmod -> data.unmod?.let { it.userLogin to it.userName } + ChannelModerateAction.Delete -> data.delete?.let { it.userLogin to it.userName } + ChannelModerateAction.Vip -> data.vip?.let { it.userLogin to it.userName } + ChannelModerateAction.Unvip -> data.unvip?.let { it.userLogin to it.userName } + ChannelModerateAction.Warn -> data.warn?.let { it.userLogin to it.userName } + ChannelModerateAction.Raid -> data.raid?.let { it.userLogin to it.userName } + ChannelModerateAction.Unraid -> data.unraid?.let { it.userLogin to it.userName } + ChannelModerateAction.SharedChatTimeout -> data.sharedChatTimeout?.let { it.userLogin to it.userName } ChannelModerateAction.SharedChatUntimeout -> data.sharedChatUntimeout?.let { it.userLogin to it.userName } - ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.let { it.userLogin to it.userName } - ChannelModerateAction.SharedChatUnban -> data.sharedChatUnban?.let { it.userLogin to it.userName } - ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.let { it.userLogin to it.userName } - else -> null + ChannelModerateAction.SharedChatBan -> data.sharedChatBan?.let { it.userLogin to it.userName } + ChannelModerateAction.SharedChatUnban -> data.sharedChatUnban?.let { it.userLogin to it.userName } + ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.let { it.userLogin to it.userName } + else -> null } private fun parseTargetMsgId(data: ModerationActionData): String? = when (data.moderationAction) { ModerationActionType.Delete -> data.args?.getOrNull(2) - else -> null + else -> null } private fun parseTargetMsgId(data: ChannelModerateDto): String? = when (data.action) { - ChannelModerateAction.Delete -> data.delete?.messageId + ChannelModerateAction.Delete -> data.delete?.messageId ChannelModerateAction.SharedChatDelete -> data.sharedChatDelete?.messageId - else -> null + else -> null } - private fun ModerationActionType.toAction() = when (this) { - ModerationActionType.Timeout -> Action.Timeout + private fun ModerationActionType.toAction(seconds: Int?) = when (this) { + ModerationActionType.Timeout -> Action.Timeout(duration = seconds?.let(::formatSecondsDuration) ?: TextResource.Plain("")) ModerationActionType.Untimeout -> Action.Untimeout - ModerationActionType.Ban -> Action.Ban - ModerationActionType.Unban -> Action.Unban - ModerationActionType.Mod -> Action.Mod - ModerationActionType.Unmod -> Action.Unmod - ModerationActionType.Clear -> Action.Clear - ModerationActionType.Delete -> Action.Delete + ModerationActionType.Ban -> Action.Ban + ModerationActionType.Unban -> Action.Unban + ModerationActionType.Mod -> Action.Mod + ModerationActionType.Unmod -> Action.Unmod + ModerationActionType.Clear -> Action.Clear + ModerationActionType.Delete -> Action.Delete } - private fun ChannelModerateAction.toAction() = when (this) { - ChannelModerateAction.Timeout -> Action.Timeout - ChannelModerateAction.Untimeout -> Action.Untimeout - ChannelModerateAction.Ban -> Action.Ban - ChannelModerateAction.Unban -> Action.Unban - ChannelModerateAction.Mod -> Action.Mod - ChannelModerateAction.Unmod -> Action.Unmod - ChannelModerateAction.Clear -> Action.Clear - ChannelModerateAction.Delete -> Action.Delete - ChannelModerateAction.Vip -> Action.Vip - ChannelModerateAction.Unvip -> Action.Unvip - ChannelModerateAction.Warn -> Action.Warn - ChannelModerateAction.Raid -> Action.Raid - ChannelModerateAction.Unraid -> Action.Unraid - ChannelModerateAction.EmoteOnly -> Action.EmoteOnly - ChannelModerateAction.EmoteOnlyOff -> Action.EmoteOnlyOff - ChannelModerateAction.Followers -> Action.Followers - ChannelModerateAction.FollowersOff -> Action.FollowersOff - ChannelModerateAction.UniqueChat -> Action.UniqueChat - ChannelModerateAction.UniqueChatOff -> Action.UniqueChatOff - ChannelModerateAction.Slow -> Action.Slow - ChannelModerateAction.SlowOff -> Action.SlowOff - ChannelModerateAction.Subscribers -> Action.Subscribers - ChannelModerateAction.SubscribersOff -> Action.SubscribersOff - ChannelModerateAction.SharedChatTimeout -> Action.SharedTimeout - ChannelModerateAction.SharedChatUntimeout -> Action.SharedUntimeout - ChannelModerateAction.SharedChatBan -> Action.SharedBan - ChannelModerateAction.SharedChatUnban -> Action.SharedUnban - ChannelModerateAction.SharedChatDelete -> Action.SharedDelete - else -> error("Unexpected moderation action $this") + private fun ChannelModerateAction.toAction( + timestamp: Instant, + data: ChannelModerateDto, + ): Action = when (this) { + ChannelModerateAction.Timeout -> { + val seconds = parseDurationSeconds(timestamp, data) + Action.Timeout(duration = seconds?.let(::formatSecondsDuration) ?: TextResource.Plain("")) + } + + ChannelModerateAction.Untimeout -> { + Action.Untimeout + } + + ChannelModerateAction.Ban -> { + Action.Ban + } + + ChannelModerateAction.Unban -> { + Action.Unban + } + + ChannelModerateAction.Mod -> { + Action.Mod + } + + ChannelModerateAction.Unmod -> { + Action.Unmod + } + + ChannelModerateAction.Clear -> { + Action.Clear + } + + ChannelModerateAction.Delete -> { + Action.Delete + } + + ChannelModerateAction.Vip -> { + Action.Vip + } + + ChannelModerateAction.Unvip -> { + Action.Unvip + } + + ChannelModerateAction.Warn -> { + Action.Warn + } + + ChannelModerateAction.Raid -> { + Action.Raid + } + + ChannelModerateAction.Unraid -> { + Action.Unraid + } + + ChannelModerateAction.EmoteOnly -> { + Action.EmoteOnly + } + + ChannelModerateAction.EmoteOnlyOff -> { + Action.EmoteOnlyOff + } + + ChannelModerateAction.Followers -> { + Action.Followers(durationMinutes = data.followers?.followDurationMinutes) + } + + ChannelModerateAction.FollowersOff -> { + Action.FollowersOff + } + + ChannelModerateAction.UniqueChat -> { + Action.UniqueChat + } + + ChannelModerateAction.UniqueChatOff -> { + Action.UniqueChatOff + } + + ChannelModerateAction.Slow -> { + Action.Slow(durationSeconds = data.slow?.waitTimeSeconds) + } + + ChannelModerateAction.SlowOff -> { + Action.SlowOff + } + + ChannelModerateAction.Subscribers -> { + Action.Subscribers + } + + ChannelModerateAction.SubscribersOff -> { + Action.SubscribersOff + } + + ChannelModerateAction.SharedChatTimeout -> { + val seconds = parseDurationSeconds(timestamp, data) + Action.SharedTimeout(duration = seconds?.let(::formatSecondsDuration) ?: TextResource.Plain("")) + } + + ChannelModerateAction.SharedChatUntimeout -> { + Action.SharedUntimeout + } + + ChannelModerateAction.SharedChatBan -> { + Action.SharedBan + } + + ChannelModerateAction.SharedChatUnban -> { + Action.SharedUnban + } + + ChannelModerateAction.SharedChatDelete -> { + Action.SharedDelete + } + + ChannelModerateAction.AddBlockedTerm -> { + Action.AddBlockedTerm + } + + ChannelModerateAction.AddPermittedTerm -> { + Action.AddPermittedTerm + } + + ChannelModerateAction.RemoveBlockedTerm -> { + Action.RemoveBlockedTerm + } + + ChannelModerateAction.RemovePermittedTerm -> { + Action.RemovePermittedTerm + } + + else -> { + error("Unexpected moderation action $this") + } } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/NoticeMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/NoticeMessage.kt index bbf47a5bf..870c840c3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/NoticeMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/NoticeMessage.kt @@ -11,22 +11,27 @@ data class NoticeMessage( override val id: String = UUID.randomUUID().toString(), override val highlights: Set = emptySet(), val channel: UserName, - val message: String -) : Message() { + val message: String, +) : Message { companion object { fun parseNotice(message: IrcMessage): NoticeMessage = with(message) { val channel = params[0].substring(1) - val notice = when { - tags["msg-id"] == "msg_timedout" -> params[1] - .split(" ") - .getOrNull(index = 5) - ?.toIntOrNull() - ?.let { - "You are timed out for ${DateTimeUtils.formatSeconds(it)}." - } ?: params[1] + val notice = + when { + tags["msg-id"] == "msg_timedout" -> { + params[1] + .split(" ") + .getOrNull(index = 5) + ?.toIntOrNull() + ?.let { + "You are timed out for ${DateTimeUtils.formatSeconds(it)}." + } ?: params[1] + } - else -> params[1] - } + else -> { + params[1] + } + } val ts = tags["rm-received-ts"]?.toLongOrNull() ?: System.currentTimeMillis() val id = tags["id"] ?: UUID.randomUUID().toString() @@ -39,18 +44,19 @@ data class NoticeMessage( ) } - val ROOM_STATE_CHANGE_MSG_IDS = listOf( - "followers_on_zero", - "followers_on", - "followers_off", - "emote_only_on", - "emote_only_off", - "r9k_on", - "r9k_off", - "subs_on", - "subs_off", - "slow_on", - "slow_off", - ) + val ROOM_STATE_CHANGE_MSG_IDS = + listOf( + "followers_on_zero", + "followers_on", + "followers_off", + "emote_only_on", + "emote_only_off", + "r9k_on", + "r9k_off", + "subs_on", + "subs_off", + "slow_on", + "slow_off", + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PointRedemptionMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PointRedemptionMessage.kt index a56c794af..b0c773bca 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PointRedemptionMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PointRedemptionMessage.kt @@ -20,19 +20,25 @@ data class PointRedemptionMessage( val cost: Int, val requiresUserInput: Boolean, val userDisplay: UserDisplay? = null, -) : Message() { +) : Message { companion object { - fun parsePointReward(timestamp: Instant, data: PointRedemptionData): PointRedemptionMessage { + fun parsePointReward( + timestamp: Instant, + data: PointRedemptionData, + ): PointRedemptionMessage { val timeZone = TimeZone.currentSystemDefault() return PointRedemptionMessage( timestamp = timestamp.toLocalDateTime(timeZone).toInstant(timeZone).toEpochMilliseconds(), id = data.id, name = data.user.name, displayName = data.user.displayName, - title = data.reward.title, - rewardImageUrl = data.reward.images?.imageLarge - ?: data.reward.defaultImages.imageLarge, - cost = data.reward.cost, + title = data.reward.effectiveTitle, + rewardImageUrl = + data.reward.images?.imageLarge + ?: data.reward.defaultImages + ?.imageLarge + .orEmpty(), + cost = data.reward.effectiveCost, requiresUserInput = data.reward.requiresUserInput, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PrivMessage.kt index ce32ef652..142dae860 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PrivMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/PrivMessage.kt @@ -1,7 +1,6 @@ package com.flxrs.dankchat.data.twitch.message import android.graphics.Color -import androidx.annotation.ColorInt import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -11,7 +10,8 @@ import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.utils.extensions.normalizeColor +import com.flxrs.dankchat.data.twitch.message.Message.Companion.parseEmoteTag +import java.util.Locale import java.util.UUID data class PrivMessage( @@ -23,7 +23,7 @@ data class PrivMessage( val userId: UserId? = null, val name: UserName, val displayName: DisplayName, - val color: Int = DEFAULT_COLOR, + val color: Int? = null, val message: String, val originalMessage: String = message, val emotes: List = emptyList(), @@ -34,41 +34,52 @@ data class PrivMessage( val userDisplay: UserDisplay? = null, val thread: MessageThreadHeader? = null, val replyMentionOffset: Int = 0, - override val emoteData: EmoteData = EmoteData( - message = originalMessage, - channel = sourceChannel ?: channel, - emotesWithPositions = parseEmoteTag(originalMessage, tags["emotes"].orEmpty()), - ), - override val badgeData: BadgeData = BadgeData(userId, channel, badgeTag = tags["badges"], badgeInfoTag = tags["badge-info"]), -) : Message() { - + val rewardCost: Int? = null, + val rewardTitle: String? = null, + val rewardImageUrl: String? = null, + override val emoteData: Message.EmoteData = + Message.EmoteData( + message = originalMessage, + channel = sourceChannel ?: channel, + emotesWithPositions = parseEmoteTag(originalMessage, tags["emotes"].orEmpty()), + ), + override val badgeData: Message.BadgeData = Message.BadgeData(userId, channel, badgeTag = tags["badges"], badgeInfoTag = tags["badge-info"]), +) : Message { companion object { - fun parsePrivMessage(ircMessage: IrcMessage, findChannel: (UserId) -> UserName?): PrivMessage = with(ircMessage) { - val (name, id) = when (ircMessage.command) { - "USERNOTICE" -> tags.getValue("login") to (tags["id"]?.let { "$it-msg" } ?: UUID.randomUUID().toString()) - else -> prefix.substringBefore('!') to (tags["id"] ?: UUID.randomUUID().toString()) - } + fun parsePrivMessage( + ircMessage: IrcMessage, + findChannel: (UserId) -> UserName?, + ): PrivMessage = with(ircMessage) { + val (name, id) = + when (ircMessage.command) { + "USERNOTICE" -> tags.getValue("login") to (tags["id"]?.let { "$it-msg" } ?: UUID.randomUUID().toString()) + else -> prefix.substringBefore('!') to (tags["id"] ?: UUID.randomUUID().toString()) + } val displayName = tags["display-name"] ?: name - val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR + val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() var isAction = false val messageParam = params.getOrElse(1) { "" } - val message = when { - params.size > 1 && messageParam.startsWith("\u0001ACTION") && messageParam.endsWith("\u0001") -> { - isAction = true - messageParam.substring("\u0001ACTION ".length, messageParam.length - "\u0001".length) - } + val message = + when { + params.size > 1 && messageParam.startsWith("\u0001ACTION") && messageParam.endsWith("\u0001") -> { + isAction = true + messageParam.substring("\u0001ACTION ".length, messageParam.length - "\u0001".length) + } - else -> messageParam - } + else -> { + messageParam + } + } val channel = params[0].substring(1).toUserName() - val sourceChannel = tags["source-room-id"] - ?.takeIf { it.isNotEmpty() && it != tags["room-id"] } - ?.toUserId() - ?.let(findChannel) + val sourceChannel = + tags["source-room-id"] + ?.takeIf { it.isNotEmpty() && it != tags["room-id"] } + ?.toUserId() + ?.let(findChannel) return PrivMessage( timestamp = ts, @@ -89,13 +100,28 @@ data class PrivMessage( } val PrivMessage.isSub: Boolean - get() = tags["msg-id"] in UserNoticeMessage.USER_NOTICE_MSG_IDS_WITH_MESSAGE - "announcement" + get() = tags["msg-id"] in UserNoticeMessage.USER_NOTICE_MSG_IDS_WITH_MESSAGE - "announcement" - "viewermilestone" val PrivMessage.isAnnouncement: Boolean get() = tags["msg-id"] == "announcement" +val PrivMessage.isViewerMilestone: Boolean + get() = tags["msg-id"] == "viewermilestone" + val PrivMessage.isReward: Boolean - get() = tags["msg-id"] == "highlighted-message" || tags["custom-reward-id"] != null + get() = tags["msg-id"] in REWARD_MSG_IDS || !tags["custom-reward-id"].isNullOrEmpty() + +val PrivMessage.isGigantifiedEmote: Boolean + get() = tags["msg-id"] == "gigantified-emote-message" + +val PrivMessage.isAnimatedMessage: Boolean + get() = tags["msg-id"] == "animated-message" + +private val REWARD_MSG_IDS = setOf( + "highlighted-message", + "gigantified-emote-message", + "animated-message", +) val PrivMessage.isFirstMessage: Boolean get() = tags["first-msg"] == "1" @@ -103,8 +129,30 @@ val PrivMessage.isFirstMessage: Boolean val PrivMessage.isElevatedMessage: Boolean get() = tags["pinned-chat-paid-amount"] != null +val PrivMessage.hypeChatInfo: String? + get() { + val amount = tags["pinned-chat-paid-amount"]?.toLongOrNull() ?: return null + val exponent = tags["pinned-chat-paid-exponent"]?.toIntOrNull() ?: 0 + val currency = tags["pinned-chat-paid-currency"] ?: return null + val level = tags["pinned-chat-paid-level"]?.let { HYPE_CHAT_LEVELS[it] } ?: return null + val divisor = Math.pow(10.0, exponent.toDouble()) + val formatted = "%.2f".format(Locale.getDefault(), amount / divisor) + return "Hype Chat Level $level — $formatted $currency" + } + +private val HYPE_CHAT_LEVELS = mapOf( + "ONE" to 1, + "TWO" to 2, + "THREE" to 3, + "FOUR" to 4, + "FIVE" to 5, + "SIX" to 6, + "SEVEN" to 7, + "EIGHT" to 8, + "NINE" to 9, + "TEN" to 10, +) + /** format name for display in chat */ val PrivMessage.aliasOrFormattedName: String get() = userDisplay?.alias ?: name.formatWithDisplayName(displayName) - -fun PrivMessage.customOrUserColorOn(@ColorInt bgColor: Int): Int = userDisplay?.color ?: color.normalizeColor(bgColor) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomState.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomState.kt index 901abaa87..c8ec8707b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomState.kt @@ -1,25 +1,29 @@ package com.flxrs.dankchat.data.twitch.message +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.R import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.irc.IrcMessage +import com.flxrs.dankchat.utils.DateTimeUtils +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +@Immutable data class RoomState( val channel: UserName, val channelId: UserId, - val tags: Map = mapOf( - RoomStateTag.EMOTE to 0, - RoomStateTag.SUBS to 0, - RoomStateTag.SLOW to 0, - RoomStateTag.R9K to 0, - RoomStateTag.FOLLOW to -1, - ) + val tags: Map = + mapOf( + RoomStateTag.EMOTE to 0, + RoomStateTag.SUBS to 0, + RoomStateTag.SLOW to 0, + RoomStateTag.R9K to 0, + RoomStateTag.FOLLOW to -1, + ), ) { - val activeStates: BooleanArray - get() = tags.entries.map { (tag, value) -> - if (tag == RoomStateTag.FOLLOW) value >= 0 else value > 0 - }.toBooleanArray() - val isEmoteMode get() = tags.getOrDefault(RoomStateTag.EMOTE, 0) > 0 val isSubscriberMode get() = tags.getOrDefault(RoomStateTag.SUBS, 0) > 0 val isSlowMode get() = tags.getOrDefault(RoomStateTag.SLOW, 0) > 0 @@ -29,19 +33,62 @@ data class RoomState( val followerModeDuration get() = tags[RoomStateTag.FOLLOW]?.takeIf { it >= 0 } val slowModeWaitTime get() = tags[RoomStateTag.SLOW]?.takeIf { it > 0 } - fun toDisplayText(): String = tags + fun toDebugText(): String = tags .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } - .map { - when (it.key) { - RoomStateTag.FOLLOW -> if (it.value == 0) "follow" else "follow(${it.value})" - RoomStateTag.SLOW -> "slow(${it.value})" - else -> it.key.name.lowercase() + .map { (tag, value) -> + when (tag) { + RoomStateTag.FOLLOW -> { + when (value) { + 0 -> "follow" + else -> "follow(${DateTimeUtils.formatSeconds(value * 60)})" + } + } + + RoomStateTag.SLOW -> { + "slow(${DateTimeUtils.formatSeconds(value)})" + } + + else -> { + tag.name.lowercase() + } } }.joinToString() + fun toDisplayTextResources(): ImmutableList = tags + .filter { (it.key == RoomStateTag.FOLLOW && it.value >= 0) || it.value > 0 } + .map { (tag, value) -> + when (tag) { + RoomStateTag.EMOTE -> { + TextResource.Res(R.string.room_state_emote_only) + } + + RoomStateTag.SUBS -> { + TextResource.Res(R.string.room_state_subscriber_only) + } + + RoomStateTag.R9K -> { + TextResource.Res(R.string.room_state_unique_chat) + } + + RoomStateTag.SLOW -> { + TextResource.Res(R.string.room_state_slow_mode_duration, persistentListOf(DateTimeUtils.formatSeconds(value))) + } + + RoomStateTag.FOLLOW -> { + when (value) { + 0 -> TextResource.Res(R.string.room_state_follower_only) + else -> TextResource.Res(R.string.room_state_follower_only_duration, persistentListOf(DateTimeUtils.formatSeconds(value * 60))) + } + } + } + }.toImmutableList() + fun copyFromIrcMessage(msg: IrcMessage): RoomState = copy( tags = tags.mapValues { (key, value) -> msg.getRoomStateTag(key, value) }, ) - private fun IrcMessage.getRoomStateTag(tag: RoomStateTag, default: Int): Int = tags[tag.ircTag]?.toIntOrNull() ?: default + private fun IrcMessage.getRoomStateTag( + tag: RoomStateTag, + default: Int, + ): Int = tags[tag.ircTag]?.toIntOrNull() ?: default } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomStateTag.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomStateTag.kt index aa5290d1b..ef108e8e2 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomStateTag.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/RoomStateTag.kt @@ -5,14 +5,16 @@ enum class RoomStateTag { FOLLOW, R9K, SLOW, - SUBS; + SUBS, + ; val ircTag: String - get() = when (this) { - EMOTE -> "emote-only" - FOLLOW -> "followers-only" - R9K -> "r9k" - SLOW -> "slow" - SUBS -> "subs-only" - } + get() = + when (this) { + EMOTE -> "emote-only" + FOLLOW -> "followers-only" + R9K -> "r9k" + SLOW -> "slow" + SUBS -> "subs-only" + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessage.kt index c5f3700a1..2a1ab3f7a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessage.kt @@ -7,4 +7,4 @@ data class SystemMessage( override val timestamp: Long = System.currentTimeMillis(), override val id: String = UUID.randomUUID().toString(), override val highlights: Set = emptySet(), -) : Message() \ No newline at end of file +) : Message diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt index 23868625d..a7cdeff11 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/SystemMessageType.kt @@ -1,28 +1,104 @@ package com.flxrs.dankchat.data.twitch.message -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatImportance +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.utils.TextResource sealed interface SystemMessageType { data object Connected : SystemMessageType + data object Disconnected : SystemMessageType + data object Reconnected : SystemMessageType - data object NoHistoryLoaded : SystemMessageType + data object LoginExpired : SystemMessageType + data object MessageHistoryIncomplete : SystemMessageType + data object MessageHistoryIgnored : SystemMessageType - data class MessageHistoryUnavailable(val status: String?) : SystemMessageType - data class ChannelNonExistent(val channel: UserName) : SystemMessageType - data class ChannelFFZEmotesFailed(val status: String) : SystemMessageType - data class ChannelBTTVEmotesFailed(val status: String) : SystemMessageType - data class ChannelSevenTVEmotesFailed(val status: String) : SystemMessageType - data class ChannelSevenTVEmoteSetChanged(val actorName: DisplayName, val newEmoteSetName: String) : SystemMessageType - data class ChannelSevenTVEmoteAdded(val actorName: DisplayName, val emoteName: String) : SystemMessageType - data class ChannelSevenTVEmoteRenamed(val actorName: DisplayName, val oldEmoteName: String, val emoteName: String) : SystemMessageType - data class ChannelSevenTVEmoteRemoved(val actorName: DisplayName, val emoteName: String) : SystemMessageType - data class Custom(val message: String) : SystemMessageType + + data class MessageHistoryUnavailable( + val status: String?, + ) : SystemMessageType + + data class ChannelNonExistent( + val channel: UserName, + ) : SystemMessageType + + data class ChannelFFZEmotesFailed( + val status: String, + ) : SystemMessageType + + data class ChannelBTTVEmotesFailed( + val status: String, + ) : SystemMessageType + + data class ChannelSevenTVEmotesFailed( + val status: String, + ) : SystemMessageType + + data class ChannelSevenTVEmoteSetChanged( + val actorName: DisplayName, + val newEmoteSetName: String, + ) : SystemMessageType + + data class ChannelSevenTVEmoteAdded( + val actorName: DisplayName, + val emoteName: String, + ) : SystemMessageType + + data class ChannelSevenTVEmoteRenamed( + val actorName: DisplayName, + val oldEmoteName: String, + val emoteName: String, + ) : SystemMessageType + + data class ChannelSevenTVEmoteRemoved( + val actorName: DisplayName, + val emoteName: String, + ) : SystemMessageType + + data class Custom( + val message: TextResource, + ) : SystemMessageType + + data object SendNotLoggedIn : SystemMessageType + + data class SendChannelNotResolved( + val channel: UserName, + ) : SystemMessageType + + data object SendNotDelivered : SystemMessageType + + data class SendDropped( + val reason: String, + val code: String, + ) : SystemMessageType + + data object SendMissingScopes : SystemMessageType + + data object SendNotAuthorized : SystemMessageType + + data object SendMessageTooLarge : SystemMessageType + + data object SendRateLimited : SystemMessageType + + data class SendFailed( + val message: String?, + ) : SystemMessageType + + data class Debug( + val message: String, + ) : SystemMessageType + + data class AutomodActionFailed( + val statusCode: Int?, + val allow: Boolean, + ) : SystemMessageType } fun SystemMessageType.toChatItem() = ChatItem(SystemMessage(this), importance = ChatImportance.SYSTEM) + +fun SystemMessageType.toDebugChatItem() = ChatItem(SystemMessage(this), importance = ChatImportance.DELETED) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserDisplay.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserDisplay.kt index 9c1b1f9c8..f7f125853 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserDisplay.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserDisplay.kt @@ -1,17 +1,14 @@ package com.flxrs.dankchat.data.twitch.message -import androidx.annotation.ColorInt import com.flxrs.dankchat.data.database.entity.UserDisplayEntity /** represent final effect UserDisplay (after considering enabled/disabled states) */ -data class UserDisplay(val alias: String?, val color: Int?) +data class UserDisplay( + val alias: String?, + val color: Int?, +) fun UserDisplayEntity.toUserDisplay() = UserDisplay( alias = alias?.takeIf { enabled && aliasEnabled && it.isNotBlank() }, color = color.takeIf { enabled && colorEnabled }, ) - -@ColorInt -fun UserDisplay?.colorOrElse(@ColorInt fallback: Int): Int = this?.color ?: fallback - - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserNoticeMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserNoticeMessage.kt index 9b4e03523..9fe0f94a5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserNoticeMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/UserNoticeMessage.kt @@ -14,26 +14,31 @@ data class UserNoticeMessage( val message: String, val childMessage: PrivMessage?, val tags: Map, -) : Message() { - - override val emoteData: EmoteData? = childMessage?.emoteData - override val badgeData: BadgeData? = childMessage?.badgeData +) : Message { + override val emoteData: Message.EmoteData? = childMessage?.emoteData + override val badgeData: Message.BadgeData? = childMessage?.badgeData companion object { - val USER_NOTICE_MSG_IDS_WITH_MESSAGE = listOf( - "sub", - "subgift", - "resub", - "bitsbadgetier", - "ritual", - "announcement" - ) + val USER_NOTICE_MSG_IDS_WITH_MESSAGE = + listOf( + "sub", + "subgift", + "resub", + "bitsbadgetier", + "ritual", + "announcement", + "viewermilestone", + ) - fun parseUserNotice(message: IrcMessage, findChannel: (UserId) -> UserName?, historic: Boolean = false): UserNoticeMessage? = with(message) { + fun parseUserNotice( + message: IrcMessage, + findChannel: (UserId) -> UserName?, + historic: Boolean = false, + ): UserNoticeMessage? = with(message) { var msgId = tags["msg-id"] var mirrored = msgId == "sharedchatnotice" if (mirrored) { - msgId = tags["source-msg-id"] + msgId = tags["source-msg-id"] } else { val roomId = tags["room-id"] val sourceRoomId = tags["source-room-id"] @@ -48,27 +53,37 @@ data class UserNoticeMessage( val id = tags["id"] ?: UUID.randomUUID().toString() val channel = params[0].substring(1) - val defaultMessage = tags["system-msg"] ?: "" - val systemMsg = when { - msgId == "announcement" -> "Announcement" - msgId == "bitsbadgetier" -> { - val displayName = tags["display-name"] - val bitAmount = tags["msg-param-threshold"] - when { - displayName != null && bitAmount != null -> "$displayName just earned a new ${bitAmount.toInt() / 1000}K Bits badge!" - else -> defaultMessage + val defaultMessage = tags["system-msg"].orEmpty() + val systemMsg = + when { + msgId == "announcement" -> { + "Announcement" } - } - historic -> params[1] - else -> defaultMessage - } + msgId == "bitsbadgetier" -> { + val displayName = tags["display-name"] + val bitAmount = tags["msg-param-threshold"] + when { + displayName != null && bitAmount != null -> "$displayName just earned a new ${bitAmount.toInt() / 1000}K Bits badge!" + else -> defaultMessage + } + } + + historic -> { + params[1] + } + + else -> { + defaultMessage + } + } val ts = tags["tmi-sent-ts"]?.toLongOrNull() ?: System.currentTimeMillis() - val childMessage = when (msgId) { - in USER_NOTICE_MSG_IDS_WITH_MESSAGE -> PrivMessage.parsePrivMessage(message, findChannel) - else -> null - } + val childMessage = + when (msgId) { + in USER_NOTICE_MSG_IDS_WITH_MESSAGE -> PrivMessage.parsePrivMessage(message, findChannel) + else -> null + } return UserNoticeMessage( timestamp = ts, @@ -84,7 +99,10 @@ data class UserNoticeMessage( // TODO split into different user notice message types val UserNoticeMessage.isSub: Boolean - get() = tags["msg-id"] != "announcement" + get() = tags["msg-id"].let { it != "announcement" && it != "viewermilestone" } val UserNoticeMessage.isAnnouncement: Boolean get() = tags["msg-id"] == "announcement" + +val UserNoticeMessage.isViewerMilestone: Boolean + get() = tags["msg-id"] == "viewermilestone" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/WhisperMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/WhisperMessage.kt index 3c82edaaf..49af73524 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/WhisperMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/message/WhisperMessage.kt @@ -1,7 +1,6 @@ package com.flxrs.dankchat.data.twitch.message import android.graphics.Color -import androidx.annotation.ColorInt import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName @@ -11,8 +10,7 @@ import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.toUserName import com.flxrs.dankchat.data.twitch.badge.Badge import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.data.twitch.pubsub.dto.whisper.WhisperData -import com.flxrs.dankchat.utils.extensions.normalizeColor +import com.flxrs.dankchat.data.twitch.message.Message.Companion.parseEmoteTag import java.util.UUID data class WhisperMessage( @@ -22,11 +20,11 @@ data class WhisperMessage( val userId: UserId?, val name: UserName, val displayName: DisplayName, - val color: Int = DEFAULT_COLOR, + val color: Int? = null, val recipientId: UserId?, val recipientName: UserName, val recipientDisplayName: DisplayName, - val recipientColor: Int = DEFAULT_COLOR, + val recipientColor: Int? = null, val message: String, val rawEmotes: String, val rawBadges: String?, @@ -36,18 +34,22 @@ data class WhisperMessage( val badges: List = emptyList(), val userDisplay: UserDisplay? = null, val recipientDisplay: UserDisplay? = null, - override val emoteData: EmoteData = EmoteData(originalMessage, WHISPER_CHANNEL, parseEmoteTag(originalMessage, rawEmotes)), - override val badgeData: BadgeData = BadgeData(userId, channel = null, badgeTag = rawBadges, badgeInfoTag = rawBadgeInfo), -) : Message() { - + override val emoteData: Message.EmoteData = Message.EmoteData(originalMessage, WHISPER_CHANNEL, parseEmoteTag(originalMessage, rawEmotes)), + override val badgeData: Message.BadgeData = Message.BadgeData(userId, channel = null, badgeTag = rawBadges, badgeInfoTag = rawBadgeInfo), +) : Message { companion object { val WHISPER_CHANNEL = "w".toUserName() - fun parseFromIrc(ircMessage: IrcMessage, recipientName: DisplayName, recipientColorTag: String?): WhisperMessage = with(ircMessage) { + + fun parseFromIrc( + ircMessage: IrcMessage, + recipientName: DisplayName, + recipientColorTag: String?, + ): WhisperMessage = with(ircMessage) { val name = prefix.substringBefore('!') val displayName = tags["display-name"] ?: name - val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR - val recipientColor = recipientColorTag?.let(Color::parseColor) ?: DEFAULT_COLOR - val emoteTag = tags["emotes"] ?: "" + val color = tags["color"]?.ifBlank { null }?.let(Color::parseColor) + val recipientColor = recipientColorTag?.let(Color::parseColor) + val emoteTag = tags["emotes"].orEmpty() val message = params.getOrElse(1) { "" } return WhisperMessage( @@ -64,47 +66,14 @@ data class WhisperMessage( message = message, rawEmotes = emoteTag, rawBadges = tags["badges"], - rawBadgeInfo = tags["badge-info"] - ) - } - - fun fromPubSub(data: WhisperData): WhisperMessage = with(data) { - val color = data.tags.color.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR - val recipientColor = data.recipient.color.ifBlank { null }?.let(Color::parseColor) ?: DEFAULT_COLOR - val badgeTag = data.tags.badges.joinToString(",") { "${it.id}/${it.version}" } - val emotesTag = data.tags.emotes - .groupBy { it.id } - .entries - .joinToString("/") { entry -> - "${entry.key}:" + entry.value.joinToString(",") { "${it.start}-${it.end}" } - } - - return WhisperMessage( - timestamp = data.timestamp * 1_000L, // PubSub uses seconds instead of millis, nice - id = data.messageId, - userId = data.userId, - name = data.tags.name, - displayName = data.tags.displayName, - color = color, - recipientId = data.recipient.id, - recipientName = data.recipient.name, - recipientDisplayName = data.recipient.displayName, - recipientColor = recipientColor, - message = message, - rawEmotes = emotesTag, - rawBadges = badgeTag, + rawBadgeInfo = tags["badge-info"], ) } } - } val WhisperMessage.senderAliasOrFormattedName: String get() = userDisplay?.alias ?: name.formatWithDisplayName(displayName) -fun WhisperMessage.senderColorOnBackground(@ColorInt background: Int): Int = userDisplay.colorOrElse(color.normalizeColor(background)) - val WhisperMessage.recipientAliasOrFormattedName: String get() = recipientDisplay?.alias ?: recipientName.formatWithDisplayName(recipientDisplayName) - -fun WhisperMessage.recipientColorOnBackground(@ColorInt background: Int): Int = recipientDisplay.colorOrElse(recipientColor.normalizeColor(background)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt index 2f1f2851e..fc2052968 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubConnection.kt @@ -1,27 +1,33 @@ package com.flxrs.dankchat.data.twitch.pubsub -import android.util.Log -import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.ifBlank import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.data.twitch.pubsub.dto.PubSubDataMessage -import com.flxrs.dankchat.data.twitch.pubsub.dto.PubSubDataObjectMessage import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModerationActionData import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModerationActionType import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModeratorAddedData import com.flxrs.dankchat.data.twitch.pubsub.dto.redemption.PointRedemption -import com.flxrs.dankchat.data.twitch.pubsub.dto.whisper.WhisperData import com.flxrs.dankchat.utils.extensions.decodeOrNull import com.flxrs.dankchat.utils.extensions.timer -import io.ktor.http.HttpHeaders +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.add @@ -29,83 +35,132 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener import org.json.JSONObject import java.util.UUID import kotlin.random.Random import kotlin.random.nextLong import kotlin.time.Clock -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant +private val logger = KotlinLogging.logger("PubSubConnection") + +@OptIn(DelicateCoroutinesApi::class) class PubSubConnection( val tag: String, - private val client: OkHttpClient, + private val client: HttpClient, private val scope: CoroutineScope, private val oAuth: String, private val jsonFormat: Json, ) { - private var socket: WebSocket? = null - private val request = Request.Builder() - .url("wss://pubsub-edge.twitch.tv") - .header(HttpHeaders.UserAgent, "dankchat/${BuildConfig.VERSION_NAME}") - .build() + @Volatile + private var session: DefaultClientWebSocketSession? = null + private var connectionJob: Job? = null private val receiveChannel = Channel(capacity = Channel.BUFFERED) - private var connecting = false private var awaitingPong = false - private var reconnectAttempts = 1 - private val currentReconnectDelay: Duration - get() { - val jitter = randomJitter() - val reconnectDelay = RECONNECT_BASE_DELAY * (1 shl (reconnectAttempts - 1)) - reconnectAttempts = (reconnectAttempts + 1).coerceAtMost(RECONNECT_MAX_ATTEMPTS) - - return reconnectDelay + jitter - } private val topics = mutableSetOf() private lateinit var currentOAuth: String private val canAcceptTopics: Boolean get() = connected && topics.size < MAX_TOPICS - var connected = false - private set + val connected: Boolean + get() = session?.isActive == true && session?.incoming?.isClosedForReceive == false + val hasTopics: Boolean get() = topics.isNotEmpty() - val hasWhisperTopic: Boolean - get() = topics.any { it.topic.startsWith("whispers.") } - - fun hasModeratorTopic(userId: UserId, channelId: UserId): Boolean { - return topics.any { it.topic.startsWith("chat_moderator_actions.$userId.$channelId") } - } - - val events = receiveChannel.receiveAsFlow().distinctUntilChanged { old, new -> - (old.isDisconnected && new.isDisconnected) || old == new - } + val events = + receiveChannel.receiveAsFlow().distinctUntilChanged { old, new -> + (old.isDisconnected && new.isDisconnected) || old == new + } fun connect(initialTopics: Set): Set { - if (connected || connecting) { + if (connected || connectionJob?.isActive == true) { return initialTopics } currentOAuth = oAuth awaitingPong = false - connecting = true val (possibleTopics, remainingTopics) = initialTopics.splitAt(MAX_TOPICS) - socket = client.newWebSocket(request, PubSubWebSocketListener(possibleTopics)) topics.clear() topics.addAll(possibleTopics) + logger.info { "[PubSub $tag] connecting with ${possibleTopics.size} topics" } + connectionJob = + scope.launch { + var retryCount = 1 + while (retryCount <= RECONNECT_MAX_ATTEMPTS) { + var serverRequestedReconnect = false + try { + client.webSocket(PUBSUB_URL) { + session = this + retryCount = 1 + receiveChannel.trySend(PubSubEvent.Connected) + logger.info { "[PubSub $tag] connected" } + + possibleTopics + .toRequestMessages() + .forEach { send(Frame.Text(it)) } + logger.debug { "[PubSub $tag] sent LISTEN for ${possibleTopics.size} topics" } + + var pingJob: Job? = null + try { + pingJob = setupPingInterval() + + while (isActive) { + val result = incoming.receiveCatching() + val text = + when (val frame = result.getOrNull()) { + null -> { + val cause = result.exceptionOrNull() ?: return@webSocket + throw cause + } + + else -> { + (frame as? Frame.Text)?.readText() ?: continue + } + } + + serverRequestedReconnect = handleMessage(text) + if (serverRequestedReconnect) return@webSocket + } + } finally { + pingJob?.cancel() + } + } + + session = null + receiveChannel.trySend(PubSubEvent.Closed) + + if (!serverRequestedReconnect) { + logger.info { "[PubSub $tag] connection closed" } + return@launch + } + logger.info { "[PubSub $tag] reconnecting after server request" } + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + logger.error { "[PubSub $tag] connection failed: $t" } + logger.error { "[PubSub $tag] attempting to reconnect #$retryCount.." } + session = null + receiveChannel.trySend(PubSubEvent.Closed) + + val jitter = randomJitter() + val reconnectDelay = RECONNECT_BASE_DELAY * (1 shl (retryCount - 1)) + delay(reconnectDelay + jitter) + retryCount = (retryCount + 1).coerceAtMost(RECONNECT_MAX_ATTEMPTS) + } + } + + logger.error { "[PubSub $tag] connection failed after $RECONNECT_MAX_ATTEMPTS retries" } + session = null + } + return remainingTopics.toSet() } @@ -118,33 +173,48 @@ class PubSubConnection( val needsListen = (possibleTopics - topics) topics.addAll(needsListen) + if (needsListen.isNotEmpty()) { + logger.debug { "[PubSub $tag] listening to ${needsListen.size} new topics" } + } + val currentSession = session needsListen .toRequestMessages() - .forEach { socket?.send(it) } + .forEach { message -> + scope.launch { runCatching { currentSession?.send(Frame.Text(message)) } } + } return remainingTopics.toSet() } fun unlistenByChannel(channel: UserName) { - val toUnlisten = topics.filter { - it is PubSubTopic.PointRedemptions && it.channelName == channel || it is PubSubTopic.ModeratorActions && it.channelName == channel - } + val toUnlisten = + topics.filter { + (it is PubSubTopic.PointRedemptions && it.channelName == channel) || (it is PubSubTopic.ModeratorActions && it.channelName == channel) + } unlisten(toUnlisten.toSet()) } fun close() { - connected = false - socket?.close(1000, null) ?: socket?.cancel() - socket = null + val currentSession = session + session = null + connectionJob?.cancel() + scope.launch { + runCatching { + currentSession?.close() + currentSession?.cancel() + } + } } fun reconnect() { - reconnectAttempts = 1 - attemptReconnect() + logger.info { "[PubSub $tag] reconnecting" } + close() + connect(topics) } fun reconnectIfNecessary() { - if (connected || connecting) return + if (connected || connectionJob?.isActive == true) return + logger.info { "[PubSub $tag] connection lost, reconnecting" } reconnect() } @@ -152,26 +222,22 @@ class PubSubConnection( val foundTopics = topics.filter { it in toUnlisten }.toSet() topics.removeAll(foundTopics) + if (foundTopics.isNotEmpty()) { + logger.debug { "[PubSub $tag] unlistening from ${foundTopics.size} topics" } + } + val currentSession = session foundTopics .toRequestMessages(type = "UNLISTEN") .forEach { message -> - socket?.send(message) + scope.launch { runCatching { currentSession?.send(Frame.Text(message)) } } } } - private fun attemptReconnect() { - scope.launch { - delay(currentReconnectDelay) - close() - connect(topics) - } - } - private fun randomJitter() = Random.nextLong(range = 0L..MAX_JITTER).milliseconds private fun setupPingInterval() = scope.timer(interval = PING_INTERVAL - randomJitter()) { - val webSocket = socket - if (awaitingPong || webSocket == null) { + val currentSession = session + if (awaitingPong || currentSession?.isActive != true) { cancel() reconnect() return@timer @@ -179,139 +245,116 @@ class PubSubConnection( if (connected) { awaitingPong = true - webSocket.send(PING_PAYLOAD) + runCatching { currentSession.send(Frame.Text(PING_PAYLOAD)) } } } - private inner class PubSubWebSocketListener(private val initialTopics: Collection) : WebSocketListener() { - private var pingJob: Job? = null - - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - connected = false - pingJob?.cancel() - receiveChannel.trySend(PubSubEvent.Closed) - } - - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "[PubSub $tag] connection failed: $t") - Log.e(TAG, "[PubSub $tag] attempting to reconnect #${reconnectAttempts}..") - connected = false - connecting = false - pingJob?.cancel() - receiveChannel.trySend(PubSubEvent.Closed) - - attemptReconnect() - } - - override fun onOpen(webSocket: WebSocket, response: Response) { - connected = true - connecting = false - reconnectAttempts = 1 - receiveChannel.trySend(PubSubEvent.Connected) - Log.i(TAG, "[PubSub $tag] connected") - - initialTopics - .toRequestMessages() - .forEach(webSocket::send) - - pingJob = setupPingInterval() - } + /** + * Handles a PubSub message. Returns true if the server requested a reconnect. + */ + @Suppress("ReturnCount") + private fun handleMessage(text: String): Boolean { + val json = JSONObject(text) + val type = json.optString("type").ifBlank { return false } + when (type) { + "PONG" -> { + awaitingPong = false + } - override fun onMessage(webSocket: WebSocket, text: String) { - val json = JSONObject(text) - val type = json.optString("type").ifBlank { return } - when (type) { - "PONG" -> awaitingPong = false - "RECONNECT" -> reconnect() - "RESPONSE" -> {} - "MESSAGE" -> { - val data = json.optJSONObject("data") ?: return - val topic = data.optString("topic").ifBlank { return } - val message = data.optString("message").ifBlank { return } - val messageObject = JSONObject(message) - val messageTopic = messageObject.optString("type") - val match = topics.find { topic == it.topic } ?: return - val pubSubMessage = when (match) { - is PubSubTopic.Whispers -> { - if (messageTopic !in listOf("whisper_sent", "whisper_received")) { - return - } + "RECONNECT" -> { + logger.info { "[PubSub $tag] server requested reconnect" } + return true + } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return - PubSubMessage.Whisper(parsedMessage.data) - } + "RESPONSE" -> { + val error = json.optString("error") + if (error.isNotBlank()) { + logger.warn { "[PubSub $tag] RESPONSE error: $error" } + } + } + "MESSAGE" -> { + val data = json.optJSONObject("data") ?: return false + val topic = data.optString("topic").ifBlank { return false } + val message = data.optString("message").ifBlank { return false } + val messageObject = JSONObject(message) + val messageTopic = messageObject.optString("type") + val match = topics.find { topic == it.topic } ?: return false + val pubSubMessage = + when (match) { is PubSubTopic.PointRedemptions -> { - if (messageTopic != "reward-redeemed") { - return + if (messageTopic !in POINT_REDEMPTION_TOPICS) { + return false } - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false PubSubMessage.PointRedemption( timestamp = parsedMessage.data.timestamp, channelName = match.channelName, channelId = match.channelId, - data = parsedMessage.data.redemption + data = parsedMessage.data.redemption, ) } is PubSubTopic.ModeratorActions -> { when (messageTopic) { - "moderator_added" -> { - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return + "moderator_added" -> { + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false val timestamp = Clock.System.now() PubSubMessage.ModeratorAction( timestamp = timestamp, channelId = parsedMessage.data.channelId, - data = ModerationActionData( - args = null, - targetUserId = parsedMessage.data.targetUserId, - targetUserName = parsedMessage.data.targetUserName, - moderationAction = parsedMessage.data.moderationAction, - creatorUserId = parsedMessage.data.creatorUserId, - creator = parsedMessage.data.creator, - createdAt = timestamp.toString(), - msgId = null - ) + data = + ModerationActionData( + args = null, + targetUserId = parsedMessage.data.targetUserId, + targetUserName = parsedMessage.data.targetUserName, + moderationAction = parsedMessage.data.moderationAction, + creatorUserId = parsedMessage.data.creatorUserId, + creator = parsedMessage.data.creator, + createdAt = timestamp.toString(), + msgId = null, + ), ) } "moderation_action" -> { - val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return + val parsedMessage = jsonFormat.decodeOrNull>(message) ?: return false if (parsedMessage.data.moderationAction == ModerationActionType.Mod) { - return - } - val timestamp = when { - parsedMessage.data.createdAt.isEmpty() -> Clock.System.now() - else -> Instant.parse(parsedMessage.data.createdAt) + return false } + val timestamp = + when { + parsedMessage.data.createdAt.isEmpty() -> Clock.System.now() + else -> Instant.parse(parsedMessage.data.createdAt) + } PubSubMessage.ModeratorAction( timestamp = timestamp, channelId = topic.substringAfterLast('.').toUserId(), - data = parsedMessage.data.copy( - msgId = parsedMessage.data.msgId?.ifBlank { null }, - creator = parsedMessage.data.creator?.ifBlank { null }, - creatorUserId = parsedMessage.data.creatorUserId?.ifBlank { null }, - targetUserId = parsedMessage.data.targetUserId?.ifBlank { null }, - targetUserName = parsedMessage.data.targetUserName?.ifBlank { null }, - ) + data = + parsedMessage.data.copy( + msgId = parsedMessage.data.msgId?.ifBlank { null }, + creator = parsedMessage.data.creator?.ifBlank { null }, + creatorUserId = parsedMessage.data.creatorUserId?.ifBlank { null }, + targetUserId = parsedMessage.data.targetUserId?.ifBlank { null }, + targetUserName = parsedMessage.data.targetUserName?.ifBlank { null }, + ), ) } - else -> return + else -> { + return false + } } } } - receiveChannel.trySend(PubSubEvent.Message(pubSubMessage)) - } + receiveChannel.trySend(PubSubEvent.Message(pubSubMessage)) } - } + return false } - private fun Collection.splitAt(n: Int): Pair, Collection> { - return take(n) to drop(n) - } + private fun Collection.splitAt(n: Int): Pair, Collection> = take(n) to drop(n) private fun Collection.toRequestMessages(type: String = "LISTEN"): List { val (pointRewards, rest) = partition { it is PubSubTopic.PointRedemptions } @@ -321,20 +364,24 @@ class PubSubConnection( ) } - private fun Collection.toRequestMessage(type: String = "LISTEN", withAuth: Boolean = true): String { + private fun Collection.toRequestMessage( + type: String = "LISTEN", + withAuth: Boolean = true, + ): String { val nonce = UUID.randomUUID().toString() - val message = buildJsonObject { - put("type", type) - put("nonce", nonce) - putJsonObject("data") { - putJsonArray("topics") { - forEach { add(it.topic) } - } - if (withAuth) { - put("auth_token", currentOAuth) + val message = + buildJsonObject { + put("type", type) + put("nonce", nonce) + putJsonObject("data") { + putJsonArray("topics") { + forEach { add(it.topic) } + } + if (withAuth) { + put("auth_token", currentOAuth) + } } } - } return message.toString() } @@ -347,6 +394,7 @@ class PubSubConnection( private const val RECONNECT_MAX_ATTEMPTS = 6 private val PING_INTERVAL = 5.minutes private const val PING_PAYLOAD = "{\"type\":\"PING\"}" - private val TAG = PubSubConnection::class.java.simpleName + private const val PUBSUB_URL = "wss://pubsub-edge.twitch.tv" + private val POINT_REDEMPTION_TOPICS = setOf("reward-redeemed", "automatic-reward-redeemed") } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubEvent.kt index c754406af..c1e58a7bd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubEvent.kt @@ -1,9 +1,14 @@ package com.flxrs.dankchat.data.twitch.pubsub sealed interface PubSubEvent { - data class Message(val message: PubSubMessage) : PubSubEvent + data class Message( + val message: PubSubMessage, + ) : PubSubEvent + data object Connected : PubSubEvent + data object Error : PubSubEvent + data object Closed : PubSubEvent val isDisconnected: Boolean diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubManager.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubManager.kt index 738cffe8a..0ee865642 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubManager.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubManager.kt @@ -1,77 +1,79 @@ package com.flxrs.dankchat.data.twitch.pubsub import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.data.repo.channel.Channel import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.toUserId import com.flxrs.dankchat.di.DispatchersProvider -import com.flxrs.dankchat.di.WebSocketOkHttpClient -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import okhttp3.OkHttpClient -import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import kotlinx.coroutines.channels.Channel as CoroutineChannel + +private val logger = KotlinLogging.logger("PubSubManager") @Single class PubSubManager( private val channelRepository: ChannelRepository, + private val chatChannelProvider: ChatChannelProvider, private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val preferenceStore: DankChatPreferenceStore, - @Named(type = WebSocketOkHttpClient::class) private val client: OkHttpClient, + private val authDataStore: AuthDataStore, + private val startupValidationHolder: StartupValidationHolder, + httpClient: HttpClient, private val json: Json, dispatchersProvider: DispatchersProvider, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private val client = + httpClient.config { + install(WebSockets) + } private val connections = mutableListOf() private val collectJobs = mutableListOf() - private val receiveChannel = Channel(capacity = Channel.BUFFERED) + private val receiveChannel = CoroutineChannel(capacity = CoroutineChannel.BUFFERED) val messages = receiveChannel.receiveAsFlow().shareIn(scope, started = SharingStarted.Eagerly) val connected: Boolean get() = connections.any { it.connected } - val connectedAndHasWhisperTopic: Boolean - get() = connections.any { it.connected && it.hasWhisperTopic } - - fun start() { - if (!preferenceStore.isLoggedIn) { - return - } - - val userId = preferenceStore.userIdString ?: return - val channels = preferenceStore.channels - + init { scope.launch { - val usePubsub = developerSettingsDataStore.settings.first().shouldUsePubSub - val helixChannels = channelRepository.getChannels(channels) - val topics = buildSet { - when { - usePubsub -> { - add(PubSubTopic.Whispers(userId)) - helixChannels.forEach { - add(PubSubTopic.PointRedemptions(channelId = it.id, channelName = it.name)) - add(PubSubTopic.ModeratorActions(userId = userId, channelId = it.id, channelName = it.name)) - } - } - - else -> { - helixChannels.forEach { - add(PubSubTopic.PointRedemptions(channelId = it.id, channelName = it.name)) - } - } - + startupValidationHolder.awaitResolved() + combine( + authDataStore.settings.map { it.isLoggedIn to it.userId }.distinctUntilChanged(), + chatChannelProvider.channels.filterNotNull(), + developerSettingsDataStore.settings.map { it.shouldUsePubSub }.distinctUntilChanged(), + ) { (isLoggedIn, userId), channels, shouldUsePubSub -> + Triple(if (isLoggedIn) userId else null, channels, shouldUsePubSub) + }.collect { (userId, channels, shouldUsePubSub) -> + closeAll() + if (userId == null) { + logger.debug { "[PubSub] skipping connection, not logged in" } + return@collect } + val resolved = channelRepository.getChannels(channels) + val topics = buildTopics(userId, resolved, shouldUsePubSub) + logger.info { "[PubSub] rebuilding connections for ${resolved.size} channels, ${topics.size} topics (pubsub=$shouldUsePubSub)" } + listen(topics) } - listen(topics) } } @@ -79,36 +81,12 @@ class PubSubManager( fun reconnectIfNecessary() = resetCollectionWith { reconnectIfNecessary() } - fun close() = scope.launch { - collectJobs.forEach { it.cancel() } - collectJobs.clear() - connections.forEach { it.close() } - } - - fun addChannel(channel: UserName) = scope.launch { - if (!preferenceStore.isLoggedIn) { - return@launch - } - - val userId = preferenceStore.userIdString ?: return@launch - val channelId = channelRepository.getChannel(channel)?.id ?: return@launch - val usePubsub = developerSettingsDataStore.settings.first().shouldUsePubSub - - val topics = buildSet { - add(PubSubTopic.PointRedemptions(channelId, channel)) - - if (usePubsub) { - add(PubSubTopic.ModeratorActions(userId, channelId, channel)) - } - } - listen(topics) - } - fun removeChannel(channel: UserName) { - val emptyConnections = connections - .onEach { it.unlistenByChannel(channel) } - .filterNot { it.hasTopics } - .onEach { it.close() } + val emptyConnections = + connections + .onEach { it.unlistenByChannel(channel) } + .filterNot { it.hasTopics } + .onEach { it.close() } if (emptyConnections.isEmpty()) { return @@ -118,11 +96,26 @@ class PubSubManager( resetCollectionWith() } - private fun listen(topics: Set) { - val oAuth = preferenceStore.oAuthKey?.withoutOAuthPrefix ?: return - val remainingTopics = connections.fold(topics) { acc, conn -> - conn.listen(acc) + private fun buildTopics( + userId: String, + channels: List, + shouldUsePubSub: Boolean, + ): Set = buildSet { + val uid = userId.toUserId() + for (channel in channels) { + add(PubSubTopic.PointRedemptions(channelId = channel.id, channelName = channel.name)) + if (shouldUsePubSub) { + add(PubSubTopic.ModeratorActions(userId = uid, channelId = channel.id, channelName = channel.name)) + } } + } + + private fun listen(topics: Set) { + val oAuth = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return + val remainingTopics = + connections.fold(topics) { acc, conn -> + conn.listen(acc) + } if (remainingTopics.isEmpty() || connections.size >= PubSubConnection.MAX_CONNECTIONS) { return @@ -133,30 +126,39 @@ class PubSubManager( .chunked(PubSubConnection.MAX_TOPICS) .withIndex() .takeWhile { (idx, _) -> connections.size + idx + 1 <= PubSubConnection.MAX_CONNECTIONS } - .forEach { (_, topics) -> - val connection = PubSubConnection( - tag = "#${connections.size}", - client = client, - scope = this, - oAuth = oAuth, - jsonFormat = json, - ) - connection.connect(initialTopics = topics.toSet()) + .forEach { (_, chunk) -> + val connection = + PubSubConnection( + tag = "#${connections.size}", + client = client, + scope = this, + oAuth = oAuth, + jsonFormat = json, + ) + connection.connect(initialTopics = chunk.toSet()) connections += connection collectJobs += launch { connection.collectEvents() } } } } + private fun closeAll() { + collectJobs.forEach { it.cancel() } + collectJobs.clear() + connections.forEach { it.close() } + connections.clear() + } + private fun resetCollectionWith(action: PubSubConnection.() -> Unit = {}) = scope.launch { collectJobs.forEach { it.cancel() } collectJobs.clear() collectJobs.addAll( - elements = connections - .map { conn -> - conn.action() - launch { conn.collectEvents() } - } + elements = + connections + .map { conn -> + conn.action() + launch { conn.collectEvents() } + }, ) } @@ -164,12 +166,8 @@ class PubSubManager( events.collect { when (it) { is PubSubEvent.Message -> receiveChannel.send(it.message) - else -> Unit + else -> Unit } } } - - companion object { - private val TAG = PubSubManager::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubMessage.kt index de4ff0648..2b83139db 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubMessage.kt @@ -4,7 +4,6 @@ import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.pubsub.dto.moderation.ModerationActionData import com.flxrs.dankchat.data.twitch.pubsub.dto.redemption.PointRedemptionData -import com.flxrs.dankchat.data.twitch.pubsub.dto.whisper.WhisperData import kotlin.time.Instant sealed interface PubSubMessage { @@ -12,14 +11,12 @@ sealed interface PubSubMessage { val timestamp: Instant, val channelName: UserName, val channelId: UserId, - val data: PointRedemptionData + val data: PointRedemptionData, ) : PubSubMessage - data class Whisper(val data: WhisperData) : PubSubMessage - data class ModeratorAction( val timestamp: Instant, val channelId: UserId, - val data: ModerationActionData + val data: ModerationActionData, ) : PubSubMessage } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubTopic.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubTopic.kt index edda0008b..9109a914b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubTopic.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/PubSubTopic.kt @@ -3,8 +3,17 @@ package com.flxrs.dankchat.data.twitch.pubsub import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -sealed class PubSubTopic(val topic: String) { - data class PointRedemptions(val channelId: UserId, val channelName: UserName) : PubSubTopic(topic = "community-points-channel-v1.$channelId") - data class Whispers(val userId: UserId) : PubSubTopic(topic = "whispers.$userId") - data class ModeratorActions(val userId: UserId, val channelId: UserId, val channelName: UserName) : PubSubTopic(topic = "chat_moderator_actions.$userId.$channelId") +sealed class PubSubTopic( + val topic: String, +) { + data class PointRedemptions( + val channelId: UserId, + val channelName: UserName, + ) : PubSubTopic(topic = "community-points-channel-v1.$channelId") + + data class ModeratorActions( + val userId: UserId, + val channelId: UserId, + val channelName: UserName, + ) : PubSubTopic(topic = "chat_moderator_actions.$userId.$channelId") } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataMessage.kt index 04d0a1492..cacbcb34d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataMessage.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataMessage.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable @Serializable data class PubSubDataMessage( val type: String, - val data: T + val data: T, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataObjectMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataObjectMessage.kt deleted file mode 100644 index f6d3007ba..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/PubSubDataObjectMessage.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto - -import androidx.annotation.Keep -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class PubSubDataObjectMessage( - val type: String, - @SerialName("data_object") val data: T -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemption.kt index c973333be..f75bed986 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemption.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemption.kt @@ -1,8 +1,8 @@ package com.flxrs.dankchat.data.twitch.pubsub.dto.redemption import androidx.annotation.Keep -import kotlin.time.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant @Keep @Serializable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionReward.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionReward.kt index 09fc34c7a..b99c2e7b3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionReward.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/redemption/PointRedemptionReward.kt @@ -7,10 +7,38 @@ import kotlinx.serialization.Serializable @Keep @Serializable data class PointRedemptionReward( - val id: String, - val title: String, - val cost: Int, - @SerialName("is_user_input_required") val requiresUserInput: Boolean, - @SerialName("image") val images: PointRedemptionImages?, - @SerialName("default_image") val defaultImages: PointRedemptionImages, -) + val id: String = "", + val title: String = "", + val cost: Int = 0, + @SerialName("is_user_input_required") val requiresUserInput: Boolean = false, + @SerialName("image") val images: PointRedemptionImages? = null, + @SerialName("default_image") val defaultImages: PointRedemptionImages? = null, + @SerialName("reward_type") val rewardType: String? = null, + @SerialName("bits_cost") val bitsCost: Int = 0, + @SerialName("default_bits_cost") val defaultBitsCost: Int = 0, + @SerialName("pricing_type") val pricingType: String? = null, +) { + val effectiveId: String + get() = when (rewardType) { + "SEND_GIGANTIFIED_EMOTE" -> "gigantified-emote-message" + "SEND_ANIMATED_MESSAGE" -> "animated-message" + else -> id + } + + val effectiveTitle: String + get() = title.ifEmpty { + when (rewardType) { + "SEND_GIGANTIFIED_EMOTE" -> "Gigantify an Emote" + "SEND_ANIMATED_MESSAGE" -> "Message Effects" + else -> "" + } + } + + val effectiveCost: Int + get() = when { + cost > 0 -> cost + bitsCost > 0 -> bitsCost + defaultBitsCost > 0 -> defaultBitsCost + else -> 0 + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperData.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperData.kt deleted file mode 100644 index 1f9bee77f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperData.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto.whisper - -import androidx.annotation.Keep -import com.flxrs.dankchat.data.UserId -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class WhisperData( - @SerialName("sent_ts") val timestamp: Long, - @SerialName("message_id") val messageId: String, - @SerialName("body") val message: String, - @SerialName("from_id") val userId: UserId, - val tags: WhisperDataTags, - val recipient: WhisperDataRecipient, -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataBadge.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataBadge.kt deleted file mode 100644 index 7be513d17..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataBadge.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto.whisper - -import androidx.annotation.Keep -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class WhisperDataBadge( - val id: String, - val version: String, -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataEmote.kt deleted file mode 100644 index 74468a8da..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataEmote.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto.whisper - -import androidx.annotation.Keep -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class WhisperDataEmote( - @SerialName("emote_id") val id: String, - val start: Int, - val end: Int, -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataRecipient.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataRecipient.kt deleted file mode 100644 index 18e15a94b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataRecipient.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto.whisper - -import androidx.annotation.Keep -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class WhisperDataRecipient( - val id: UserId, - val color: String, - @SerialName("username") val name: UserName, - @SerialName("display_name") val displayName: DisplayName, -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataTags.kt b/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataTags.kt deleted file mode 100644 index 6fc496f09..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/data/twitch/pubsub/dto/whisper/WhisperDataTags.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flxrs.dankchat.data.twitch.pubsub.dto.whisper - -import androidx.annotation.Keep -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserName -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Keep -@Serializable -data class WhisperDataTags( - @SerialName("login") val name: UserName, - @SerialName("display_name") val displayName: DisplayName, - val color: String, - val emotes: List, - val badges: List, -) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt index f39995736..7ff562413 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/ConnectionModule.kt @@ -1,32 +1,31 @@ package com.flxrs.dankchat.di +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.twitch.chat.ChatConnection import com.flxrs.dankchat.data.twitch.chat.ChatConnectionType -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import okhttp3.OkHttpClient +import io.ktor.client.HttpClient import org.koin.core.annotation.Module import org.koin.core.annotation.Named import org.koin.core.annotation.Single -data object ReadConnection -data object WriteConnection +const val READ_CONNECTION = "ReadConnection" +const val WRITE_CONNECTION = "WriteConnection" @Module class ConnectionModule { - @Single - @Named(type = ReadConnection::class) + @Named(READ_CONNECTION) fun provideReadConnection( - @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, + httpClient: HttpClient, dispatchersProvider: DispatchersProvider, - preferenceStore: DankChatPreferenceStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Read, client, preferenceStore, dispatchersProvider) + authDataStore: AuthDataStore, + ): ChatConnection = ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchersProvider) @Single - @Named(type = WriteConnection::class) + @Named(WRITE_CONNECTION) fun provideWriteConnection( - @Named(type = WebSocketOkHttpClient::class) client: OkHttpClient, + httpClient: HttpClient, dispatchersProvider: DispatchersProvider, - preferenceStore: DankChatPreferenceStore, - ): ChatConnection = ChatConnection(ChatConnectionType.Write, client, preferenceStore, dispatchersProvider) + authDataStore: AuthDataStore, + ): ChatConnection = ChatConnection(ChatConnectionType.Write, httpClient, authDataStore, dispatchersProvider) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt index df356c96c..5d5169fc7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/CoroutineModule.kt @@ -7,6 +7,7 @@ import org.koin.core.annotation.Single @Module class CoroutineModule { + @Suppress("InjectDispatcher") @Single fun provideDispatchersProvider(): DispatchersProvider = object : DispatchersProvider { override val default: CoroutineDispatcher = Dispatchers.Default diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt index 230bf72b6..6d7fce886 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/DatabaseModule.kt @@ -17,59 +17,38 @@ import org.koin.core.annotation.Single @Module class DatabaseModule { - @Single - fun provideDatabase( - context: Context - ): DankChatDatabase = Room + fun provideDatabase(context: Context): DankChatDatabase = Room .databaseBuilder(context, DankChatDatabase::class.java, DB_NAME) .addMigrations(DankChatDatabase.MIGRATION_4_5) .build() @Single - fun provideEmoteUsageDao( - database: DankChatDatabase - ): EmoteUsageDao = database.emoteUsageDao() + fun provideEmoteUsageDao(database: DankChatDatabase): EmoteUsageDao = database.emoteUsageDao() @Single - fun provideRecentUploadsDao( - database: DankChatDatabase - ): RecentUploadsDao = database.recentUploadsDao() + fun provideRecentUploadsDao(database: DankChatDatabase): RecentUploadsDao = database.recentUploadsDao() @Single - fun provideUserDisplayDao( - database: DankChatDatabase - ): UserDisplayDao = database.userDisplayDao() + fun provideUserDisplayDao(database: DankChatDatabase): UserDisplayDao = database.userDisplayDao() @Single - fun provideMessageHighlightDao( - database: DankChatDatabase - ): MessageHighlightDao = database.messageHighlightDao() + fun provideMessageHighlightDao(database: DankChatDatabase): MessageHighlightDao = database.messageHighlightDao() @Single - fun provideUserHighlightDao( - database: DankChatDatabase - ): UserHighlightDao = database.userHighlightDao() + fun provideUserHighlightDao(database: DankChatDatabase): UserHighlightDao = database.userHighlightDao() @Single - fun provideBadgeHighlightDao( - database: DankChatDatabase - ): BadgeHighlightDao = database.badgeHighlightDao() + fun provideBadgeHighlightDao(database: DankChatDatabase): BadgeHighlightDao = database.badgeHighlightDao() @Single - fun provideIgnoreUserDao( - database: DankChatDatabase - ): UserIgnoreDao = database.userIgnoreDao() + fun provideIgnoreUserDao(database: DankChatDatabase): UserIgnoreDao = database.userIgnoreDao() @Single - fun provideMessageIgnoreDao( - database: DankChatDatabase - ): MessageIgnoreDao = database.messageIgnoreDao() + fun provideMessageIgnoreDao(database: DankChatDatabase): MessageIgnoreDao = database.messageIgnoreDao() @Single - fun provideBlacklistedUserHighlightDao( - database: DankChatDatabase - ): BlacklistedUserDao = database.blacklistedUserDao() + fun provideBlacklistedUserHighlightDao(database: DankChatDatabase): BlacklistedUserDao = database.blacklistedUserDao() private companion object { const val DB_NAME = "dankchat-db" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt index 35877c359..6e3daa4fb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/di/NetworkModule.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.di -import android.util.Log import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.data.api.auth.AuthApi import com.flxrs.dankchat.data.api.badges.BadgesApi @@ -8,11 +7,14 @@ import com.flxrs.dankchat.data.api.bttv.BTTVApi import com.flxrs.dankchat.data.api.dankchat.DankChatApi import com.flxrs.dankchat.data.api.ffz.FFZApi import com.flxrs.dankchat.data.api.helix.HelixApi +import com.flxrs.dankchat.data.api.helix.HelixApiStats import com.flxrs.dankchat.data.api.recentmessages.RecentMessagesApi import com.flxrs.dankchat.data.api.seventv.SevenTVApi import com.flxrs.dankchat.data.api.supibot.SupibotApi -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidationHolder import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout @@ -23,6 +25,7 @@ import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.observer.ResponseObserver import io.ktor.client.request.header import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json @@ -33,8 +36,8 @@ import org.koin.core.annotation.Single import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration -data object WebSocketOkHttpClient -data object UploadOkHttpClient +const val WEBSOCKET_OKHTTP_CLIENT = "WebSocketOkHttpClient" +const val UPLOAD_OKHTTP_CLIENT = "UploadOkHttpClient" @Module class NetworkModule { @@ -50,14 +53,16 @@ class NetworkModule { } @Single - @Named(type = WebSocketOkHttpClient::class) - fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + @Named(WEBSOCKET_OKHTTP_CLIENT) + fun provideOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() .callTimeout(20.seconds.toJavaDuration()) .build() @Single - @Named(type = UploadOkHttpClient::class) - fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + @Named(UPLOAD_OKHTTP_CLIENT) + fun provideUploadOkHttpClient(): OkHttpClient = OkHttpClient + .Builder() .callTimeout(60.seconds.toJavaDuration()) .build() @@ -70,90 +75,127 @@ class NetworkModule { } @Single - fun provideKtorClient(json: Json): HttpClient = HttpClient(OkHttp) { - install(Logging) { - level = LogLevel.INFO - logger = object : Logger { - override fun log(message: String) { - Log.v("HttpClient", message) - } + fun provideKtorClient(json: Json): HttpClient { + val httpLogger = KotlinLogging.logger("HttpClient") + return HttpClient(OkHttp) { + install(Logging) { + level = LogLevel.INFO + logger = + object : Logger { + override fun log(message: String) { + httpLogger.trace { message } + } + } + } + install(HttpCache) + install(UserAgent) { + agent = "dankchat/${BuildConfig.VERSION_NAME}" + } + install(ContentNegotiation) { + json(json) + } + install(HttpTimeout) { + connectTimeoutMillis = 30_000 + requestTimeoutMillis = 30_000 + socketTimeoutMillis = 30_000 } - } - install(HttpCache) - install(UserAgent) { - agent = "dankchat/${BuildConfig.VERSION_NAME}" - } - install(ContentNegotiation) { - json(json) - } - install(HttpTimeout) { - connectTimeoutMillis = 15_000 - requestTimeoutMillis = 15_000 - socketTimeoutMillis = 15_000 } } @Single - fun provideAuthApi(ktorClient: HttpClient) = AuthApi(ktorClient.config { - defaultRequest { - url(AUTH_BASE_URL) - } - }) + fun provideAuthApi(ktorClient: HttpClient) = AuthApi( + ktorClient.config { + defaultRequest { + url(AUTH_BASE_URL) + } + }, + ) @Single - fun provideDankChatApi(ktorClient: HttpClient) = DankChatApi(ktorClient.config { - defaultRequest { - url(DANKCHAT_BASE_URL) - } - }) + fun provideDankChatApi(ktorClient: HttpClient) = DankChatApi( + ktorClient.config { + defaultRequest { + url(DANKCHAT_BASE_URL) + } + }, + ) @Single - fun provideSupibotApi(ktorClient: HttpClient) = SupibotApi(ktorClient.config { - defaultRequest { - url(SUPIBOT_BASE_URL) - } - }) + fun provideSupibotApi(ktorClient: HttpClient) = SupibotApi( + ktorClient.config { + defaultRequest { + url(SUPIBOT_BASE_URL) + } + }, + ) @Single - fun provideHelixApi(ktorClient: HttpClient, preferenceStore: DankChatPreferenceStore) = HelixApi(ktorClient.config { - defaultRequest { - url(HELIX_BASE_URL) - header("Client-ID", preferenceStore.clientId) - } - }, preferenceStore) + fun provideHelixApi( + ktorClient: HttpClient, + authDataStore: AuthDataStore, + helixApiStats: HelixApiStats, + startupValidationHolder: StartupValidationHolder, + ) = HelixApi( + ktorClient.config { + defaultRequest { + url(HELIX_BASE_URL) + header("Client-ID", authDataStore.clientId) + } + install(ResponseObserver) { + onResponse { response -> + helixApiStats.recordResponse(response.status.value) + } + } + }, + authDataStore, + startupValidationHolder, + ) @Single - fun provideBadgesApi(ktorClient: HttpClient) = BadgesApi(ktorClient.config { - defaultRequest { - url(BADGES_BASE_URL) - } - }) + fun provideBadgesApi(ktorClient: HttpClient) = BadgesApi( + ktorClient.config { + defaultRequest { + url(BADGES_BASE_URL) + } + }, + ) @Single - fun provideFFZApi(ktorClient: HttpClient) = FFZApi(ktorClient.config { - defaultRequest { - url(FFZ_BASE_URL) - } - }) + fun provideFFZApi(ktorClient: HttpClient) = FFZApi( + ktorClient.config { + defaultRequest { + url(FFZ_BASE_URL) + } + }, + ) @Single - fun provideBTTVApi(ktorClient: HttpClient) = BTTVApi(ktorClient.config { - defaultRequest { - url(BTTV_BASE_URL) - } - }) + fun provideBTTVApi(ktorClient: HttpClient) = BTTVApi( + ktorClient.config { + defaultRequest { + url(BTTV_BASE_URL) + } + }, + ) @Single - fun provideRecentMessagesApi(ktorClient: HttpClient, developerSettingsDataStore: DeveloperSettingsDataStore) = RecentMessagesApi(ktorClient.config { - defaultRequest { - url(developerSettingsDataStore.current().customRecentMessagesHost) - } - }) + fun provideRecentMessagesApi( + ktorClient: HttpClient, + developerSettingsDataStore: DeveloperSettingsDataStore, + ) = RecentMessagesApi( + ktorClient.config { + defaultRequest { + url(developerSettingsDataStore.current().customRecentMessagesHost) + } + }, + ) @Single - fun provideSevenTVApi(ktorClient: HttpClient) = SevenTVApi(ktorClient.config { - defaultRequest { - url(SEVENTV_BASE_URL) - } - }) + fun provideSevenTVApi(ktorClient: HttpClient) = SevenTVApi( + ktorClient.config { + defaultRequest { + url(SEVENTV_BASE_URL) + } + }, + ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt new file mode 100644 index 000000000..bace597ff --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinator.kt @@ -0,0 +1,284 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.data.DataLoadingStep +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.repo.data.DataUpdateEventMessage +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import java.util.concurrent.ConcurrentHashMap + +@Single +class ChannelDataCoordinator( + private val channelDataLoader: ChannelDataLoader, + private val globalDataLoader: GlobalDataLoader, + private val chatMessageRepository: ChatMessageRepository, + private val dataRepository: DataRepository, + private val authDataStore: AuthDataStore, + private val preferenceStore: DankChatPreferenceStore, + private val startupValidationHolder: StartupValidationHolder, + private val streamDataRepository: StreamDataRepository, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + private var globalLoadJob: Job? = null + + // Track loading state per channel + private val channelStates = ConcurrentHashMap>() + + // Global loading state + private val _globalLoadingState = MutableStateFlow(GlobalLoadingState.Idle) + val globalLoadingState: StateFlow = _globalLoadingState.asStateFlow() + + init { + scope.launch { + dataRepository.dataUpdateEvents.collect { event -> + when (event) { + is DataUpdateEventMessage.ActiveEmoteSetChanged -> { + chatMessageRepository.addSystemMessage(event.channel, SystemMessageType.ChannelSevenTVEmoteSetChanged(event.actorName, event.emoteSetName)) + } + + is DataUpdateEventMessage.EmoteSetUpdated -> { + val (channel, update) = event + update.added.forEach { chatMessageRepository.addSystemMessage(channel, SystemMessageType.ChannelSevenTVEmoteAdded(update.actorName, it.name)) } + update.updated.forEach { chatMessageRepository.addSystemMessage(channel, SystemMessageType.ChannelSevenTVEmoteRenamed(update.actorName, it.oldName, it.name)) } + update.removed.forEach { chatMessageRepository.addSystemMessage(channel, SystemMessageType.ChannelSevenTVEmoteRemoved(update.actorName, it.name)) } + } + } + } + } + + scope.launch { + chatMessageRepository.chatLoadingFailures.collect { chatFailures -> + if (chatFailures.isNotEmpty()) { + _globalLoadingState.update { current -> + when (current) { + is GlobalLoadingState.Failed -> current.copy(chatFailures = chatFailures) + is GlobalLoadingState.Loaded -> GlobalLoadingState.Failed(chatFailures = chatFailures) + else -> current + } + } + } + } + } + } + + fun getChannelLoadingState(channel: UserName): StateFlow = channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } + + fun loadChannelData(channel: UserName) { + scope.launch { + loadChannelDataSuspend(channel) + } + } + + private suspend fun loadChannelDataSuspend(channel: UserName) { + startupValidationHolder.awaitResolved() + val stateFlow = + channelStates.getOrPut(channel) { + MutableStateFlow(ChannelLoadingState.Idle) + } + stateFlow.value = ChannelLoadingState.Loading + stateFlow.value = channelDataLoader.loadChannelData(channel) + chatMessageRepository.reparseAllEmotesAndBadges() + } + + fun loadGlobalData() { + globalLoadJob = + scope.launch { + _globalLoadingState.value = GlobalLoadingState.Loading + dataRepository.clearDataLoadingFailures() + + // Phase 1: Non-auth data (3rd-party emotes, DankChat badges) — loads immediately + globalDataLoader.loadGlobalData() + chatMessageRepository.reparseAllEmotesAndBadges() + + // Phase 2: Auth-gated data (badges, user emotes, blocks) — wait for validation to resolve + startupValidationHolder.awaitResolved() + if (startupValidationHolder.isAuthAvailable && authDataStore.isLoggedIn) { + // Fetch stream data first — single lightweight call before heavy emote pagination + val channels = preferenceStore.channels + if (channels.isNotEmpty()) { + runCatching { streamDataRepository.fetchOnce(channels) } + streamDataRepository.fetchStreamData(channels) + } + + globalDataLoader.loadAuthGlobalData() + chatMessageRepository.reparseAllEmotesAndBadges() + + val userId = authDataStore.userIdString + if (userId != null) { + val firstPageLoaded = CompletableDeferred() + launch { + globalDataLoader + .loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + .onSuccess { chatMessageRepository.reparseAllEmotesAndBadges() } + .onFailure { firstPageLoaded.complete(Unit) } + } + firstPageLoaded.await() + chatMessageRepository.reparseAllEmotesAndBadges() + } + } + + val dataFailures = dataRepository.dataLoadingFailures.value + val chatFailures = chatMessageRepository.chatLoadingFailures.value + _globalLoadingState.value = + when { + dataFailures.isEmpty() && chatFailures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed(failures = dataFailures, chatFailures = chatFailures) + } + } + } + + fun cancelGlobalLoading() { + globalLoadJob?.cancel() + globalLoadJob = null + } + + fun cleanupChannel(channel: UserName) { + channelStates.remove(channel) + scope.launch { + dataRepository.removeChannels(listOf(channel)) + } + } + + fun reloadAllChannels() { + scope.launch { + preferenceStore.channels.forEach { channel -> + loadChannelData(channel) + } + } + } + + fun reloadUserEmotes() { + scope.launch { + val userId = authDataStore.userIdString ?: return@launch + val firstPageLoaded = CompletableDeferred() + launch { + globalDataLoader + .loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + .onSuccess { chatMessageRepository.reparseAllEmotesAndBadges() } + .onFailure { firstPageLoaded.complete(Unit) } + } + firstPageLoaded.await() + chatMessageRepository.reparseAllEmotesAndBadges() + } + } + + fun reloadGlobalData() { + loadGlobalData() + } + + fun retryDataLoading(failedState: GlobalLoadingState.Failed) { + scope.launch { + _globalLoadingState.value = GlobalLoadingState.Loading + dataRepository.clearDataLoadingFailures() + chatMessageRepository.clearChatLoadingFailures() + + val channelsToRetry = mutableSetOf() + + val dataResults = + failedState.failures.map { failure -> + async { + when (val step = failure.step) { + is DataLoadingStep.GlobalSevenTVEmotes -> { + globalDataLoader.loadGlobalSevenTVEmotes() + } + + is DataLoadingStep.GlobalBTTVEmotes -> { + globalDataLoader.loadGlobalBTTVEmotes() + } + + is DataLoadingStep.GlobalFFZEmotes -> { + globalDataLoader.loadGlobalFFZEmotes() + } + + is DataLoadingStep.GlobalBadges -> { + globalDataLoader.loadGlobalBadges() + } + + is DataLoadingStep.DankChatBadges -> { + globalDataLoader.loadDankChatBadges() + } + + is DataLoadingStep.TwitchEmotes -> { + val userId = authDataStore.userIdString + if (userId != null) { + val firstPageLoaded = CompletableDeferred() + launch { + globalDataLoader + .loadUserEmotes(userId) { firstPageLoaded.complete(Unit) } + .onSuccess { chatMessageRepository.reparseAllEmotesAndBadges() } + .onFailure { firstPageLoaded.complete(Unit) } + } + firstPageLoaded.await() + chatMessageRepository.reparseAllEmotesAndBadges() + } + } + + is DataLoadingStep.ChannelBadges -> { + channelsToRetry.add(step.channel) + } + + is DataLoadingStep.ChannelSevenTVEmotes -> { + channelsToRetry.add(step.channel) + } + + is DataLoadingStep.ChannelFFZEmotes -> { + channelsToRetry.add(step.channel) + } + + is DataLoadingStep.ChannelBTTVEmotes -> { + channelsToRetry.add(step.channel) + } + + is DataLoadingStep.ChannelCheermotes -> { + channelsToRetry.add(step.channel) + } + } + } + } + + failedState.chatFailures.forEach { failure -> + when (val step = failure.step) { + is ChatLoadingStep.RecentMessages -> channelsToRetry.add(step.channel) + } + } + + dataResults.awaitAll() + channelsToRetry + .map { channel -> + async { loadChannelDataSuspend(channel) } + }.awaitAll() + + val remainingDataFailures = dataRepository.dataLoadingFailures.value + val remainingChatFailures = chatMessageRepository.chatLoadingFailures.value + _globalLoadingState.value = + when { + remainingDataFailures.isEmpty() && remainingChatFailures.isEmpty() -> GlobalLoadingState.Loaded + else -> GlobalLoadingState.Failed(failures = remainingDataFailures, chatFailures = remainingChatFailures) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt new file mode 100644 index 000000000..fe49a8d78 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ChannelDataLoader.kt @@ -0,0 +1,124 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.ApiException +import com.flxrs.dankchat.data.repo.channel.Channel +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.state.ChannelLoadingFailure +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.di.DispatchersProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +@Single +class ChannelDataLoader( + private val dataRepository: DataRepository, + private val chatRepository: ChatRepository, + private val chatMessageRepository: ChatMessageRepository, + private val channelRepository: ChannelRepository, + private val getChannelsUseCase: GetChannelsUseCase, + private val dispatchersProvider: DispatchersProvider, +) { + suspend fun loadChannelData(channel: UserName): ChannelLoadingState { + return try { + // Phase 1: No auth needed — create flows and load message history + dataRepository.createFlowsIfNecessary(listOf(channel)) + chatRepository.createFlowsIfNecessary(channel) + chatRepository.loadRecentMessagesIfEnabled(channel) + + // Phase 2: Needs channel info (Helix or IRC fallback) for emotes/badges + val channelInfo = + channelRepository.getChannel(channel) + ?: getChannelsUseCase(listOf(channel)).firstOrNull() + if (channelInfo == null) { + return ChannelLoadingState.Failed(emptyList()) + } + + val failures = + withContext(dispatchersProvider.io) { + val badgesResult = async { loadChannelBadges(channel, channelInfo.id) } + val emotesResults = async { loadChannelEmotes(channel, channelInfo) } + + listOfNotNull( + badgesResult.await(), + *emotesResults.await().toTypedArray(), + ) + } + + failures.forEach { failure -> + val status = (failure.error as? ApiException)?.status?.value?.toString() ?: "0" + val systemMessageType = + when (failure) { + is ChannelLoadingFailure.SevenTVEmotes -> SystemMessageType.ChannelSevenTVEmotesFailed(status) + is ChannelLoadingFailure.BTTVEmotes -> SystemMessageType.ChannelBTTVEmotesFailed(status) + is ChannelLoadingFailure.FFZEmotes -> SystemMessageType.ChannelFFZEmotesFailed(status) + else -> null + } + systemMessageType?.let { + chatMessageRepository.addSystemMessage(channel, it) + } + } + + when { + failures.isEmpty() -> ChannelLoadingState.Loaded + else -> ChannelLoadingState.Failed(failures) + } + } catch (_: Exception) { + ChannelLoadingState.Failed(emptyList()) + } + } + + suspend fun loadChannelBadges( + channel: UserName, + channelId: UserId, + ): ChannelLoadingFailure.Badges? = dataRepository.loadChannelBadges(channel, channelId).fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.Badges(channel, channelId, it) }, + ) + + suspend fun loadChannelEmotes( + channel: UserName, + channelInfo: Channel, + ): List = withContext(dispatchersProvider.io) { + val bttvResult = + async { + dataRepository.loadChannelBTTVEmotes(channel, channelInfo.displayName, channelInfo.id).fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.BTTVEmotes(channel, it) }, + ) + } + val ffzResult = + async { + dataRepository.loadChannelFFZEmotes(channel, channelInfo.id).fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.FFZEmotes(channel, it) }, + ) + } + val sevenTvResult = + async { + dataRepository.loadChannelSevenTVEmotes(channel, channelInfo.id).fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.SevenTVEmotes(channel, it) }, + ) + } + val cheermotesResult = + async { + dataRepository.loadChannelCheermotes(channel, channelInfo.id).fold( + onSuccess = { null }, + onFailure = { ChannelLoadingFailure.Cheermotes(channel, it) }, + ) + } + listOfNotNull( + bttvResult.await(), + ffzResult.await(), + sevenTvResult.await(), + cheermotesResult.await(), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt new file mode 100644 index 000000000..f31aa4b01 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/ConnectionCoordinator.kt @@ -0,0 +1,58 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.auth.AuthEvent +import com.flxrs.dankchat.data.auth.AuthStateCoordinator +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatConnector +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.utils.AppLifecycleListener +import com.flxrs.dankchat.utils.AppLifecycleListener.AppLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single + +@Single +class ConnectionCoordinator( + private val chatConnector: ChatConnector, + private val dataRepository: DataRepository, + private val chatChannelProvider: ChatChannelProvider, + private val authStateCoordinator: AuthStateCoordinator, + private val startupValidationHolder: StartupValidationHolder, + private val appLifecycleListener: AppLifecycleListener, + dispatchersProvider: DispatchersProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchersProvider.default) + + fun initialize() { + scope.launch { + val result = authStateCoordinator.validateOnStartup() + when (result) { + is AuthEvent.TokenInvalid -> Unit + else -> chatConnector.connectAndJoin(chatChannelProvider.channels.value.orEmpty()) + } + } + + scope.launch { + startupValidationHolder.awaitResolved() + var wasInBackground = false + appLifecycleListener.appState.collect { state -> + when (state) { + is AppLifecycle.Background -> { + wasInBackground = true + } + + is AppLifecycle.Foreground -> { + if (wasInBackground) { + wasInBackground = false + chatConnector.reconnectIfNecessary() + dataRepository.reconnectIfNecessary() + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt index f9d65f07b..92dbfe5f1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GetChannelsUseCase.kt @@ -13,31 +13,37 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Single @Single -class GetChannelsUseCase(private val channelRepository: ChannelRepository) { - +class GetChannelsUseCase( + private val channelRepository: ChannelRepository, +) { suspend operator fun invoke(names: List): List = coroutineScope { val channels = channelRepository.getChannels(names) val remaining = names - channels.mapTo(mutableSetOf(), Channel::name) - val (roomStatePairs, remainingForRoomState) = remaining.fold(Pair(emptyList(), emptyList())) { (states, remaining), user -> - when (val state = channelRepository.getRoomState(user)) { - null -> states to remaining + user - else -> states + state to remaining + val (roomStatePairs, remainingForRoomState) = + remaining.fold(Pair(emptyList(), emptyList())) { (states, remaining), user -> + when (val state = channelRepository.getRoomState(user)) { + null -> states to remaining + user + else -> states + state to remaining + } } - } - val remainingPairs = remainingForRoomState.map { user -> - async { - withTimeoutOrNull(getRoomStateDelay(remainingForRoomState)) { - channelRepository.getRoomStateFlow(user).firstOrNull()?.let { - Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) + val remainingPairs = + remainingForRoomState + .map { user -> + async { + withTimeoutOrNull(getRoomStateDelay(remainingForRoomState)) { + channelRepository.getRoomStateFlow(user).firstOrNull()?.let { + Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) + } + } } - } - } - }.awaitAll().filterNotNull() + }.awaitAll() + .filterNotNull() - val roomStateChannels = roomStatePairs.map { - Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) - } + remainingPairs + val roomStateChannels = + roomStatePairs.map { + Channel(id = it.channelId, name = it.channel, displayName = it.channel.toDisplayName(), avatarUrl = null) + } + remainingPairs channelRepository.cacheChannels(roomStateChannels) channels + roomStateChannels @@ -46,6 +52,7 @@ class GetChannelsUseCase(private val channelRepository: ChannelRepository) { companion object { private const val IRC_TIMEOUT_DELAY = 5_000L private const val IRC_TIMEOUT_CHANNEL_DELAY = 600L + private fun getRoomStateDelay(channels: List): Long = IRC_TIMEOUT_DELAY + channels.size * IRC_TIMEOUT_CHANNEL_DELAY } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt new file mode 100644 index 000000000..04ad27c2b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/domain/GlobalDataLoader.kt @@ -0,0 +1,61 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.command.CommandRepository +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single + +@Single +class GlobalDataLoader( + private val dataRepository: DataRepository, + private val commandRepository: CommandRepository, + private val ignoresRepository: IgnoresRepository, + private val dispatchersProvider: DispatchersProvider, +) { + suspend fun loadGlobalData(): List> = withContext(dispatchersProvider.io) { + val results = + awaitAll( + async { loadDankChatBadges() }, + async { loadGlobalBTTVEmotes() }, + async { loadGlobalFFZEmotes() }, + async { loadGlobalSevenTVEmotes() }, + ) + launch { loadSupibotCommands() } + results + } + + suspend fun loadAuthGlobalData(): List> = withContext(dispatchersProvider.io) { + val results = + awaitAll( + async { loadGlobalBadges() }, + ) + launch { loadUserBlocks() } + results + } + + suspend fun loadDankChatBadges(): Result = dataRepository.loadDankChatBadges() + + suspend fun loadGlobalBadges(): Result = dataRepository.loadGlobalBadges() + + suspend fun loadGlobalBTTVEmotes(): Result = dataRepository.loadGlobalBTTVEmotes() + + suspend fun loadGlobalFFZEmotes(): Result = dataRepository.loadGlobalFFZEmotes() + + suspend fun loadGlobalSevenTVEmotes(): Result = dataRepository.loadGlobalSevenTVEmotes() + + suspend fun loadSupibotCommands() = commandRepository.loadSupibotCommands() + + suspend fun loadUserBlocks() = ignoresRepository.loadUserBlocks() + + suspend fun loadUserEmotes( + userId: UserId, + onFirstPageLoaded: (() -> Unit)? = null, + ): Result = dataRepository.loadUserEmotes(userId, onFirstPageLoaded) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/LoginFragment.kt deleted file mode 100644 index 3f9d63fb7..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginFragment.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.flxrs.dankchat.login - -import android.annotation.SuppressLint -import android.os.Build -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.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.activity.addCallback -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.core.net.toUri -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.LoginFragmentBinding -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.transition.MaterialSharedAxis -import org.koin.androidx.viewmodel.ext.android.viewModel - -class LoginFragment : Fragment() { - - private var bindingRef: LoginFragmentBinding? = null - private val binding get() = bindingRef!! - private val loginViewModel: LoginViewModel by viewModel() - - @SuppressLint("SetJavaScriptEnabled") - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - bindingRef = LoginFragmentBinding.inflate(inflater, container, false) - binding.webview.apply { - with(settings) { - javaScriptEnabled = true - setSupportZoom(true) - } - - clearCache(true) - clearFormData() - - webViewClient = TwitchAuthClient() - loadUrl(loginViewModel.loginUrl) - } - - return binding.root - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> - val systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemInsets.left, systemInsets.top, systemInsets.right, systemInsets.bottom) - ViewCompat.onApplyWindowInsets(v, insets) - } - - (requireActivity() as AppCompatActivity).apply { - binding.loginToolbar.setNavigationOnClickListener { showCancelLoginDialog() } - onBackPressedDispatcher.addCallback(viewLifecycleOwner) { - when { - binding.webview.canGoBack() -> binding.webview.goBack() - else -> showCancelLoginDialog() - } - } - addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED) - setSupportActionBar(binding.loginToolbar) - } - collectFlow(loginViewModel.events) { (successful) -> - with(findNavController()) { - runCatching { - val handle = previousBackStackEntry?.savedStateHandle ?: return@collectFlow - handle[MainFragment.LOGIN_REQUEST_KEY] = successful - } - navigateUp() - } - } - } - - private val menuProvider = object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.login_menu, menu) - } - - override fun onPrepareMenu(menu: Menu) { - val isDefaultZoom = binding.webview.settings.textZoom == 100 - menu.findItem(R.id.zoom_out)?.isVisible = isDefaultZoom - menu.findItem(R.id.zoom_in)?.isVisible = !isDefaultZoom - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.zoom_in -> binding.webview.settings.textZoom = 100 - R.id.zoom_out -> binding.webview.settings.textZoom = 50 - else -> return false - } - activity?.invalidateMenu() - return true - } - } - - override fun onDestroyView() { - super.onDestroyView() - bindingRef = null - } - - private fun showCancelLoginDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_login_cancel_title) - .setMessage(R.string.confirm_login_cancel_message) - .setPositiveButton(R.string.confirm_login_cancel_positive_button) { _, _ -> findNavController().popBackStack() } - .setNegativeButton(R.string.dialog_dismiss) { _, _ -> } - .create().show() - } - - @Suppress("OVERRIDE_DEPRECATION") - private inner class TwitchAuthClient : WebViewClient() { - override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { - //bindingRef?.root?.showLongSnackbar("Error $errorCode: $description") - Log.e(TAG, "Error $errorCode in WebView: $description") - } - - @RequiresApi(Build.VERSION_CODES.M) - override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { - val message = error?.description - val code = error?.errorCode - //bindingRef?.root?.showLongSnackbar("Error $code: $message") - Log.e(TAG, "Error $code in WebView: $message") - } - - override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { - val urlString = url ?: "" - val fragment = urlString.toUri().fragment ?: return false - loginViewModel.parseToken(fragment) - return false - } - - @RequiresApi(Build.VERSION_CODES.N) - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val fragment = request?.url?.fragment ?: return false - loginViewModel.parseToken(fragment) - return false - } - } - - companion object { - private val TAG = LoginFragment::class.java.simpleName - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt deleted file mode 100644 index f01c6b383..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/login/LoginViewModel.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.flxrs.dankchat.login - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.api.auth.AuthApiClient -import com.flxrs.dankchat.data.api.auth.dto.ValidateDto -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel - -@KoinViewModel -class LoginViewModel( - private val authApiClient: AuthApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore, -) : ViewModel() { - - data class TokenParseEvent(val successful: Boolean) - - private val eventChannel = Channel(Channel.BUFFERED) - val events = eventChannel.receiveAsFlow() - - val loginUrl = AuthApiClient.LOGIN_URL - - fun parseToken(fragment: String) = viewModelScope.launch { - if (!fragment.startsWith("access_token")) { - eventChannel.send(TokenParseEvent(successful = false)) - return@launch - } - - val token = fragment - .substringAfter("access_token=") - .substringBefore("&scope=") - - val result = authApiClient.validateUser(token).fold( - onSuccess = { saveLoginDetails(token, it) }, - onFailure = { - Log.e(TAG, "Failed to validate token: ${it.message}") - TokenParseEvent(successful = false) - } - ) - eventChannel.send(result) - } - - private fun saveLoginDetails(oAuth: String, validateDto: ValidateDto): TokenParseEvent { - dankChatPreferenceStore.apply { - oAuthKey = "oauth:$oAuth" - userName = validateDto.login.lowercase() - userIdString = validateDto.userId - clientId = validateDto.clientId - isLoggedIn = true - } - - return TokenParseEvent(successful = true) - } - - companion object { - private val TAG = LoginViewModel::class.java.simpleName - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt deleted file mode 100644 index 81dcb23bb..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInput.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flxrs.dankchat.main - -import android.content.Context -import android.os.Build -import android.util.AttributeSet -import android.view.KeyEvent -import android.widget.AdapterView -import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView -import com.flxrs.dankchat.chat.suggestion.SuggestionsArrayAdapter - -class DankChatInput : AppCompatMultiAutoCompleteTextView { - - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - override fun onKeyPreIme(keyCode: Int, event: KeyEvent?): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && event?.keyCode == KeyEvent.KEYCODE_BACK) { - clearFocus() - } - - return super.onKeyPreIme(keyCode, event) - } - - fun setSuggestionAdapter(enabled: Boolean, adapter: SuggestionsArrayAdapter) = setAdapter(adapter.takeIf { enabled }) - fun isItemSelected() = this.listSelection != AdapterView.INVALID_POSITION -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInputLayout.kt deleted file mode 100644 index 085f27b64..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/DankChatInputLayout.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.flxrs.dankchat.main - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.util.Log -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.google.android.material.R -import com.google.android.material.textfield.TextInputLayout -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class DankChatInputLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.textInputStyle, -) : TextInputLayout(context, attrs, defStyleAttr) { - - companion object { - private const val INVALID_FINGER_INDEX = -1 - private val TAG = DankChatInputLayout::class.java.simpleName - } - - private var endIconTouchListener: OnTouchListener? = null - - @SuppressLint("ClickableViewAccessibility") - fun setEndIconTouchListener(touchListener: OnTouchListener?): Boolean { - val imageButton = runCatching { - val endLayout = javaClass.superclass.getDeclaredField("endLayout").let { - it.isAccessible = true - it.get(this) - } - endLayout.javaClass.getDeclaredField("endIconView").let { - it.isAccessible = true - it.get(endLayout) as View - } - }.getOrElse { - Log.e(TAG, "Failed to access EndIcon ImageButton", it) - return false - } - - endIconTouchListener = touchListener - - if (touchListener == null) { - imageButton.setOnTouchListener(null) - return true - } - - imageButton.isFocusable = true - imageButton.isClickable = true - - var firstFingerIndex = INVALID_FINGER_INDEX - var isHolding = false - val callbackJob = Job() - val cancelActionJob = { - isHolding = false - firstFingerIndex = INVALID_FINGER_INDEX - callbackJob.cancelChildren() - } - - val viewTouchListener = OnTouchListener { view: View, event: MotionEvent -> - if (firstFingerIndex != INVALID_FINGER_INDEX && firstFingerIndex != event.getPointerId(event.actionIndex)) { - return@OnTouchListener view.onTouchEvent(event) - } - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - firstFingerIndex = event.getPointerId(event.actionIndex) - view.findViewTreeLifecycleOwner()?.lifecycleScope?.launch(callbackJob) { - delay(500L) - isHolding = true - touchListener.onTouch(TouchEvent.LONG_CLICK) - - delay(1_000L) - touchListener.onTouch(TouchEvent.HOLD_START) - } ?: return@OnTouchListener view.onTouchEvent(event) - } - - MotionEvent.ACTION_MOVE -> Unit - MotionEvent.ACTION_UP -> { - when { - isHolding -> touchListener.onTouch(TouchEvent.HOLD_STOP) - else -> touchListener.onTouch(TouchEvent.CLICK) - } - cancelActionJob() - } - - else -> cancelActionJob() - } - - false - } - - imageButton.setOnTouchListener(viewTouchListener) - return true - } - - fun interface OnTouchListener { - fun onTouch(touchEvent: TouchEvent) - } - - enum class TouchEvent { - CLICK, - LONG_CLICK, - HOLD_START, - HOLD_STOP - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt deleted file mode 100644 index 24a062ead..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/InputState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flxrs.dankchat.main - -sealed interface InputState { - object Default : InputState - object Replying : InputState - object NotLoggedIn : InputState - object Disconnected: InputState -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt deleted file mode 100644 index 3f8630c47..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainActivity.kt +++ /dev/null @@ -1,227 +0,0 @@ -package com.flxrs.dankchat.main - -import android.Manifest -import android.annotation.SuppressLint -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Build -import android.os.Bundle -import android.os.IBinder -import android.util.Log -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat.Type -import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.doOnAttach -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.findNavController -import com.flxrs.dankchat.DankChatViewModel -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.notification.NotificationService -import com.flxrs.dankchat.data.repo.data.ServiceEvent -import com.flxrs.dankchat.databinding.MainActivityBinding -import com.flxrs.dankchat.utils.extensions.hasPermission -import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu -import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode -import com.flxrs.dankchat.utils.extensions.keepScreenOn -import com.flxrs.dankchat.utils.extensions.parcelable -import com.google.android.material.color.DynamicColors -import com.google.android.material.color.DynamicColorsOptions -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.androidx.viewmodel.ext.android.viewModel - -class MainActivity : AppCompatActivity() { - - private val viewModel: DankChatViewModel by viewModel() - private val pendingChannelsToClear = mutableListOf() - private val navController: NavController by lazy { findNavController(R.id.main_content) } - private var bindingRef: MainActivityBinding? = null - private val binding get() = bindingRef!! - - private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { - // just start the service, we don't care if the permission has been granted or not xd - startService() - } - - private val twitchServiceConnection = TwitchServiceConnection() - var notificationService: NotificationService? = null - var isBound = false - var channelToOpen: UserName? = null - - override fun onCreate(savedInstanceState: Bundle?) { - val isTrueDarkModeEnabled = viewModel.isTrueDarkModeEnabled - val isDynamicColorAvailable = DynamicColors.isDynamicColorAvailable() - when { - isTrueDarkModeEnabled && isDynamicColorAvailable -> { - val dynamicColorsOptions = DynamicColorsOptions.Builder() - .setThemeOverlay(R.style.AppTheme_TrueDarkOverlay) - .build() - DynamicColors.applyToActivityIfAvailable(this, dynamicColorsOptions) - // TODO check if still neded in future material alphas - theme.applyStyle(R.style.AppTheme_TrueDarkOverlay, true) - window.peekDecorView()?.context?.theme?.applyStyle(R.style.AppTheme_TrueDarkOverlay, true) - } - - isTrueDarkModeEnabled -> { - theme.applyStyle(R.style.AppTheme_TrueDarkTheme, true) - window.peekDecorView()?.context?.theme?.applyStyle(R.style.AppTheme_TrueDarkTheme, true) - } - - else -> DynamicColors.applyToActivityIfAvailable(this) - } - - super.onCreate(savedInstanceState) - enableEdgeToEdge() - bindingRef = MainActivityBinding.inflate(layoutInflater) - setContentView(binding.root) - - viewModel.checkLogin() - viewModel.serviceEvents - .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) - .onEach { - Log.i(TAG, "Received service event: $it") - when (it) { - ServiceEvent.Shutdown -> handleShutDown() - } - } - .launchIn(lifecycleScope) - - viewModel.keepScreenOn - .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) - .onEach { - Log.i(TAG, "Setting FLAG_KEEP_SCREEN_ON to $it") - keepScreenOn(it) - } - .launchIn(lifecycleScope) - } - - override fun onDestroy() { - super.onDestroy() - bindingRef = null - - if (!isChangingConfigurations && !isInSupportedPictureInPictureMode) { - handleShutDown() - } - } - - @SuppressLint("InlinedApi") - override fun onStart() { - super.onStart() - val needsNotificationPermission = isAtLeastTiramisu && hasPermission(Manifest.permission.POST_NOTIFICATIONS) - when { - needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - // start service without notification permission - else -> startService() - } - } - - private fun startService() { - if (!isBound) Intent(this, NotificationService::class.java).also { - try { - isBound = true - ContextCompat.startForegroundService(this, it) - bindService(it, twitchServiceConnection, Context.BIND_AUTO_CREATE) - } catch (t: Throwable) { - Log.e(TAG, Log.getStackTraceString(t)) - } - } - } - - override fun onStop() { - super.onStop() - if (isBound) { - if (!isChangingConfigurations) { - notificationService?.enableNotifications() - } - - isBound = false - try { - unbindService(twitchServiceConnection) - } catch (t: Throwable) { - Log.e(TAG, Log.getStackTraceString(t)) - } - } - } - - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp() || super.onSupportNavigateUp() - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) - channelToOpen = channelExtra - } - - fun clearNotificationsOfChannel(channel: UserName) = when { - isBound && notificationService != null -> notificationService?.setActiveChannel(channel) - else -> pendingChannelsToClear += channel - } - - fun setFullScreen(enabled: Boolean, changeActionBarVisibility: Boolean = true) = binding.root.doOnAttach { - val windowInsetsController = WindowCompat.getInsetsController(window, it) - when { - enabled -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !isInMultiWindowMode) { - with(windowInsetsController) { - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - hide(Type.systemBars()) - } - } - if (changeActionBarVisibility) { - supportActionBar?.hide() - } - } - - else -> { - windowInsetsController.show(Type.systemBars()) - if (changeActionBarVisibility) { - supportActionBar?.show() - } - } - } - it.requestApplyInsets() - } - - private fun handleShutDown() { - stopService(Intent(this, NotificationService::class.java)) - finish() - android.os.Process.killProcess(android.os.Process.myPid()) - } - - private inner class TwitchServiceConnection : ServiceConnection { - override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as NotificationService.LocalBinder - notificationService = binder.service - isBound = true - - if (pendingChannelsToClear.isNotEmpty()) { - pendingChannelsToClear.forEach { notificationService?.setActiveChannel(it) } - pendingChannelsToClear.clear() - } - - viewModel.init(tryReconnect = !isChangingConfigurations) - binder.service.checkForNotification() - } - - override fun onServiceDisconnected(className: ComponentName?) { - notificationService = null - isBound = false - } - } - - companion object { - private val TAG = MainActivity::class.java.simpleName - const val OPEN_CHANNEL_KEY = "open_channel" - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt deleted file mode 100644 index 6b1af5557..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.flxrs.dankchat.main - -sealed interface MainEvent { - data class Error(val throwable: Throwable) : MainEvent -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt deleted file mode 100644 index c145a6dab..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainFragment.kt +++ /dev/null @@ -1,1551 +0,0 @@ -package com.flxrs.dankchat.main - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.PictureInPictureParams -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Intent -import android.content.res.ColorStateList -import android.content.res.Configuration -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.text.SpannableStringBuilder -import android.text.method.LinkMovementMethod -import android.text.util.Linkify -import android.util.LayoutDirection -import android.util.Rational -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.MotionEvent -import android.view.RoundedCorner -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.view.inputmethod.EditorInfo -import android.webkit.MimeTypeMap -import android.widget.ProgressBar -import android.widget.TextView -import androidx.activity.BackEventCompat -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.content.ContextCompat -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.content.FileProvider -import androidx.core.net.toFile -import androidx.core.net.toUri -import androidx.core.view.MenuProvider -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsAnimationCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsCompat.Type -import androidx.core.view.doOnPreDraw -import androidx.core.view.get -import androidx.core.view.isVisible -import androidx.core.view.postDelayed -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentContainerView -import androidx.fragment.app.commitNow -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import com.flxrs.dankchat.BuildConfig -import com.flxrs.dankchat.DankChatViewModel -import com.flxrs.dankchat.R -import com.flxrs.dankchat.ValidationResult -import com.flxrs.dankchat.chat.ChatTabAdapter -import com.flxrs.dankchat.chat.FullScreenSheetState -import com.flxrs.dankchat.chat.InputSheetState -import com.flxrs.dankchat.chat.emote.EmoteSheetFragment -import com.flxrs.dankchat.chat.emote.EmoteSheetResult -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuFragment -import com.flxrs.dankchat.chat.mention.MentionFragment -import com.flxrs.dankchat.chat.message.MessageSheetResult -import com.flxrs.dankchat.chat.message.MoreActionsMessageSheetResult -import com.flxrs.dankchat.chat.replies.RepliesFragment -import com.flxrs.dankchat.chat.replies.ReplyInputSheetFragment -import com.flxrs.dankchat.chat.suggestion.SpaceTokenizer -import com.flxrs.dankchat.chat.suggestion.Suggestion -import com.flxrs.dankchat.chat.suggestion.SuggestionsArrayAdapter -import com.flxrs.dankchat.chat.user.UserPopupResult -import com.flxrs.dankchat.data.DisplayName -import com.flxrs.dankchat.data.UserId -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.state.DataLoadingState -import com.flxrs.dankchat.data.state.ImageUploadState -import com.flxrs.dankchat.data.toUserId -import com.flxrs.dankchat.data.toUserName -import com.flxrs.dankchat.data.twitch.badge.Badge -import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote -import com.flxrs.dankchat.databinding.EditDialogBinding -import com.flxrs.dankchat.databinding.MainFragmentBinding -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore -import com.flxrs.dankchat.preferences.model.ChannelWithRename -import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore -import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore -import com.flxrs.dankchat.utils.createMediaFile -import com.flxrs.dankchat.utils.extensions.awaitState -import com.flxrs.dankchat.utils.extensions.collectFlow -import com.flxrs.dankchat.utils.extensions.expand -import com.flxrs.dankchat.utils.extensions.firstValueOrNull -import com.flxrs.dankchat.utils.extensions.hide -import com.flxrs.dankchat.utils.extensions.hideKeyboard -import com.flxrs.dankchat.utils.extensions.isCollapsed -import com.flxrs.dankchat.utils.extensions.isHidden -import com.flxrs.dankchat.utils.extensions.isInPictureInPictureMode -import com.flxrs.dankchat.utils.extensions.isLandscape -import com.flxrs.dankchat.utils.extensions.isPortrait -import com.flxrs.dankchat.utils.extensions.isVisible -import com.flxrs.dankchat.utils.extensions.navigateSafe -import com.flxrs.dankchat.utils.extensions.px -import com.flxrs.dankchat.utils.extensions.reduceDragSensitivity -import com.flxrs.dankchat.utils.extensions.withData -import com.flxrs.dankchat.utils.extensions.withTrailingSpace -import com.flxrs.dankchat.utils.extensions.withoutInvisibleChar -import com.flxrs.dankchat.utils.insets.ControlFocusInsetsAnimationCallback -import com.flxrs.dankchat.utils.insets.RootViewDeferringInsetsCallback -import com.flxrs.dankchat.utils.insets.TranslateDeferringInsetsAnimationCallback -import com.flxrs.dankchat.utils.removeExifAttributes -import com.flxrs.dankchat.utils.showErrorDialog -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.color.MaterialColors -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayoutMediator -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.activityViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.io.File -import java.io.IOException -import java.net.URL -import kotlin.math.roundToInt - -class MainFragment : Fragment() { - - private val mainViewModel: MainViewModel by viewModel() - private val dankChatViewModel: DankChatViewModel by activityViewModel() - private val chatSettingsDataStore: ChatSettingsDataStore by inject() - private val developerSettingsDataStore: DeveloperSettingsDataStore by inject() - private val toolsSettingsDataStore: ToolsSettingsDataStore by inject() - private val notificationsSettingsDataStore: NotificationsSettingsDataStore by inject() - private val dankChatPreferences: DankChatPreferenceStore by inject() - private val navController: NavController by lazy { findNavController() } - private var bindingRef: MainFragmentBinding? = null - private val binding get() = bindingRef!! - - private var inputBottomSheetBehavior: BottomSheetBehavior? = null - private var fullscreenBottomSheetBehavior: BottomSheetBehavior? = null - private val onBackPressedCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - when { - inputBottomSheetBehavior?.isVisible == true -> inputBottomSheetBehavior?.handleBackInvoked() - fullscreenBottomSheetBehavior?.isVisible == true -> fullscreenBottomSheetBehavior?.handleBackInvoked() - mainViewModel.isFullscreen -> mainViewModel.toggleFullscreen() - } - } - - override fun handleOnBackProgressed(backEvent: BackEventCompat) { - when { - inputBottomSheetBehavior?.isVisible == true -> inputBottomSheetBehavior?.updateBackProgress(backEvent) - fullscreenBottomSheetBehavior?.isVisible == true -> fullscreenBottomSheetBehavior?.updateBackProgress(backEvent) - } - } - - override fun handleOnBackCancelled() { - when { - inputBottomSheetBehavior?.isVisible == true -> inputBottomSheetBehavior?.cancelBackProgress() - fullscreenBottomSheetBehavior?.isVisible == true -> fullscreenBottomSheetBehavior?.cancelBackProgress() - } - } - - override fun handleOnBackStarted(backEvent: BackEventCompat) { - when { - inputBottomSheetBehavior?.isVisible == true -> inputBottomSheetBehavior?.startBackProgress(backEvent) - fullscreenBottomSheetBehavior?.isVisible == true -> fullscreenBottomSheetBehavior?.startBackProgress(backEvent) - } - } - } - - private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - closeInputSheets() - val newChannel = tabAdapter[position] ?: return - mainViewModel.setActiveChannel(newChannel) - } - - override fun onPageScrollStateChanged(state: Int) = closeInputSheets() - } - - private val menuProvider = object : MenuProvider { - override fun onPrepareMenu(menu: Menu) { - with(menu) { - val isLoggedIn = dankChatPreferences.isLoggedIn - val shouldShowProgress = mainViewModel.shouldShowUploadProgress.value - val hasChannels = mainViewModel.getChannels().isNotEmpty() - val mentionIconColor = when (mainViewModel.shouldColorNotification.value) { - true -> R.attr.colorError - else -> R.attr.colorControlHighlight - } - findItem(R.id.menu_login)?.isVisible = !isLoggedIn - findItem(R.id.menu_account)?.isVisible = isLoggedIn - findItem(R.id.menu_manage)?.isVisible = hasChannels - findItem(R.id.menu_channel)?.isVisible = hasChannels - findItem(R.id.menu_open_channel)?.isVisible = hasChannels - findItem(R.id.menu_block_channel)?.isVisible = isLoggedIn - findItem(R.id.menu_mentions)?.apply { - isVisible = hasChannels - context?.let { - val fallback = ContextCompat.getColor(it, android.R.color.white) - val color = MaterialColors.getColor(it, mentionIconColor, fallback) - icon?.setTintList(ColorStateList.valueOf(color)) - } - } - - findItem(R.id.progress)?.apply { - isVisible = shouldShowProgress - actionView = ProgressBar(requireContext()).apply { - indeterminateTintList = ColorStateList.valueOf(MaterialColors.getColor(this, R.attr.colorOnSurfaceVariant)) - isVisible = shouldShowProgress - } - } - } - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.menu, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - when (menuItem.itemId) { - R.id.menu_reconnect -> mainViewModel.reconnect() - R.id.menu_login, R.id.menu_relogin -> openLogin() - R.id.menu_logout -> showLogoutConfirmationDialog() - R.id.menu_add -> navigateSafe(R.id.action_mainFragment_to_addChannelDialogFragment).also { closeInputSheets() } - R.id.menu_mentions -> openMentionSheet() - R.id.menu_open_channel -> openChannel() - R.id.menu_remove_channel -> removeChannel() - R.id.menu_report_channel -> reportChannel() - R.id.menu_block_channel -> blockChannel() - R.id.menu_manage -> openManageChannelsDialog() - R.id.menu_reload_emotes -> reloadEmotes() - R.id.menu_choose_media -> showExternalHostingUploadDialogIfNotAcknowledged { requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) } - R.id.menu_capture_image -> startCameraCapture() - R.id.menu_capture_video -> startCameraCapture(captureVideo = true) - R.id.menu_clear -> clear() - R.id.menu_settings -> navigateSafe(R.id.action_mainFragment_to_overviewSettingsFragment).also { hideKeyboard() } - else -> return false - } - return true - } - } - - private fun closeInputSheets() { - mainViewModel.closeInputSheet(keepPreviousReply = false) - inputBottomSheetBehavior?.hide() - binding.input.dismissDropDown() - } - - private lateinit var tabAdapter: ChatTabAdapter - private lateinit var tabLayoutMediator: TabLayoutMediator - private lateinit var suggestionAdapter: SuggestionsArrayAdapter - private var currentMediaUri = Uri.EMPTY - private val tabSelectionListener = TabSelectionListener() - - private val requestImageCapture = registerForActivityResult(StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = true) } - private val requestVideoCapture = registerForActivityResult(StartActivityForResult()) { if (it.resultCode == Activity.RESULT_OK) handleCaptureRequest(imageCapture = false) } - private val requestGalleryMedia = registerForActivityResult(PickVisualMedia()) { uri -> - uri ?: return@registerForActivityResult - val contentResolver = activity?.contentResolver ?: return@registerForActivityResult - val context = context ?: return@registerForActivityResult - val mimeType = contentResolver.getType(uri) - val mimeTypeMap = MimeTypeMap.getSingleton() - val extension = mimeTypeMap.getExtensionFromMimeType(mimeType) - if (extension == null) { - showSnackBar(getString(R.string.snackbar_upload_failed)) - return@registerForActivityResult - } - - val copy = createMediaFile(context, extension) - try { - contentResolver.openInputStream(uri)?.run { copy.outputStream().use { copyTo(it) } } - if (copy.extension == "jpg" || copy.extension == "jpeg") { - copy.removeExifAttributes() - } - - mainViewModel.uploadMedia(copy, imageCapture = false) - } catch (_: Throwable) { - copy.delete() - showSnackBar(getString(R.string.snackbar_upload_failed)) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - tabAdapter = ChatTabAdapter(parentFragment = this) - bindingRef = MainFragmentBinding.inflate(inflater, container, false).apply { - updatePictureInPictureVisibility() - - inputBottomSheetBehavior = BottomSheetBehavior.from(inputSheetFragment).apply { - addBottomSheetCallback(inputSheetCallback) - skipCollapsed = true - } - chatViewpager.setup() - input.setup(this) - - fullscreenBottomSheetBehavior = BottomSheetBehavior.from(fullScreenSheetFragment).apply { setupFullScreenSheet() } - - tabLayoutMediator = TabLayoutMediator(tabs, chatViewpager) { tab, position -> - tab.text = tabAdapter.getFormattedChannel(position) - } - - tabs.setInitialColors() - - addChannelsButton.setOnClickListener { navigateSafe(R.id.action_mainFragment_to_addChannelDialogFragment) } - toggleFullscreen.setOnClickListener { mainViewModel.toggleFullscreen() } - toggleInput.setOnClickListener { mainViewModel.toggleInput() } - toggleStream.setOnClickListener { - mainViewModel.toggleStream() - root.requestApplyInsets() - } - changeRoomstate.setOnClickListener { showRoomStateDialog() } - showChips.setOnClickListener { mainViewModel.toggleChipsExpanded() } - var offset = 0f - splitThumb?.setOnTouchListener { v, event -> - when (event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - val guideline = splitGuideline ?: return@setOnTouchListener false - // Calculate centered position the same way for both LTR and RTL - val centered = event.rawX + offset + (v.width / 2f) - val percentValue = centered / root.width - guideline.updateLayoutParams { - // Only invert the percentage for RTL - val isRtl = root.layoutDirection == LayoutDirection.RTL - guidePercent = if (isRtl) { - (1f - percentValue).coerceIn(MIN_GUIDELINE_PERCENT, MAX_GUIDELINE_PERCENT) - } else { - percentValue.coerceIn(MIN_GUIDELINE_PERCENT, MAX_GUIDELINE_PERCENT) - } - } - true - } - - MotionEvent.ACTION_DOWN -> { - offset = v.x - event.rawX - true - } - - else -> false - } - } - } - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - postponeEnterTransition() - view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.colorBackground)) - view.doOnPreDraw { startPostponedEnterTransition() } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - activity?.setPictureInPictureParams( - PictureInPictureParams.Builder() - .setAspectRatio(Rational(16, 9)) - .build() - ) - } - - initPreferences() - binding.splitThumb?.background?.alpha = 150 - activity?.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.STARTED) - mainViewModel.apply { - setIsLandscape(isLandscape) - collectFlow(imageUploadState, ::handleImageUploadState) - collectFlow(dataLoadingState, ::handleDataLoadingState) - collectFlow(shouldShowUploadProgress) { activity?.invalidateMenu() } - collectFlow(suggestions, ::setSuggestions) - collectFlow(isFullscreenFlow) { changeActionBarVisibility(it) } - collectFlow(shouldShowInput) { - binding.inputLayout.isVisible = it - } - collectFlow(canType) { - binding.inputLayout.isEnabled = it - binding.input.isEnabled = it - when { - it -> binding.inputLayout.setupSendButton() - else -> with(binding.inputLayout) { - endIconDrawable = null - setEndIconTouchListener(null) - setEndIconOnClickListener(null) - setEndIconOnLongClickListener(null) - } - } - } - collectFlow(inputState) { state -> - binding.inputLayout.hint = when (state) { - InputState.Default -> getString(R.string.hint_connected) - InputState.Replying -> getString(R.string.hint_replying) - InputState.NotLoggedIn -> getString(R.string.hint_not_logged_int) - InputState.Disconnected -> getString(R.string.hint_disconnected) - } - } - collectFlow(bottomTextState) { (enabled, text) -> - binding.inputLayout.helperText = text - binding.inputLayout.isHelperTextEnabled = enabled - binding.fullscreenHintText.text = text - } - collectFlow(activeChannel) { channel -> - channel ?: return@collectFlow - (activity as? MainActivity)?.notificationService?.setActiveChannel(channel) // TODO move - val index = tabAdapter.indexOfChannel(channel) - binding.tabs.getTabAt(index)?.removeBadge() - mainViewModel.clearMentionCount(channel) - mainViewModel.clearUnreadMessage(channel) - } - collectFlow(shouldShowTabs) { binding.tabs.isVisible = it && !isInPictureInPictureMode } - collectFlow(shouldShowChipToggle) { binding.showChips.isVisible = it } - collectFlow(areChipsExpanded) { - val resourceId = if (it) R.drawable.ic_keyboard_arrow_up else R.drawable.ic_keyboard_arrow_down - binding.showChips.setChipIconResource(resourceId) - } - collectFlow(shouldShowExpandedChips) { - binding.toggleFullscreen.isVisible = it - binding.toggleInput.isVisible = it - } - collectFlow(shouldShowStreamToggle) { binding.toggleStream.isVisible = it } - collectFlow(hasModInChannel) { binding.changeRoomstate.isVisible = it } - collectFlow(shouldShowViewPager) { - binding.chatViewpager.isVisible = it && !isInPictureInPictureMode - binding.addChannelsText.isVisible = !it - binding.addChannelsButton.isVisible = !it - } - collectFlow(shouldShowEmoteMenuIcon) { showEmoteMenuIcon -> - when { - showEmoteMenuIcon -> binding.inputLayout.setupEmoteMenu() - else -> binding.inputLayout.startIconDrawable = null - } - } - collectFlow(shouldShowFullscreenHelper) { binding.fullscreenHintText.isVisible = it } - - collectFlow(events) { - when (it) { - is MainEvent.Error -> handleErrorEvent(it) - } - } - collectFlow(channelMentionCount, ::updateChannelMentionBadges) - collectFlow(unreadMessagesMap, ::updateUnreadChannelTabColors) - collectFlow(shouldColorNotification) { activity?.invalidateMenu() } - collectFlow(channels, mainViewModel::fetchStreamData) - collectFlow(currentStreamedChannel) { - val isActive = it != null - binding.streamWebviewWrapper.isVisible = isActive - if (!isLandscape) { - return@collectFlow - } - - binding.splitThumb?.isVisible = isActive && !isInPictureInPictureMode - binding.splitGuideline?.updateLayoutParams { - guidePercent = when { - isActive && isInPictureInPictureMode -> PIP_GUIDELINE_PERCENT - isActive -> DEFAULT_GUIDELINE_PERCENT - else -> DISABLED_GUIDELINE_PERCENT - } - } - } - collectFlow(shouldEnablePictureInPictureAutoMode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - activity?.setPictureInPictureParams( - PictureInPictureParams.Builder() - .setAutoEnterEnabled(it) - .build() - ) - } - } - collectFlow(useCustomBackHandling) { onBackPressedCallback.isEnabled = it } - collectFlow(dankChatViewModel.validationResult) { - if (isInPictureInPictureMode) { - return@collectFlow - } - - when (it) { - // wait for username to be validated before showing snackbar - is ValidationResult.User -> showSnackBar(getString(R.string.snackbar_login, it.username), onDismiss = ::openChangelogSheetIfNecessary) - is ValidationResult.IncompleteScopes -> MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.login_outdated_title) - .setMessage(R.string.login_outdated_message) - .setPositiveButton(R.string.oauth_expired_login_again) { _, _ -> openLogin() } - .setNegativeButton(R.string.dialog_dismiss) { _, _ -> openChangelogSheetIfNecessary() } - .create().show() - - ValidationResult.TokenInvalid -> { - mainViewModel.cancelDataLoad() - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.oauth_expired_title) - .setMessage(R.string.oauth_expired_message) - .setPositiveButton(R.string.oauth_expired_login_again) { _, _ -> openLogin() } - .setNegativeButton(R.string.dialog_dismiss) { _, _ -> openChangelogSheetIfNecessary() } // default action is dismissing anyway - .create().show() - } - - ValidationResult.Failure -> showSnackBar(getString(R.string.oauth_verify_failed), onDismiss = ::openChangelogSheetIfNecessary) - } - } - } - - val navBackStackEntry = navController.getBackStackEntry(R.id.mainFragment) - val handle = navBackStackEntry.savedStateHandle - val observer = LifecycleEventObserver { _, event -> - if (event != Lifecycle.Event.ON_RESUME) return@LifecycleEventObserver - handle.keys().forEach { key -> - when (key) { - LOGIN_REQUEST_KEY -> handle.withData(key, ::handleLoginRequest) - ADD_CHANNEL_REQUEST_KEY -> handle.withData(key, ::addChannel) - HISTORY_DISCLAIMER_KEY -> handle.withData(key, ::handleMessageHistoryDisclaimerResult) - USER_POPUP_RESULT_KEY -> handle.withData(key, ::handleUserPopupResult) - MESSAGE_SHEET_RESULT_KEY -> handle.withData(key, ::handleMessageSheetResult) - COPY_MESSAGE_SHEET_RESULT_KEY -> handle.withData(key, ::handleCopyMessageSheetResult) - EMOTE_SHEET_RESULT_KEY -> handle.withData(key, ::handleEmoteSheetResult) - LOGOUT_REQUEST_KEY -> handle.withData(key) { showLogoutConfirmationDialog() } - CHANNELS_REQUEST_KEY -> handle.withData>(key) { updateChannels(it.toList()) } - } - } - } - navBackStackEntry.lifecycle.addObserver(observer) - viewLifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_DESTROY) { - navBackStackEntry.lifecycle.removeObserver(observer) - } - }) - - if (dankChatPreferences.isLoggedIn && dankChatPreferences.userIdString == null) { - dankChatPreferences.userIdString = "${dankChatPreferences.userId}".toUserId() - } - - val channels = dankChatPreferences.channels - val withRenames = dankChatPreferences.getChannelsWithRenames(channels) - tabAdapter.updateFragments(withRenames) -// Setting a custom limit completely breaks the chat when in landscape, rtl and the split is moved around -// Use the default caching mechanism instead but limit the cache size of the underlying recyclerview -// binding.chatViewpager.offscreenPageLimit = OFFSCREEN_PAGE_LIMIT - runCatching { - with(binding.chatViewpager[0] as RecyclerView) { - setItemViewCacheSize(OFFSCREEN_PAGE_LIMIT * 2) - } - } - tabLayoutMediator.attach() - binding.tabs.addOnTabSelectedListener(tabSelectionListener) - - (requireActivity() as AppCompatActivity).apply { - setSupportActionBar(binding.toolbar) - onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) - - ViewCompat.setOnApplyWindowInsetsListener(binding.showChips) { v, insets -> - // additional margin for chips because of display cutouts/punch holes - val needsExtraMargin = bindingRef?.streamWebviewWrapper?.isVisible == true || v.resources.isLandscape || !mainViewModel.isFullscreen - val extraMargin = when { - needsExtraMargin -> 0 - else -> insets.getInsets(Type.displayCutout()).top - } - v.updateLayoutParams { - topMargin = 8.px + extraMargin - } - - WindowInsetsCompat.CONSUMED - } - - val deferringInsetsListener = RootViewDeferringInsetsCallback( - persistentInsetTypes = Type.systemBars() or Type.displayCutout(), - deferredInsetTypes = Type.ime(), - ignorePersistentInsetTypes = { mainViewModel.isFullscreen } - ) - ViewCompat.setWindowInsetsAnimationCallback(binding.root, deferringInsetsListener) - ViewCompat.setOnApplyWindowInsetsListener(binding.root, deferringInsetsListener) - ViewCompat.setWindowInsetsAnimationCallback( - binding.inputLayout, - TranslateDeferringInsetsAnimationCallback( - view = binding.inputLayout, - persistentInsetTypes = Type.systemBars(), - deferredInsetTypes = Type.ime(), - dispatchMode = WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE - ) - ) - ViewCompat.setWindowInsetsAnimationCallback( - binding.input, - ControlFocusInsetsAnimationCallback(binding.input) - ) - - ViewCompat.setOnApplyWindowInsetsListener(binding.fullscreenHintText) { v, compatInsets -> - val insets = compatInsets.toWindowInsets() - if (insets != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val bottomLeft = insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) - val bottomRight = insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) - if (bottomLeft == null || bottomRight == null) { - return@setOnApplyWindowInsetsListener compatInsets - } - - val isRtl = v.layoutDirection == View.LAYOUT_DIRECTION_RTL - val left = when { - mainViewModel.isFullscreen && (v.resources.isPortrait || isRtl || !mainViewModel.isStreamActive) -> bottomLeft.center.x - else -> 8.px - } - - val screenWidth = window.decorView.width - val right = when { - mainViewModel.isFullscreen && (v.resources.isPortrait || !isRtl || !mainViewModel.isStreamActive) -> screenWidth - bottomRight.center.x - else -> 8.px - } - - v.updateLayoutParams { - leftMargin = left - rightMargin = right - } - } - - compatInsets - } - - if (savedInstanceState == null && !mainViewModel.started) { - mainViewModel.started = true // TODO ??? - if (!dankChatPreferences.hasMessageHistoryAcknowledged) { - navigateSafe(R.id.action_mainFragment_to_messageHistoryDisclaimerDialogFragment) - } else { - mainViewModel.loadData(channels) - } - } - } - } - - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - bindingRef?.updatePictureInPictureVisibility(isInPictureInPictureMode) - } - - override fun onPause() { - binding.input.clearFocus() - mainViewModel.cancelStreamData() - super.onPause() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(CURRENT_STREAM_STATE, mainViewModel.currentStreamedChannel.value?.value) - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - savedInstanceState?.let { - mainViewModel.setCurrentStream(it.getString(CURRENT_STREAM_STATE)?.toUserName()) - } - } - - override fun onResume() { - super.onResume() - changeActionBarVisibility(mainViewModel.isFullscreenFlow.value) - bindingRef?.updatePictureInPictureVisibility() - - (activity as? MainActivity)?.apply { - val channel = channelToOpen - if (channel != null) { - val index = mainViewModel.getChannels().indexOf(channel) - if (index >= 0) { - when (index) { - bindingRef?.chatViewpager?.currentItem -> clearNotificationsOfChannel(channel) - else -> bindingRef?.chatViewpager?.post { bindingRef?.chatViewpager?.setCurrentItem(index, false) } - } - } - channelToOpen = null - } else { - val activeChannel = mainViewModel.getActiveChannel() ?: return - clearNotificationsOfChannel(activeChannel) - } - } - - if (mainViewModel.isFullScreenSheetClosed) { - val existing = childFragmentManager.fragments.filter { it is MentionFragment || it is RepliesFragment } - if (existing.isNotEmpty()) { - childFragmentManager.commitNow(allowStateLoss = true) { - existing.forEach(::remove) - } - fullscreenBottomSheetBehavior?.hide() - } - } - - when { - mainViewModel.isInputSheetClosed -> { - val existing = childFragmentManager.fragments.filter { it is ReplyInputSheetFragment || it is EmoteSheetFragment } - if (existing.isNotEmpty()) { - childFragmentManager.commitNow(allowStateLoss = true) { - existing.forEach(::remove) - } - inputBottomSheetBehavior?.hide() - bindingRef?.chatViewpager?.updateLayoutParams { bottomMargin = 0 } - } - } - - mainViewModel.isReplySheetOpen -> { - val reply = mainViewModel.currentReply ?: return - startReply(reply.replyMessageId, reply.replyName) - } - } - } - - override fun onDestroyView() { - binding.tabs.removeOnTabSelectedListener(tabSelectionListener) - binding.chatViewpager.unregisterOnPageChangeCallback(pageChangeCallback) - inputBottomSheetBehavior?.removeBottomSheetCallback(inputSheetCallback) - fullscreenBottomSheetBehavior?.removeBottomSheetCallback(fullScreenSheetCallback) - tabLayoutMediator.detach() - inputBottomSheetBehavior = null - fullscreenBottomSheetBehavior = null - binding.chatViewpager.adapter = null - bindingRef = null - super.onDestroyView() - } - - fun openUserPopup( - targetUserId: UserId, - targetUserName: UserName, - targetDisplayName: DisplayName, - channel: UserName?, - badges: List, - isWhisperPopup: Boolean = false - ) { - val directions = MainFragmentDirections.actionMainFragmentToUserPopupDialogFragment( - targetUserId = targetUserId, - targetUserName = targetUserName, - targetDisplayName = targetDisplayName, - channel = channel, - isWhisperPopup = isWhisperPopup, - badges = badges.toTypedArray(), - ) - navigateSafe(directions) - } - - fun openMessageSheet(messageId: String, channel: UserName?, fullMessage: String, canReply: Boolean, canModerate: Boolean) { - val directions = MainFragmentDirections.actionMainFragmentToMessageSheetFragment(messageId, channel, fullMessage, canReply, canModerate) - navigateSafe(directions) - } - - fun openEmoteSheet(emotes: List) { - val directions = MainFragmentDirections.actionMainFragmentToEmoteSheetFragment(emotes.toTypedArray()) - navigateSafe(directions) - } - - fun mentionUser(user: UserName, display: DisplayName) { - val template = notificationsSettingsDataStore.current().mentionFormat.template - val mention = "${template.replace("name", user.valueOrDisplayName(display))} " - insertText(mention) - } - - fun whisperUser(user: UserName) { - openMentionSheet(openWhisperTab = true) - - val current = binding.input.text.toString() - val command = "/w $user" - if (current.startsWith(command)) { - return - } - - val text = "$command $current" - binding.input.setText(text) - binding.input.setSelection(text.length) - } - - fun openReplies(replyMessageId: String) { - inputBottomSheetBehavior?.hide() - val fragment = RepliesFragment.newInstance(replyMessageId) - childFragmentManager.commitNow(allowStateLoss = true) { - replace(R.id.full_screen_sheet_fragment, fragment) - } - fullscreenBottomSheetBehavior?.expand() - } - - fun insertEmote(code: String, id: String) { - insertText("$code ") - mainViewModel.addEmoteUsage(id) - } - - private fun openChangelogSheetIfNecessary() { - if (dankChatPreferences.shouldShowChangelog()) { - navigateSafe(R.id.action_mainFragment_to_changelogSheetFragment) - } - } - - private fun openMentionSheet(openWhisperTab: Boolean = false) { - when { - openWhisperTab && mainViewModel.isWhisperTabOpen -> return - openWhisperTab && mainViewModel.isMentionTabOpen -> { - val fragment = childFragmentManager.fragments.filterIsInstance().firstOrNull() - if (fragment == null) { - createAndOpenMentionSheet(openWhisperTab = true) - return - } - - mainViewModel.setFullScreenSheetState(FullScreenSheetState.Whisper) - fragment.scrollToWhisperTab() - } - - else -> createAndOpenMentionSheet(openWhisperTab) - } - } - - private fun createAndOpenMentionSheet(openWhisperTab: Boolean = false) { - inputBottomSheetBehavior?.hide() - lifecycleScope.launch { - fullscreenBottomSheetBehavior?.awaitState(BottomSheetBehavior.STATE_HIDDEN) - val fragment = MentionFragment.newInstance(openWhisperTab) - childFragmentManager.commitNow(allowStateLoss = true) { - replace(R.id.full_screen_sheet_fragment, fragment) - } - fullscreenBottomSheetBehavior?.expand() - } - } - - private fun handleMessageSheetResult(result: MessageSheetResult) = when (result) { - is MessageSheetResult.OpenMoreActions -> openMoreActionsMessageSheet(result.messageId, result.fullMessage) - is MessageSheetResult.Copy -> copyAndShowSnackBar(result.message, R.string.snackbar_message_copied) - is MessageSheetResult.Reply -> startReply(result.replyMessageId, result.replyName) - is MessageSheetResult.ViewThread -> openReplies(result.rootThreadId) - } - - private fun openMoreActionsMessageSheet(messageId: String, fullMessage: String) { - val directions = MainFragmentDirections.actionMainFragmentToMoreActionsMessageSheetFragment(messageId, fullMessage) - navigateSafe(directions) - } - - private fun handleCopyMessageSheetResult(result: MoreActionsMessageSheetResult) = when (result) { - is MoreActionsMessageSheetResult.Copy -> copyAndShowSnackBar(result.message, R.string.snackbar_message_copied) - is MoreActionsMessageSheetResult.CopyId -> copyAndShowSnackBar(result.id, R.string.snackbar_message_id_copied) - } - - private fun handleEmoteSheetResult(result: EmoteSheetResult) = when (result) { - is EmoteSheetResult.Copy -> copyAndShowSnackBar(result.emoteName, R.string.emote_copied) - is EmoteSheetResult.Use -> insertEmote(result.emoteName, result.id) - } - - private fun copyAndShowSnackBar(value: String, @StringRes snackBarLabel: Int) { - getSystemService(requireContext(), ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText(CLIPBOARD_LABEL_MESSAGE, value)) - showSnackBar( - message = getString(snackBarLabel), - action = getString(R.string.snackbar_paste) to { - val preparedMessage = value - .withoutInvisibleChar - .withTrailingSpace - insertText(preparedMessage) - } - ) - } - - private fun startReply(replyMessageId: String, replyName: UserName) { - val fragment = ReplyInputSheetFragment.newInstance(replyMessageId, replyName) - childFragmentManager.commitNow(allowStateLoss = true) { - replace(R.id.input_sheet_fragment, fragment) - } - inputBottomSheetBehavior?.expand() - binding.root.post { - binding.chatViewpager.updateLayoutParams { - bottomMargin = binding.inputSheetFragment.height - } - } - } - - private fun insertText(text: String) { - if (!dankChatPreferences.isLoggedIn || !mainViewModel.shouldShowInput.value) { - return - } - - val current = binding.input.text.toString() - val index = binding.input.selectionStart.takeIf { it >= 0 } ?: current.length - val builder = StringBuilder(current).insert(index, text) - - binding.input.setText(builder.toString()) - binding.input.setSelection(index + text.length) - } - - private fun openLogin() { - val directions = MainFragmentDirections.actionMainFragmentToLoginFragment() - navigateSafe(directions) - hideKeyboard() - } - - private fun handleMessageHistoryDisclaimerResult(result: Boolean) { - dankChatPreferences.setCurrentInstalledVersionCode() - dankChatPreferences.hasMessageHistoryAcknowledged = true - lifecycleScope.launch { - chatSettingsDataStore.update { it.copy(loadMessageHistory = result) } - } - mainViewModel.loadData() - } - - private fun handleUserPopupResult(result: UserPopupResult) { - when (result) { - is UserPopupResult.Error -> showSnackBar(getString(R.string.user_popup_error, result.throwable?.message.orEmpty())) - is UserPopupResult.Mention -> { - lifecycleScope.launch { - if (mainViewModel.isMentionTabOpen) { - mainViewModel.setFullScreenSheetState(FullScreenSheetState.Closed) - fullscreenBottomSheetBehavior?.awaitState(BottomSheetBehavior.STATE_HIDDEN) - } - mentionUser(result.targetUser, result.targetDisplayName) - } - } - - is UserPopupResult.Whisper -> whisperUser(result.targetUser) - } - } - - private fun addChannel(channel: String) { - val lowerCaseChannel = channel.lowercase().removePrefix("#").toUserName() - var newTabIndex = mainViewModel.getChannels().indexOf(lowerCaseChannel) - if (newTabIndex == -1) { - val updatedChannels = mainViewModel.joinChannel(lowerCaseChannel) - newTabIndex = updatedChannels.lastIndex - mainViewModel.loadData(channelList = listOf(lowerCaseChannel)) - dankChatPreferences.channels = updatedChannels - - tabAdapter.addFragment(lowerCaseChannel) - } - binding.chatViewpager.setCurrentItem(newTabIndex, false) - - mainViewModel.setActiveChannel(lowerCaseChannel) - activity?.invalidateMenu() - } - - private fun sendMessage(): Boolean { - val msg = binding.input.text?.toString().orEmpty() - mainViewModel.trySendMessageOrCommand(msg) - binding.input.setText("") - - if (mainViewModel.isReplySheetOpen) { - inputBottomSheetBehavior?.hide() - } - - return true - } - - private fun repeatSendMessage(event: DankChatInputLayout.TouchEvent) { - val input = binding.input.text.toString().ifBlank { return } - mainViewModel.setRepeatedSend(event == DankChatInputLayout.TouchEvent.HOLD_START, input) - } - - private fun getLastMessage(): Boolean { - if (binding.input.text.isNotBlank()) { - return false - } - - val lastMessage = mainViewModel.getLastMessage() ?: return false - binding.input.setText(lastMessage) - binding.input.setSelection(lastMessage.length) - - return true - } - - private fun handleImageUploadState(result: ImageUploadState) { - when (result) { - is ImageUploadState.Loading, ImageUploadState.None -> return - is ImageUploadState.Failed -> showSnackBar( - message = result.errorMessage?.let { getString(R.string.snackbar_upload_failed_cause, it) } ?: getString(R.string.snackbar_upload_failed), - onDismiss = { result.mediaFile.delete() }, - action = getString(R.string.snackbar_retry) to { mainViewModel.uploadMedia(result.mediaFile, result.imageCapture) }) - - is ImageUploadState.Finished -> { - val clipboard = getSystemService(requireContext(), ClipboardManager::class.java) - clipboard?.setPrimaryClip(ClipData.newPlainText(CLIPBOARD_LABEL, result.url)) - showSnackBar( - message = getString(R.string.snackbar_image_uploaded, result.url), - action = getString(R.string.snackbar_paste) to { insertText(result.url) } - ) - } - } - } - - private fun handleDataLoadingState(result: DataLoadingState) { - when (result) { - is DataLoadingState.Loading, DataLoadingState.Finished, DataLoadingState.None -> return - is DataLoadingState.Reloaded -> showSnackBar(getString(R.string.snackbar_data_reloaded)) - is DataLoadingState.Failed -> { - val message = when (result.errorCount) { - 1 -> getString(R.string.snackbar_data_load_failed_cause, result.errorMessage) - else -> getString(R.string.snackbar_data_load_failed_multiple_causes, result.errorMessage) - } - showSnackBar( - message = message, - maxLines = 8, - duration = Snackbar.LENGTH_LONG, - action = getString(R.string.snackbar_retry) to { - mainViewModel.retryDataLoading(result.dataFailures, result.chatFailures) - }) - } - } - } - - private fun handleErrorEvent(event: MainEvent.Error) { - if (developerSettingsDataStore.current().debugMode) { - binding.root.showErrorDialog(event.throwable) - } - } - - private fun handleLoginRequest(success: Boolean) { - val name = dankChatPreferences.userName - if (success && name != null) { - mainViewModel.closeAndReconnect() - showSnackBar(getString(R.string.snackbar_login, name), onDismiss = ::openChangelogSheetIfNecessary) - } else { - dankChatPreferences.clearLogin() - showSnackBar(getString(R.string.snackbar_login_failed), onDismiss = ::openChangelogSheetIfNecessary) - } - } - - private fun handleCaptureRequest(imageCapture: Boolean) { - if (currentMediaUri == Uri.EMPTY) return - var mediaFile: File? = null - - try { - mediaFile = currentMediaUri.toFile() - currentMediaUri = Uri.EMPTY - mainViewModel.uploadMedia(mediaFile, imageCapture) - } catch (_: IOException) { - currentMediaUri = Uri.EMPTY - mediaFile?.delete() - showSnackBar(getString(R.string.snackbar_upload_failed)) - } - } - - private fun setSuggestions(suggestions: Triple, List, List>) { - if (binding.input.isPopupShowing) { - return - } - - suggestionAdapter.setSuggestions(suggestions) - } - - private inline fun showExternalHostingUploadDialogIfNotAcknowledged(crossinline action: () -> Unit) { - // show host name in dialog, another nice thing we get is it also detect some invalid URLs - val host = runCatching { - URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host - }.getOrElse { "" } - - // if config is invalid, just let the error handled by HTTP client - if (host.isNotBlank() && !dankChatPreferences.hasExternalHostingAcknowledged) { - val spannable = SpannableStringBuilder(getString(R.string.external_upload_disclaimer, host)) - Linkify.addLinks(spannable, Linkify.WEB_URLS) - - MaterialAlertDialogBuilder(requireContext()) - .setCancelable(false) - .setTitle(R.string.nuuls_upload_title) - .setMessage(spannable) - .setPositiveButton(R.string.dialog_ok) { dialog, _ -> - dialog.dismiss() - dankChatPreferences.hasExternalHostingAcknowledged = true - action() - } - .show().also { it.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance() } - } else { - action() - } - } - - private fun startCameraCapture(captureVideo: Boolean = false) { - val packageManager = activity?.packageManager ?: return - val (action, extension) = when { - captureVideo -> MediaStore.ACTION_VIDEO_CAPTURE to "mp4" - else -> MediaStore.ACTION_IMAGE_CAPTURE to "jpg" - } - showExternalHostingUploadDialogIfNotAcknowledged { - Intent(action).also { captureIntent -> - captureIntent.resolveActivity(packageManager)?.also { - try { - createMediaFile(requireContext(), extension).apply { currentMediaUri = toUri() } - } catch (ex: IOException) { - null - }?.also { - val uri = FileProvider.getUriForFile(requireContext(), "${BuildConfig.APPLICATION_ID}.fileprovider", it) - captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) - when { - captureVideo -> requestVideoCapture.launch(captureIntent) - else -> requestImageCapture.launch(captureIntent) - } - } - } - } - } - } - - private fun changeActionBarVisibility(isFullscreen: Boolean) { - if (isInPictureInPictureMode) { - return - } - - hideKeyboard() - (activity as? MainActivity)?.setFullScreen(isFullscreen) - } - - private fun clear() { - val position = binding.tabs.selectedTabPosition - val channel = tabAdapter[position] ?: return - mainViewModel.clear(channel) - } - - private fun reloadEmotes() { - val position = binding.tabs.selectedTabPosition - val channel = tabAdapter[position] ?: return - mainViewModel.reloadEmotes(channel) - } - - private fun initPreferences() { - if (dankChatPreferences.isLoggedIn && dankChatPreferences.oAuthKey.isNullOrBlank()) { - dankChatPreferences.clearLogin() - } - - collectFlow(chatSettingsDataStore.suggestions) { - binding.input.setSuggestionAdapter(it, suggestionAdapter) - } - } - - private fun showSnackBar( - message: String, - maxLines: Int = 1, - @BaseTransientBottomBar.Duration duration: Int = Snackbar.LENGTH_SHORT, - onDismiss: () -> Unit = {}, - action: Pair Unit>? = null - ) { - bindingRef?.let { binding -> - binding.inputLayout.post { - Snackbar.make(binding.coordinator, message, duration).apply { - if (binding.inputLayout.isVisible) { - anchorView = binding.inputLayout - } - setTextMaxLines(maxLines) - addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - when (event) { - BaseCallback.DISMISS_EVENT_CONSECUTIVE, BaseCallback.DISMISS_EVENT_TIMEOUT, BaseCallback.DISMISS_EVENT_SWIPE -> onDismiss() - else -> return - } - } - }) - action?.let { (msg, onAction) -> setAction(msg) { onAction() } } - - }.show() - } - } - } - - private fun showLogoutConfirmationDialog() = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.confirm_logout_title)) - .setMessage(getString(R.string.confirm_logout_message)) - .setPositiveButton(getString(R.string.confirm_logout_positive_button)) { dialog, _ -> - mainViewModel.clearDataForLogout() - dialog.dismiss() - } - .setNegativeButton(getString(R.string.dialog_cancel)) { dialog, _ -> dialog.dismiss() } - .create().show() - - private fun openChannel() { - val channel = mainViewModel.getActiveChannel() ?: return - val url = "https://twitch.tv/$channel" - Intent(Intent.ACTION_VIEW).also { - it.data = url.toUri() - startActivity(it) - } - } - - private fun reportChannel() { - val activeChannel = mainViewModel.getActiveChannel() ?: return - val url = "https://twitch.tv/$activeChannel/report" - Intent(Intent.ACTION_VIEW).also { - it.data = url.toUri() - startActivity(it) - } - } - - private fun blockChannel() { - closeInputSheets() - val activeChannel = mainViewModel.getActiveChannel() ?: return - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_channel_block_title) - .setMessage(getString(R.string.confirm_channel_block_message_named, activeChannel)) - .setPositiveButton(R.string.confirm_user_block_positive_button) { _, _ -> - mainViewModel.blockUser() - removeChannel() - showSnackBar(getString(R.string.channel_blocked_message)) - } - .setNegativeButton(R.string.dialog_cancel) { d, _ -> d.dismiss() } - .show() - } - - private fun removeChannel() { - closeInputSheets() - val activeChannel = mainViewModel.getActiveChannel() ?: return - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_channel_removal_title) - // should give user more info that it's gonna delete the currently active channel (unlike when clicking delete from manage channels list, where is very obvious) - .setMessage(getString(R.string.confirm_channel_removal_message_named, activeChannel)) - .setPositiveButton(R.string.confirm_channel_removal_positive_button) { _, _ -> - dankChatPreferences.removeChannel(activeChannel) - val withRenames = dankChatPreferences.getChannelsWithRenames() - updateChannels(withRenames) - } - .setNegativeButton(R.string.dialog_cancel) { _, _ -> } - .create().show() - } - - private fun openManageChannelsDialog() { - closeInputSheets() - val direction = MainFragmentDirections.actionMainFragmentToChannelsDialogFragment(channels = mainViewModel.getChannels().toTypedArray()) - navigateSafe(direction) - } - - private fun showRoomStateDialog() { - val currentRoomState = mainViewModel.currentRoomState ?: return - val activeStates = currentRoomState.activeStates - val choices = resources.getStringArray(R.array.roomstate_entries) - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_user_roomstate_title) - .setPositiveButton(R.string.dialog_ok) { d, _ -> d.dismiss() } - .setMultiChoiceItems(choices, activeStates) { d, index, isChecked -> - if (!isChecked) { - mainViewModel.changeRoomState(index, enabled = false) - d.dismiss() - return@setMultiChoiceItems - } - - when (index) { - 0, 1, 3 -> { - mainViewModel.changeRoomState(index, enabled = true) - d.dismiss() - } - - else -> { - val title = choices[index] - val hint = if (index == 2) R.string.seconds else R.string.minutes - val content = EditDialogBinding.inflate(LayoutInflater.from(requireContext()), null, false).apply { - dialogEdit.setText(10.toString()) - dialogEdit.inputType = EditorInfo.TYPE_CLASS_NUMBER - dialogEditLayout.setHint(hint) - } - - d.dismiss() - MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setView(content.root) - .setPositiveButton(R.string.dialog_ok) { editDialog, _ -> - val input = content.dialogEdit.text?.toString().orEmpty() - mainViewModel.changeRoomState(index, enabled = true, time = input) - editDialog.dismiss() - } - .setNegativeButton(R.string.dialog_cancel) { editDialog, _ -> - editDialog.dismiss() - } - .show() - } - } - } - .show() - } - - private fun updateChannels(updatedChannelsWithRenames: List) { - val updatedChannels = updatedChannelsWithRenames.map(ChannelWithRename::channel) - val oldChannels = mainViewModel.getChannels() - val oldIndex = binding.chatViewpager.currentItem - val oldActiveChannel = oldChannels.getOrNull(oldIndex) - - val index = updatedChannelsWithRenames - .indexOfFirst { it.channel == oldActiveChannel } - .coerceAtLeast(0) - val activeChannel = updatedChannels.getOrNull(index) - - tabAdapter.updateFragments(updatedChannelsWithRenames) - mainViewModel.updateChannels(updatedChannels) - mainViewModel.setActiveChannel(activeChannel) - - binding.chatViewpager.setCurrentItem(index, false) - binding.root.postDelayed(TAB_SCROLL_DELAY_MS) { - binding.tabs.setScrollPosition(index, 0f, false) - } - - activity?.invalidateMenu() - updateChannelMentionBadges(channels = mainViewModel.channelMentionCount.firstValueOrNull.orEmpty()) - updateUnreadChannelTabColors(channels = mainViewModel.unreadMessagesMap.firstValueOrNull.orEmpty()) - } - - private fun updateUnreadChannelTabColors(channels: Map) { - channels.forEach { (channel, _) -> - when (val index = tabAdapter.indexOfChannel(channel)) { - binding.chatViewpager.currentItem -> mainViewModel.clearUnreadMessage(channel) - else -> { - val tab = binding.tabs.getTabAt(index) - binding.tabs.post { tab?.setTextColor(R.attr.colorOnSurface) } - } - } - } - } - - private fun updateChannelMentionBadges(channels: Map) { - channels.forEach { (channel, count) -> - val index = tabAdapter.indexOfChannel(channel) - if (count > 0) { - when (index) { - binding.chatViewpager.currentItem -> mainViewModel.clearMentionCount(channel) // mention is in active channel - else -> binding.tabs.getTabAt(index)?.apply { orCreateBadge } - } - } else { - binding.tabs.getTabAt(index)?.removeBadge() - } - } - } - - private fun MainFragmentBinding.updatePictureInPictureVisibility(isInPictureInPicture: Boolean = isInPictureInPictureMode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - appbarLayout.isVisible = !isInPictureInPicture - tabs.isVisible = !isInPictureInPicture && mainViewModel.shouldShowTabs.value - chatViewpager.isVisible = !isInPictureInPicture && mainViewModel.shouldShowViewPager.value - fullScreenSheetFragment.isVisible = !isInPictureInPicture - inputSheetFragment.isVisible = !isInPictureInPicture - inputLayout.isVisible = !isInPictureInPicture && mainViewModel.shouldShowInput.value - fullscreenHintText.isVisible = !isInPictureInPicture && mainViewModel.shouldShowFullscreenHelper.value - showChips.isVisible = !isInPictureInPicture && mainViewModel.shouldShowChipToggle.value - splitThumb?.isVisible = !isInPictureInPicture && streamWebviewWrapper.isVisible - splitGuideline?.updateLayoutParams { - guidePercent = when { - !mainViewModel.isStreamActive -> DISABLED_GUIDELINE_PERCENT - isInPictureInPicture -> PIP_GUIDELINE_PERCENT - else -> DEFAULT_GUIDELINE_PERCENT - } - } - } - } - - private fun BottomSheetBehavior.setupFullScreenSheet() { - addBottomSheetCallback(fullScreenSheetCallback) - hide() - } - - private fun ViewPager2.setup() { - adapter = tabAdapter - reduceDragSensitivity() - registerOnPageChangeCallback(pageChangeCallback) - } - - private fun DankChatInputLayout.setupSendButton() { - setEndIconDrawable(R.drawable.ic_send) - val touchListenerAdded = when { - developerSettingsDataStore.current().repeatedSending -> { - setEndIconOnClickListener { } // for ripple effects - setEndIconTouchListener { holdTouchEvent -> - when (holdTouchEvent) { - DankChatInputLayout.TouchEvent.CLICK -> sendMessage() - DankChatInputLayout.TouchEvent.LONG_CLICK -> getLastMessage() - else -> repeatSendMessage(holdTouchEvent) - } - } - } - - else -> false - } - - if (!touchListenerAdded) { - setEndIconOnClickListener { sendMessage() } - setEndIconOnLongClickListener { getLastMessage() } - } - } - - private fun DankChatInputLayout.setupEmoteMenu() { - setStartIconDrawable(R.drawable.ic_insert_emoticon) - setStartIconOnClickListener { - if (mainViewModel.isEmoteSheetOpen) { - closeInputSheetAndSetState() - return@setStartIconOnClickListener - } - - if (isLandscape) { - hideKeyboard() - binding.input.clearFocus() - } - - childFragmentManager.commitNow(allowStateLoss = true) { - replace(R.id.input_sheet_fragment, EmoteMenuFragment()) - } - inputBottomSheetBehavior?.expand() - } - } - - private fun closeInputSheetAndSetState() { - val previousState = mainViewModel.closeInputSheet() - lifecycleScope.launch { - inputBottomSheetBehavior?.awaitState(BottomSheetBehavior.STATE_HIDDEN) - if (previousState is InputSheetState.Replying) { - startReply(previousState.replyMessageId, previousState.replyName) - } - } - } - - private val fullScreenSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - override fun onStateChanged(bottomSheet: View, newState: Int) { - val behavior = fullscreenBottomSheetBehavior ?: return - when { - behavior.isHidden -> { - mainViewModel.setFullScreenSheetState(FullScreenSheetState.Closed) - val channel = tabAdapter[binding.tabs.selectedTabPosition] ?: return - mainViewModel.setSuggestionChannel(channel) - - val existing = childFragmentManager.fragments.filter { it is MentionFragment || it is RepliesFragment } - childFragmentManager.commitNow(allowStateLoss = true) { - existing.forEach(::remove) - } - } - - behavior.isCollapsed -> behavior.hide() - } - } - } - - private val inputSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - - override fun onStateChanged(bottomSheet: View, newState: Int) { - val behavior = inputBottomSheetBehavior ?: return - if (!mainViewModel.isFullscreenFlow.value && isLandscape && mainViewModel.isEmoteSheetOpen) { - when (newState) { - BottomSheetBehavior.STATE_EXPANDED, BottomSheetBehavior.STATE_COLLAPSED -> { - (activity as? AppCompatActivity)?.supportActionBar?.hide() - // binding.tabs.visibility = View.GONE - } - - else -> { - (activity as? AppCompatActivity)?.supportActionBar?.show() - //binding.tabs.visibility = View.VISIBLE - } - } - } - - if (behavior.isHidden) { - val previousState = mainViewModel.closeInputSheet() - val existing = childFragmentManager.fragments.filter { it is EmoteMenuFragment || it is ReplyInputSheetFragment } - childFragmentManager.commitNow(allowStateLoss = true) { - existing.forEach(::remove) - } - when (previousState) { - is InputSheetState.Replying -> startReply(previousState.replyMessageId, previousState.replyName) - else -> binding.chatViewpager.updateLayoutParams { bottomMargin = 0 } - } - } - - //binding.streamWebviewWrapper.isVisible = !mainViewModel.isEmoteSheetOpen && mainViewModel.isStreamActive - } - } - - private fun DankChatInput.setup(binding: MainFragmentBinding) { - imeOptions = EditorInfo.IME_ACTION_SEND or EditorInfo.IME_FLAG_NO_FULLSCREEN - setRawInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) - setTokenizer(SpaceTokenizer()) - suggestionAdapter = SuggestionsArrayAdapter(binding.input.context, chatSettingsDataStore) { count -> - dropDownHeight = if (count > 4) { - (binding.root.height / 4.0).roundToInt() - } else { - ViewGroup.LayoutParams.WRAP_CONTENT - } - dropDownWidth = (binding.root.width * 0.6).roundToInt() - } - - setOnItemClickListener { parent, _, position, _ -> - val suggestion = parent.getItemAtPosition(position) - if (suggestion is Suggestion.EmoteSuggestion) { - mainViewModel.addEmoteUsage(suggestion.emote.id) - } - } - - setOnEditorActionListener { _, actionId, _ -> - return@setOnEditorActionListener when (actionId) { - EditorInfo.IME_ACTION_SEND -> sendMessage() - else -> false - } - } - setOnKeyListener { _, keyCode, _ -> - when (keyCode) { - KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { - if (!isItemSelected()) sendMessage() else false - } - - else -> false - } - } - - setOnFocusChangeListener { _, hasFocus -> - val isFullscreen = mainViewModel.isFullscreenFlow.value - (activity as? MainActivity)?.setFullScreen(isFullscreen, changeActionBarVisibility = false) - mainViewModel.setInputFocus(hasFocus) - - if (hasFocus && mainViewModel.isEmoteSheetOpen) { - closeInputSheetAndSetState() - } - - if (isPortrait) { - return@setOnFocusChangeListener - } - - - val hasHardwareKeyboard = resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS - binding.tabs.isVisible = (!hasFocus || hasHardwareKeyboard) && mainViewModel.shouldShowTabs.value - binding.streamWebviewWrapper.isVisible = (!hasFocus || hasHardwareKeyboard) && !mainViewModel.isEmoteSheetOpen && mainViewModel.isStreamActive - when { - hasFocus -> (activity as? MainActivity)?.supportActionBar?.hide() - !isFullscreen -> (activity as? MainActivity)?.supportActionBar?.show() - } - } - } - - companion object { - private const val DISABLED_GUIDELINE_PERCENT = 0f - private const val DEFAULT_GUIDELINE_PERCENT = 0.6f - private const val PIP_GUIDELINE_PERCENT = 1f - private const val MAX_GUIDELINE_PERCENT = 0.8f - private const val MIN_GUIDELINE_PERCENT = 0.2f - private const val CLIPBOARD_LABEL = "dankchat_media_url" - private const val CLIPBOARD_LABEL_MESSAGE = "dankchat_message" - private const val TAB_SCROLL_DELAY_MS = 1000 / 60 * 10L - private const val OFFSCREEN_PAGE_LIMIT = 2 - private const val CURRENT_STREAM_STATE = "current_stream_state" - - const val LOGOUT_REQUEST_KEY = "logout_key" - const val LOGIN_REQUEST_KEY = "login_key" - const val CHANNELS_REQUEST_KEY = "channels_key" - const val ADD_CHANNEL_REQUEST_KEY = "add_channel_key" - const val HISTORY_DISCLAIMER_KEY = "history_disclaimer_key" - const val USER_POPUP_RESULT_KEY = "user_popup_key" - const val MESSAGE_SHEET_RESULT_KEY = "message_sheet_key" - const val COPY_MESSAGE_SHEET_RESULT_KEY = "copy_message_sheet_key" - const val EMOTE_SHEET_RESULT_KEY = "emote_sheet_key" - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt deleted file mode 100644 index 1747932cb..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/MainViewModel.kt +++ /dev/null @@ -1,913 +0,0 @@ -package com.flxrs.dankchat.main - -import android.annotation.SuppressLint -import android.util.Log -import android.webkit.CookieManager -import android.webkit.WebStorage -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.chat.FullScreenSheetState -import com.flxrs.dankchat.chat.InputSheetState -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTab -import com.flxrs.dankchat.chat.emotemenu.EmoteMenuTabItem -import com.flxrs.dankchat.chat.suggestion.Suggestion -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.api.ApiException -import com.flxrs.dankchat.data.repo.IgnoresRepository -import com.flxrs.dankchat.data.repo.channel.ChannelRepository -import com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure -import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep -import com.flxrs.dankchat.data.repo.chat.ChatRepository -import com.flxrs.dankchat.data.repo.chat.UserStateRepository -import com.flxrs.dankchat.data.repo.chat.UsersRepository -import com.flxrs.dankchat.data.repo.chat.toMergedStrings -import com.flxrs.dankchat.data.repo.command.CommandRepository -import com.flxrs.dankchat.data.repo.command.CommandResult -import com.flxrs.dankchat.data.repo.data.DataLoadingFailure -import com.flxrs.dankchat.data.repo.data.DataLoadingStep -import com.flxrs.dankchat.data.repo.data.DataRepository -import com.flxrs.dankchat.data.repo.data.DataUpdateEventMessage -import com.flxrs.dankchat.data.repo.data.toMergedStrings -import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository -import com.flxrs.dankchat.data.repo.emote.Emotes -import com.flxrs.dankchat.data.state.DataLoadingState -import com.flxrs.dankchat.data.state.ImageUploadState -import com.flxrs.dankchat.data.twitch.chat.ConnectionState -import com.flxrs.dankchat.data.twitch.command.TwitchCommand -import com.flxrs.dankchat.data.twitch.emote.EmoteType -import com.flxrs.dankchat.data.twitch.message.RoomState -import com.flxrs.dankchat.data.twitch.message.SystemMessageType -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelBTTVEmotesFailed -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelFFZEmotesFailed -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelSevenTVEmoteAdded -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelSevenTVEmoteRemoved -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelSevenTVEmoteRenamed -import com.flxrs.dankchat.data.twitch.message.SystemMessageType.ChannelSevenTVEmoteSetChanged -import com.flxrs.dankchat.data.twitch.message.WhisperMessage -import com.flxrs.dankchat.domain.GetChannelsUseCase -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore -import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore -import com.flxrs.dankchat.utils.DateTimeUtils -import com.flxrs.dankchat.utils.extensions.flatMapLatestOrDefault -import com.flxrs.dankchat.utils.extensions.moveToFront -import com.flxrs.dankchat.utils.extensions.timer -import com.flxrs.dankchat.utils.extensions.toEmoteItems -import com.flxrs.dankchat.utils.removeExifAttributes -import io.ktor.serialization.JsonConvertException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.WhileSubscribed -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerializationException -import org.koin.android.annotation.KoinViewModel -import java.io.File -import kotlin.time.Duration.Companion.seconds - -@KoinViewModel -class MainViewModel( - private val chatRepository: ChatRepository, - private val dataRepository: DataRepository, - private val commandRepository: CommandRepository, - private val emoteUsageRepository: EmoteUsageRepository, - private val ignoresRepository: IgnoresRepository, - private val channelRepository: ChannelRepository, - private val usersRepository: UsersRepository, - private val userStateRepository: UserStateRepository, - private val dankChatPreferenceStore: DankChatPreferenceStore, - private val appearanceSettingsDataStore: AppearanceSettingsDataStore, - private val streamsSettingsDataStore: StreamsSettingsDataStore, - private val getChannelsUseCase: GetChannelsUseCase, - chatSettingsDataStore: ChatSettingsDataStore, -) : ViewModel() { - - private var fetchTimerJob: Job? = null - private var lastDataLoadingJob: Job? = null - - var started = false - - val activeChannel: StateFlow = chatRepository.activeChannel - - private val eventChannel = Channel(Channel.CONFLATED) - private val dataLoadingStateChannel = Channel(Channel.CONFLATED) - private val imageUploadStateChannel = Channel(Channel.CONFLATED) - private val isImageUploading = MutableStateFlow(false) - private val isDataLoading = MutableStateFlow(false) - private val streamData = MutableStateFlow>(emptyList()) - private val currentSuggestionChannel = MutableStateFlow(null) - private val inputSheetState = MutableStateFlow(InputSheetState.Closed) - private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) - private val _currentStreamedChannel = MutableStateFlow(null) - private val _isFullscreen = MutableStateFlow(false) - private val _isLandscape = MutableStateFlow(false) - private val isInputFocused = MutableStateFlow(false) - private val isScrolling = MutableStateFlow(false) - private val chipsExpanded = MutableStateFlow(false) - private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) - - private val emotes = currentSuggestionChannel - .flatMapLatestOrDefault(Emotes()) { dataRepository.getEmotes(it) } - - private val recentEmotes = emoteUsageRepository.getRecentUsages().distinctUntilChanged { old, new -> - new.all { newEmote -> old.any { it.emoteId == newEmote.emoteId } } - } - - private val roomStateText = combine( - chatSettingsDataStore.showChatModes, - currentSuggestionChannel - ) { roomStateEnabled, channel -> roomStateEnabled to channel } - .flatMapLatest { (enabled, channel) -> - when { - enabled && channel != null -> channelRepository.getRoomStateFlow(channel) - else -> flowOf(null) - } - } - .map { it?.toDisplayText()?.ifBlank { null } } - - private val users = currentSuggestionChannel.flatMapLatestOrDefault(emptySet()) { usersRepository.getUsersFlow(it) } - private val supibotCommands = currentSuggestionChannel.flatMapLatestOrDefault(emptyList()) { commandRepository.getSupibotCommands(it) } - private val currentStreamInformation = combine( - streamsSettingsDataStore.showStreamsInfo, - activeChannel, - streamData - ) { streamInfoEnabled, activeChannel, streamData -> - streamData.find { it.channel == activeChannel }?.formattedData?.takeIf { streamInfoEnabled } - } - - private val emoteSuggestions = emotes.mapLatest { emotes -> - emotes.suggestions.map { Suggestion.EmoteSuggestion(it) } - } - - private val userSuggestions = users.mapLatest { users -> - users.map { Suggestion.UserSuggestion(it) } - } - - private val supibotCommandSuggestions = supibotCommands.mapLatest { commands -> - commands.map { Suggestion.CommandSuggestion(it) } - } - - private val commandSuggestions = currentSuggestionChannel.flatMapLatestOrDefault(emptyList()) { - commandRepository.getCommandTriggers(it) - }.map { triggers -> triggers.map { Suggestion.CommandSuggestion(it) } } - - private val currentBottomText: Flow = - combine(roomStateText, currentStreamInformation, fullScreenSheetState) { roomState, streamInfo, chatSheetState -> - listOfNotNull(roomState, streamInfo) - .takeUnless { chatSheetState.isOpen } - ?.joinToString(separator = " - ") - .orEmpty() - } - - private val shouldShowBottomText: Flow = - combine( - chatSettingsDataStore.showChatModes, - streamsSettingsDataStore.showStreamsInfo, - fullScreenSheetState, - currentBottomText - ) { roomStateEnabled, streamInfoEnabled, chatSheetState, bottomText -> - (roomStateEnabled || streamInfoEnabled) && !chatSheetState.isOpen && bottomText.isNotBlank() - } - - private val loadingFailures = combine(dataRepository.dataLoadingFailures, chatRepository.chatLoadingFailures) { data, chat -> - data to chat - }.stateIn(viewModelScope, SharingStarted.Eagerly, Pair(emptySet(), emptySet())) - - private val connectionState = activeChannel - .flatMapLatestOrDefault(ConnectionState.DISCONNECTED) { chatRepository.getConnectionState(it) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), ConnectionState.DISCONNECTED) - - val channels: StateFlow?> = chatRepository.channels - .onEach { channels -> - if (channels != null && _currentStreamedChannel.value !in channels) { - _currentStreamedChannel.value = null - } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), null) - - init { - viewModelScope.launch { - repeatedSend.collectLatest { - if (it.enabled && it.message.isNotBlank()) { - while (isActive) { - val activeChannel = activeChannel.value ?: break - val delay = userStateRepository.getSendDelay(activeChannel) - trySendMessageOrCommand(it.message, skipSuspendingCommands = true) - delay(delay) - } - } - } - } - - viewModelScope.launch { - dataRepository.dataUpdateEvents.collect { updateEvent -> - when (updateEvent) { - is DataUpdateEventMessage.ActiveEmoteSetChanged -> chatRepository.makeAndPostSystemMessage( - type = ChannelSevenTVEmoteSetChanged(updateEvent.actorName, updateEvent.emoteSetName), - channel = updateEvent.channel - ) - - is DataUpdateEventMessage.EmoteSetUpdated -> { - val (channel, event) = updateEvent - event.added.forEach { chatRepository.makeAndPostSystemMessage(ChannelSevenTVEmoteAdded(event.actorName, it.name), channel) } - event.updated.forEach { chatRepository.makeAndPostSystemMessage(ChannelSevenTVEmoteRenamed(event.actorName, it.oldName, it.name), channel) } - event.removed.forEach { chatRepository.makeAndPostSystemMessage(ChannelSevenTVEmoteRemoved(event.actorName, it.name), channel) } - } - } - } - } - } - - val events = eventChannel.receiveAsFlow() - - val channelMentionCount: SharedFlow> = chatRepository.channelMentionCount - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), replay = 1) - val unreadMessagesMap: SharedFlow> = chatRepository.unreadMessagesMap - .mapLatest { map -> map.filterValues { it } } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), replay = 1) - - val imageUploadState = imageUploadStateChannel.receiveAsFlow() - val dataLoadingState = dataLoadingStateChannel.receiveAsFlow() - - val shouldColorNotification: StateFlow = - combine(chatRepository.hasMentions, chatRepository.hasWhispers) { hasMentions, hasWhispers -> - hasMentions || hasWhispers - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val shouldShowViewPager: StateFlow = channels.mapLatest { it?.isNotEmpty() != false } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), true) - - val shouldShowTabs: StateFlow = combine(shouldShowViewPager, _isFullscreen, _isLandscape, _currentStreamedChannel) { shouldShowViewPager, isFullscreen, isLandscape, currentStream -> - shouldShowViewPager && !isFullscreen && !(isLandscape && currentStream != null) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), true) - - val shouldShowInput: StateFlow = combine( - appearanceSettingsDataStore.showInput, - shouldShowViewPager - ) { inputEnabled, shouldShowViewPager -> - inputEnabled && shouldShowViewPager - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), true) - - val shouldShowUploadProgress = combine(isImageUploading, isDataLoading) { isUploading, isDataLoading -> - isUploading || isDataLoading - }.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val inputState: StateFlow = combine(connectionState, fullScreenSheetState, inputSheetState) { connectionState, chatSheetState, inputSheetState -> - val inputIsReply = inputSheetState is InputSheetState.Replying || (inputSheetState as? InputSheetState.Emotes)?.previousReply != null - when (connectionState) { - ConnectionState.CONNECTED -> when { - chatSheetState is FullScreenSheetState.Replies || inputIsReply -> InputState.Replying - else -> InputState.Default - } - - ConnectionState.CONNECTED_NOT_LOGGED_IN -> InputState.NotLoggedIn - ConnectionState.DISCONNECTED -> InputState.Disconnected - } - }.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), InputState.Disconnected) - - val canType: StateFlow = combine(connectionState, fullScreenSheetState) { connectionState, fullScreenSheetState -> - val canTypeInConnectionState = connectionState == ConnectionState.CONNECTED || !appearanceSettingsDataStore.settings.first().autoDisableInput - (fullScreenSheetState != FullScreenSheetState.Mention && canTypeInConnectionState) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - data class BottomTextState(val enabled: Boolean = true, val text: String = "") - - val bottomTextState: StateFlow = shouldShowBottomText.combine(currentBottomText) { enabled, text -> - BottomTextState(enabled, text) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), BottomTextState()) - - val shouldShowFullscreenHelper: StateFlow = - combine( - shouldShowInput, - shouldShowBottomText, - currentBottomText, - shouldShowViewPager - ) { shouldShowInput, shouldShowBottomText, bottomText, shouldShowViewPager -> - !shouldShowInput && shouldShowBottomText && bottomText.isNotBlank() && shouldShowViewPager - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val shouldShowEmoteMenuIcon: StateFlow = - combine(canType, fullScreenSheetState) { canType, chatSheetState -> - canType && !chatSheetState.isMentionSheet - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val suggestions: StateFlow, List, List>> = combine( - emoteSuggestions, - userSuggestions, - supibotCommandSuggestions, - commandSuggestions, - ) { emotes, users, supibotCommands, defaultCommands -> - Triple(users, emotes, defaultCommands + supibotCommands) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), Triple(emptyList(), emptyList(), emptyList())) - - val emoteTabItems: StateFlow> = combine(emotes, recentEmotes) { emotes, recentEmotes -> - withContext(Dispatchers.Default) { - val sortedEmotes = emotes.sorted - val availableRecents = recentEmotes.mapNotNull { usage -> - sortedEmotes - .firstOrNull { it.id == usage.emoteId } - ?.copy(emoteType = EmoteType.RecentUsageEmote) - } - - val groupedByType = sortedEmotes.groupBy { - when (it.emoteType) { - is EmoteType.ChannelTwitchEmote, - is EmoteType.ChannelTwitchBitEmote, - is EmoteType.ChannelTwitchFollowerEmote -> EmoteMenuTab.SUBS - - is EmoteType.ChannelFFZEmote, - is EmoteType.ChannelBTTVEmote, - is EmoteType.ChannelSevenTVEmote -> EmoteMenuTab.CHANNEL - - else -> EmoteMenuTab.GLOBAL - } - } - listOf( - async { EmoteMenuTabItem(EmoteMenuTab.RECENT, availableRecents.toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.SUBS, groupedByType[EmoteMenuTab.SUBS]?.moveToFront(activeChannel.value).toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, groupedByType[EmoteMenuTab.CHANNEL].toEmoteItems()) }, - async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, groupedByType[EmoteMenuTab.GLOBAL].toEmoteItems()) } - ).awaitAll() - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), EmoteMenuTab.entries.map { EmoteMenuTabItem(it, emptyList()) }) - - val isFullscreenFlow: StateFlow = _isFullscreen.asStateFlow() - val areChipsExpanded: StateFlow = chipsExpanded.asStateFlow() - - val shouldShowChipToggle: StateFlow = combine( - appearanceSettingsDataStore.showChips, - isScrolling, - ) { shouldShowChips, isScrolling -> - shouldShowChips && !isScrolling - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), true) - - val shouldShowExpandedChips: StateFlow = combine(shouldShowChipToggle, chipsExpanded) { shouldShowChips, chipsExpanded -> - shouldShowChips && chipsExpanded - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val shouldShowStreamToggle: StateFlow = - combine( - shouldShowExpandedChips, - activeChannel, - _currentStreamedChannel, - streamData - ) { canShowChips, activeChannel, currentStream, streamData -> - canShowChips && activeChannel != null && (currentStream != null || streamData.find { it.channel == activeChannel } != null) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val hasModInChannel: StateFlow = - combine(shouldShowExpandedChips, activeChannel, userStateRepository.userState) { canShowChips, channel, userState -> - canShowChips && channel != null && channel in userState.moderationChannels - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val useCustomBackHandling: StateFlow = combine(isFullscreenFlow, inputSheetState, fullScreenSheetState) { isFullscreen, inputSheetState, chatSheetState -> - isFullscreen || inputSheetState.isOpen || chatSheetState.isOpen - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val currentStreamedChannel: StateFlow = _currentStreamedChannel.asStateFlow() - - val shouldEnablePictureInPictureAutoMode: StateFlow = combine( - currentStreamedChannel, - streamsSettingsDataStore.pipEnabled, - ) { currentStream, pipEnabled -> - currentStream != null && pipEnabled - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeout = 5.seconds), false) - - val isStreamActive: Boolean - get() = currentStreamedChannel.value != null - val isFullscreen: Boolean - get() = isFullscreenFlow.value - val isEmoteSheetOpen: Boolean - get() = inputSheetState.value is InputSheetState.Emotes - val isReplySheetOpen: Boolean - get() = inputSheetState.value is InputSheetState.Replying - val currentReply: InputSheetState.Replying? - get() = inputSheetState.value as? InputSheetState.Replying - val isWhisperTabOpen: Boolean - get() = fullScreenSheetState.value is FullScreenSheetState.Whisper - val isMentionTabOpen: Boolean - get() = fullScreenSheetState.value is FullScreenSheetState.Mention - - val isFullScreenSheetClosed: Boolean - get() = fullScreenSheetState.value is FullScreenSheetState.Closed - val isInputSheetClosed: Boolean - get() = inputSheetState.value is InputSheetState.Closed - - val currentRoomState: RoomState? - get() { - val channel = currentSuggestionChannel.value ?: return null - return channelRepository.getRoomState(channel) - } - - fun cancelDataLoad() { - lastDataLoadingJob?.cancel() - viewModelScope.launch { - clearDataLoadingStates(DataLoadingState.None) - } - } - - fun loadData(channelList: List = channels.value.orEmpty()) { - val isLoggedIn = dankChatPreferenceStore.isLoggedIn - - lastDataLoadingJob?.cancel() - lastDataLoadingJob = viewModelScope.launch { - clearDataLoadingStates(DataLoadingState.Loading) - - dataRepository.createFlowsIfNecessary(channels = channelList + WhisperMessage.WHISPER_CHANNEL) - ignoresRepository.loadUserBlocks() - - val channels = getChannelsUseCase(channelList) - awaitAll( - async { dataRepository.loadDankChatBadges() }, - async { dataRepository.loadGlobalBadges() }, - async { commandRepository.loadSupibotCommands() }, - async { dataRepository.loadGlobalBTTVEmotes() }, - async { dataRepository.loadGlobalFFZEmotes() }, - async { dataRepository.loadGlobalSevenTVEmotes() }, - *channels.flatMap { (channelId, channel, channelDisplayName) -> - chatRepository.createFlowsIfNecessary(channel) - listOf( - async { dataRepository.loadChannelBadges(channel, channelId) }, - async { dataRepository.loadChannelBTTVEmotes(channel, channelDisplayName, channelId) }, - async { dataRepository.loadChannelFFZEmotes(channel, channelId) }, - async { dataRepository.loadChannelSevenTVEmotes(channel, channelId) }, - async { chatRepository.loadRecentMessagesIfEnabled(channel) }, - ) - }.toTypedArray() - ) - - chatRepository.reparseAllEmotesAndBadges() - - if (!isLoggedIn) { - checkFailuresAndEmitState() - return@launch - } - - val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channelList.size) - userState?.let { - dataRepository.loadUserStateEmotes(userState.globalEmoteSets, userState.followerEmoteSets) - } - - checkFailuresAndEmitState() - } - } - - fun retryDataLoading(dataLoadingFailures: Set, chatLoadingFailures: Set) { - lastDataLoadingJob?.cancel() - lastDataLoadingJob = viewModelScope.launch { - clearDataLoadingStates(DataLoadingState.Loading) - dataLoadingFailures.map { - async { - Log.d(TAG, "Retrying data loading step: $it") - when (it.step) { - is DataLoadingStep.GlobalSevenTVEmotes -> dataRepository.loadGlobalSevenTVEmotes() - is DataLoadingStep.GlobalBTTVEmotes -> dataRepository.loadGlobalBTTVEmotes() - is DataLoadingStep.GlobalFFZEmotes -> dataRepository.loadGlobalFFZEmotes() - is DataLoadingStep.GlobalBadges -> dataRepository.loadGlobalBadges() - is DataLoadingStep.DankChatBadges -> dataRepository.loadDankChatBadges() - is DataLoadingStep.ChannelBadges -> dataRepository.loadChannelBadges(it.step.channel, it.step.channelId) - is DataLoadingStep.ChannelSevenTVEmotes -> dataRepository.loadChannelSevenTVEmotes(it.step.channel, it.step.channelId) - is DataLoadingStep.ChannelFFZEmotes -> dataRepository.loadChannelFFZEmotes(it.step.channel, it.step.channelId) - is DataLoadingStep.ChannelBTTVEmotes -> dataRepository.loadChannelBTTVEmotes(it.step.channel, it.step.channelDisplayName, it.step.channelId) - } - } - } + chatLoadingFailures.map { - async { - Log.d(TAG, "Retrying chat loading step: $it") - when (it.step) { - is ChatLoadingStep.RecentMessages -> chatRepository.loadRecentMessagesIfEnabled(it.step.channel) - } - } - }.awaitAll() - - chatRepository.reparseAllEmotesAndBadges() - - checkFailuresAndEmitState() - } - } - - fun getLastMessage() = chatRepository.getLastMessage() - - fun getChannels(): List = channels.value.orEmpty() - - fun getActiveChannel(): UserName? = activeChannel.value - - fun blockUser() = viewModelScope.launch { - runCatching { - if (!dankChatPreferenceStore.isLoggedIn) { - return@launch - } - - val activeChannel = getActiveChannel() ?: return@launch - val channelId = channelRepository.getChannel(activeChannel)?.id ?: return@launch - ignoresRepository.addUserBlock(channelId, activeChannel) - } - } - - fun setActiveChannel(channel: UserName?) { - repeatedSend.update { it.copy(enabled = false) } - chatRepository.setActiveChannel(channel) - currentSuggestionChannel.value = channel - } - - fun setSuggestionChannel(channel: UserName) { - currentSuggestionChannel.value = channel - } - - fun setFullScreenSheetState(state: FullScreenSheetState) { - repeatedSend.update { it.copy(enabled = false) } - fullScreenSheetState.update { state } - when (state) { - FullScreenSheetState.Whisper -> chatRepository.clearMentionCount(WhisperMessage.WHISPER_CHANNEL) // TODO check clearing when already in whisper tab - FullScreenSheetState.Mention -> chatRepository.clearMentionCounts() - else -> Unit - } - } - - fun setEmoteInputSheetState() { - inputSheetState.update { - when (it) { - is InputSheetState.Emotes -> it - else -> InputSheetState.Emotes(previousReply = it as? InputSheetState.Replying) - } - } - } - - fun setReplyingInputSheetState(replyMessageId: String, replyName: UserName) { - inputSheetState.update { - InputSheetState.Replying(replyMessageId, replyName) - } - } - - fun closeInputSheet(keepPreviousReply: Boolean = true): InputSheetState { - inputSheetState.update { - (it as? InputSheetState.Emotes)?.previousReply?.takeIf { keepPreviousReply } ?: InputSheetState.Closed - } - return inputSheetState.value - } - - fun clear(channel: UserName) = chatRepository.clear(channel) - fun clearMentionCount(channel: UserName) = chatRepository.clearMentionCount(channel) - fun clearUnreadMessage(channel: UserName) = chatRepository.clearUnreadMessage(channel) - fun reconnect() = viewModelScope.launch { - chatRepository.reconnect() - dataRepository.reconnect() - } - - fun joinChannel(channel: UserName): List = chatRepository.joinChannel(channel) - fun trySendMessageOrCommand(message: String, skipSuspendingCommands: Boolean = false) = viewModelScope.launch { - val channel = currentSuggestionChannel.value ?: return@launch - val chatState = fullScreenSheetState.value - val inputSheetState = inputSheetState.value - val replyIdOrNull = chatState.replyIdOrNull ?: inputSheetState.replyIdOrNull - val commandResult = runCatching { - when (chatState) { - FullScreenSheetState.Whisper -> commandRepository.checkForWhisperCommand(message, skipSuspendingCommands) - else -> { - val roomState = currentRoomState ?: return@launch - val userState = userStateRepository.userState.value - val shouldSkip = skipSuspendingCommands || chatState is FullScreenSheetState.Replies - commandRepository.checkForCommands(message, channel, roomState, userState, shouldSkip) - } - } - }.getOrElse { - eventChannel.send(MainEvent.Error(it)) - return@launch - } - - when (commandResult) { - is CommandResult.Accepted, - is CommandResult.Blocked -> Unit - - is CommandResult.IrcCommand, - is CommandResult.NotFound -> chatRepository.sendMessage(message, replyIdOrNull) - - is CommandResult.AcceptedTwitchCommand -> { - if (commandResult.command == TwitchCommand.Whisper) { - chatRepository.fakeWhisperIfNecessary(message) - } - if (commandResult.response != null) { - chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) - } - } - - is CommandResult.AcceptedWithResponse -> chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) - is CommandResult.Message -> chatRepository.sendMessage(commandResult.message, replyIdOrNull) - } - - if (commandResult != CommandResult.NotFound && commandResult != CommandResult.IrcCommand) { - chatRepository.appendLastMessage(channel, message) - } - } - - fun setRepeatedSend(enabled: Boolean, message: String) = repeatedSend.update { - RepeatedSendData(enabled, message) - } - - fun updateChannels(channels: List) = viewModelScope.launch { - val removed = chatRepository.updateChannels(channels) - dataRepository.removeChannels(removed) - } - - fun closeAndReconnect() { - chatRepository.closeAndReconnect() - loadData() - } - - @SuppressLint("BuildListAdds") - fun reloadEmotes(channelName: UserName) = viewModelScope.launch { - val isLoggedIn = dankChatPreferenceStore.isLoggedIn - dataLoadingStateChannel.send(DataLoadingState.Loading) - isDataLoading.update { true } - - if (isLoggedIn) { - // reconnect to retrieve an an up-to-date GLOBALUSERSTATE - userStateRepository.clearUserStateEmotes() - chatRepository.reconnect(reconnectPubsub = false) - } - - val channel = channelRepository.getChannel(channelName) - - buildList { - this += launch { dataRepository.loadDankChatBadges() } - this += launch { dataRepository.loadGlobalBTTVEmotes() } - this += launch { dataRepository.loadGlobalFFZEmotes() } - this += launch { dataRepository.loadGlobalSevenTVEmotes() } - if (channel != null) { - this += launch { dataRepository.loadChannelBadges(channelName, channel.id) } - this += launch { dataRepository.loadChannelBTTVEmotes(channelName, channel.displayName, channel.id) } - this += launch { dataRepository.loadChannelFFZEmotes(channelName, channel.id) } - this += launch { dataRepository.loadChannelSevenTVEmotes(channelName, channel.id) } - } - }.joinAll() - - chatRepository.reparseAllEmotesAndBadges() - - if (!isLoggedIn) { - checkFailuresAndEmitState() - return@launch - } - - val channels = channels.value ?: listOf(channel) - val userState = userStateRepository.tryGetUserStateOrFallback(minChannelsSize = channels.size) - userState?.let { - dataRepository.loadUserStateEmotes(userState.globalEmoteSets, userState.followerEmoteSets) - } - - checkFailuresAndEmitState() - } - - fun uploadMedia(file: File, imageCapture: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - // only remove exif data if an image was selected - if (imageCapture) { - runCatching { file.removeExifAttributes() } - } - - imageUploadStateChannel.send(ImageUploadState.Loading) - isImageUploading.update { true } - val result = dataRepository.uploadMedia(file) - val state = result.fold( - onSuccess = { - file.delete() - ImageUploadState.Finished(it) - }, - onFailure = { - val message = when (it) { - is ApiException -> "${it.status} ${it.message}" - else -> it.stackTraceToString() - } - ImageUploadState.Failed(message, file, imageCapture) - } - ) - imageUploadStateChannel.send(state) - isImageUploading.update { false } - } - } - - fun fetchStreamData(channels: List? = this.channels.value) { - cancelStreamData() - channels?.ifEmpty { null } ?: return - - viewModelScope.launch { - val settings = streamsSettingsDataStore.settings.first() - if (!dankChatPreferenceStore.isLoggedIn || !settings.fetchStreams) { - return@launch - } - - fetchTimerJob = timer(STREAM_REFRESH_RATE) { - val data = dataRepository.getStreams(channels)?.map { - val uptime = DateTimeUtils.calculateUptime(it.startedAt) - val category = it.category - ?.takeIf { settings.showStreamCategory } - ?.ifBlank { null } - val formatted = dankChatPreferenceStore.formatViewersString(it.viewerCount, uptime, category) - - StreamData(channel = it.userLogin, formattedData = formatted) - }.orEmpty() - - streamData.value = data - } - } - } - - fun cancelStreamData() { - fetchTimerJob?.cancel() - fetchTimerJob = null - streamData.value = emptyList() - } - - fun setCurrentStream(value: UserName?) { - _currentStreamedChannel.update { value } - } - - fun toggleStream() { - chipsExpanded.update { false } - _currentStreamedChannel.update { - when (it) { - null -> activeChannel.value - else -> null - } - } - } - - fun setInputFocus(value: Boolean) { - isInputFocused.value = value - } - - fun toggleFullscreen() { - chipsExpanded.update { false } - _isFullscreen.update { !it } - } - - fun toggleInput() { - viewModelScope.launch { - appearanceSettingsDataStore.update { it.copy(showInput = !it.showInput) } - } - } - - fun setIsLandscape(value: Boolean) { - _isLandscape.update { value } - } - - fun isScrolling(value: Boolean) { - isScrolling.value = value - } - - fun toggleChipsExpanded() { - chipsExpanded.update { !it } - } - - fun changeRoomState(index: Int, enabled: Boolean, time: String = "") { - val base = when (index) { - 0 -> "/emoteonly" - 1 -> "/subscribers" - 2 -> "/slow" - 3 -> "/uniquechat" - else -> "/followers" - } - - val command = buildString { - append(base) - - if (!enabled) { - append("off") - } - - if (time.isNotBlank()) { - append(" $time") - } - } - - trySendMessageOrCommand(command) - } - - fun addEmoteUsage(id: String) = viewModelScope.launch { - emoteUsageRepository.addEmoteUsage(id) - } - - private suspend fun checkFailuresAndEmitState() { - val (dataFailures, chatFailures) = loadingFailures.value - dataFailures.forEach { - val status = (it.failure as? ApiException)?.status?.value?.toString() ?: "0" - when (it.step) { - is DataLoadingStep.ChannelSevenTVEmotes -> chatRepository.makeAndPostSystemMessage(SystemMessageType.ChannelSevenTVEmotesFailed(status), it.step.channel) - is DataLoadingStep.ChannelBTTVEmotes -> chatRepository.makeAndPostSystemMessage(ChannelBTTVEmotesFailed(status), it.step.channel) - is DataLoadingStep.ChannelFFZEmotes -> chatRepository.makeAndPostSystemMessage(ChannelFFZEmotesFailed(status), it.step.channel) - else -> Unit - } - } - - val steps = dataFailures.map(DataLoadingFailure::step).toMergedStrings() + chatFailures.map(ChatLoadingFailure::step).toMergedStrings() - val failures = dataFailures.map(DataLoadingFailure::failure) + chatFailures.map(ChatLoadingFailure::failure) - val state = when (val errorCount = dataFailures.size + chatFailures.size) { - 0 -> DataLoadingState.Finished - 1 -> { - val step = steps.firstOrNull() - val failure = failures.firstOrNull() - val message = buildString { - if (step != null) { - append(step) - append(": ") - } - append(failure?.toErrorMessage().orEmpty()) - } - - DataLoadingState.Failed(message, errorCount, dataFailures, chatFailures) - } - - else -> { - val message = failures - .groupBy { it.message }.values - .maxBy { it.size } - .let { - buildString { - append(steps.joinToString()) - - val error = it.firstOrNull()?.toErrorMessage() - if (error != null) { - append("\n") - append(error) - } - } - } - - DataLoadingState.Failed(message, errorCount, dataFailures, chatFailures) - } - } - - clearDataLoadingStates(state) - } - - private suspend fun clearDataLoadingStates(state: DataLoadingState) { - chatRepository.clearChatLoadingFailures() - dataRepository.clearDataLoadingFailures() - dataLoadingStateChannel.send(state) - isDataLoading.update { state is DataLoadingState.Loading } - } - - private fun Throwable.toErrorMessage(): String { - return when (this) { - is JsonConvertException -> (cause as? SerializationException ?: this).toString() - - is ApiException -> buildString { - append("${url}(${status.value})") - if (message != null) { - append(": $message") - } - } - - else -> toString() - } - } - - private fun clearEmoteUsages() = viewModelScope.launch { - emoteUsageRepository.clearUsages() - } - - private fun clearIgnores() = ignoresRepository.clearIgnores() - - fun clearDataForLogout() { - CookieManager.getInstance().removeAllCookies(null) - WebStorage.getInstance().deleteAllData() - - dankChatPreferenceStore.clearLogin() - userStateRepository.clear() - - closeAndReconnect() - clearIgnores() - clearEmoteUsages() - } - - companion object { - private val TAG = MainViewModel::class.java.simpleName - private val STREAM_REFRESH_RATE = 30.seconds - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt deleted file mode 100644 index edc46028e..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/RepeatedSendData.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.flxrs.dankchat.main - -data class RepeatedSendData(val enabled: Boolean, val message: String) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt deleted file mode 100644 index dc85ce129..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamData.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.flxrs.dankchat.main - -import com.flxrs.dankchat.data.UserName - -data class StreamData(val channel: UserName, val formattedData: String) \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamWebViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/StreamWebViewModel.kt deleted file mode 100644 index a793f6ff7..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/StreamWebViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.flxrs.dankchat.main - -import android.annotation.SuppressLint -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.main.stream.StreamWebView -import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore -import org.koin.android.annotation.KoinViewModel - -@KoinViewModel -class StreamWebViewModel( - application: Application, - private val streamsSettingsDataStore: StreamsSettingsDataStore, -) : AndroidViewModel(application) { - - private var lastStreamedChannel: UserName? = null - - @SuppressLint("StaticFieldLeak") - private var streamWebView: StreamWebView? = null - - fun getOrCreateStreamWebView(): StreamWebView { - return when { - !streamsSettingsDataStore.current().preventStreamReloads -> StreamWebView(getApplication()) - else -> streamWebView ?: StreamWebView(getApplication()).also { - streamWebView = it - } - } - } - - fun setStream(channel: UserName?, webView: StreamWebView) { - if (!streamsSettingsDataStore.current().preventStreamReloads) { - // Clear previous retained WebView instance - streamWebView?.let { - it.destroy() - streamWebView = null - lastStreamedChannel = null - } - - webView.setStream(channel) - return - } - - // Prevent unnecessary stream loading - if (channel == lastStreamedChannel) { - return - } - - lastStreamedChannel = channel - webView.setStream(channel) - } - - override fun onCleared() { - streamWebView?.destroy() - streamWebView = null - lastStreamedChannel = null - super.onCleared() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/TabSelectionListener.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/TabSelectionListener.kt deleted file mode 100644 index cd37ce843..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/TabSelectionListener.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.flxrs.dankchat.main - -import android.widget.TextView -import androidx.annotation.AttrRes -import androidx.core.view.get -import com.flxrs.dankchat.R -import com.google.android.material.color.MaterialColors -import com.google.android.material.tabs.TabLayout - -class TabSelectionListener : TabLayout.OnTabSelectedListener { - override fun onTabReselected(tab: TabLayout.Tab?) = Unit - - override fun onTabUnselected(tab: TabLayout.Tab?) { - tab?.setTextColor(R.attr.colorOnSurfaceVariant, layerWithSurface = true) - } - - override fun onTabSelected(tab: TabLayout.Tab?) { - tab?.setTextColor(R.attr.colorPrimary) - } -} - -fun TabLayout.setInitialColors() { - val surfaceVariant = MaterialColors.getColor(this, R.attr.colorOnSurfaceVariant) - val surface = MaterialColors.getColor(this, R.attr.colorSurface) - val primary = MaterialColors.getColor(this, R.attr.colorPrimary) - val layeredUnselectedColor = MaterialColors.layer(surfaceVariant, surface, UNSELECTED_TAB_OVERLAY_ALPHA) - setTabTextColors(layeredUnselectedColor, primary) -} - -@Suppress("USELESS_ELVIS") -fun TabLayout.Tab.setTextColor(@AttrRes id: Int, layerWithSurface: Boolean = false) { - val tabView = view ?: return - val textView = tabView[1] as? TextView ?: return - val textColor = MaterialColors.getColor(textView, id).let { color -> - when { - layerWithSurface -> { - val surface = MaterialColors.getColor(textView, R.attr.colorSurface) - MaterialColors.layer(color, surface, UNSELECTED_TAB_OVERLAY_ALPHA) - } - - else -> color - } - } - textView.setTextColor(textColor) -} - -private const val UNSELECTED_TAB_OVERLAY_ALPHA = 0.25f diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/AddChannelDialogFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/AddChannelDialogFragment.kt deleted file mode 100644 index e191a7a57..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/AddChannelDialogFragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.flxrs.dankchat.main.dialog - -import android.app.Dialog -import android.os.Bundle -import android.text.Editable -import android.view.inputmethod.EditorInfo -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.databinding.AddChannelDialogBinding -import com.flxrs.dankchat.main.MainFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class AddChannelDialogFragment : DialogFragment() { - - private var bindingRef: AddChannelDialogBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - bindingRef = AddChannelDialogBinding.inflate(layoutInflater, null, false) - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.add_dialog_title) - .setView(binding.root) - .setNegativeButton(R.string.dialog_cancel) { _, _ -> dismiss() } - .setPositiveButton(R.string.dialog_ok) { _, _ -> getInputAndDismiss(binding.dialogEdit.text) } - - binding.dialogEdit.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> getInputAndDismiss(binding.dialogEdit.text) - else -> false - } - } - return builder.create() - } - - override fun onDestroyView() { - super.onDestroyView() - bindingRef = null - } - - private fun getInputAndDismiss(input: Editable?): Boolean { - val trimmedInput = input?.toString()?.trim().orEmpty() - if (trimmedInput.isNotBlank()) { - with(findNavController()) { - getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.ADD_CHANNEL_REQUEST_KEY] = trimmedInput - } - } - dismiss() - return true - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/EditChannelDialogFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/EditChannelDialogFragment.kt deleted file mode 100644 index 6cdae03a2..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/EditChannelDialogFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.flxrs.dankchat.main.dialog - -import android.app.Dialog -import android.os.Bundle -import android.text.Editable -import android.view.inputmethod.EditorInfo -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.navArgs -import com.flxrs.dankchat.R -import com.flxrs.dankchat.data.toUserName -import com.flxrs.dankchat.databinding.EditDialogBinding -import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.koin.android.ext.android.inject - -class EditChannelDialogFragment : DialogFragment() { - - private val args: EditChannelDialogFragmentArgs by navArgs() - - private val dankChatPreferences: DankChatPreferenceStore by inject() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = EditDialogBinding.inflate(layoutInflater, null, false).apply { - dialogEdit.hint = args.channelWithRename.rename?.value ?: args.channelWithRename.channel.value - dialogEdit.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> getInputAndDismiss(dialogEdit.text) - else -> false - } - } - - dialogEditLayout.isEndIconVisible = args.channelWithRename.rename != null - dialogEditLayout.setEndIconOnClickListener { - val rename = args.channelWithRename.copy(rename = null) - dankChatPreferences.setRenamedChannel(rename) - dismiss() - } - } - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.edit_dialog_title) - .setView(binding.root) - .setNegativeButton(R.string.dialog_cancel) { _, _ -> dismiss() } - .setPositiveButton(R.string.dialog_ok) { _, _ -> getInputAndDismiss(binding.dialogEdit.text) } - - return builder.create() - } - - private fun getInputAndDismiss(input: Editable?): Boolean { - val trimmedInput = input?.toString()?.trim()?.ifBlank { null } - val rename = args.channelWithRename.copy(rename = trimmedInput?.toUserName()) - dankChatPreferences.setRenamedChannel(rename) - dismiss() - return true - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/MessageHistoryDisclaimerDialogFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/MessageHistoryDisclaimerDialogFragment.kt deleted file mode 100644 index 5bf93dcfe..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/dialog/MessageHistoryDisclaimerDialogFragment.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flxrs.dankchat.main.dialog - -import android.app.Dialog -import android.os.Bundle -import android.text.SpannableStringBuilder -import android.text.method.LinkMovementMethod -import android.text.util.Linkify -import android.widget.TextView -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.main.MainFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class MessageHistoryDisclaimerDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val spannable = SpannableStringBuilder(getString(R.string.message_history_disclaimer_message)) - Linkify.addLinks(spannable, Linkify.WEB_URLS) - - return MaterialAlertDialogBuilder(requireContext()) - .setCancelable(false) - .setTitle(R.string.message_history_disclaimer_title) - .setMessage(spannable) - .setPositiveButton(R.string.dialog_optin) { _, _ -> dismissAndHandleResult(true) } - .setNegativeButton(R.string.dialog_optout) { _, _ -> dismissAndHandleResult(false) } - .create() - } - - override fun onStart() { - super.onStart() - dialog?.apply { - setCanceledOnTouchOutside(false) - findViewById(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance() - } - } - - private fun dismissAndHandleResult(result: Boolean): Boolean { - findNavController() - .getBackStackEntry(R.id.mainFragment) - .savedStateHandle[MainFragment.HISTORY_DISCLAIMER_KEY] = result - dismiss() - return true - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt deleted file mode 100644 index 42aec0b94..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebView.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.flxrs.dankchat.main.stream - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.core.view.isVisible -import com.flxrs.dankchat.data.UserName - -@SuppressLint("SetJavaScriptEnabled") -class StreamWebView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = android.R.attr.webViewStyle, - defStyleRes: Int = 0 -) : WebView(context, attrs, defStyleAttr, defStyleRes) { - - init { - with(settings) { - javaScriptEnabled = true - setSupportZoom(false) - mediaPlaybackRequiresUserGesture = false - domStorageEnabled = true - } - webViewClient = StreamWebViewClient() - } - - fun setStream(channel: UserName?) { - val isActive = channel != null - isVisible = isActive - val url = when { - isActive -> "https://player.twitch.tv/?channel=$channel&enableExtensions=true&muted=false&parent=twitch.tv" - else -> BLANK_URL - } - - stopLoading() - loadUrl(url) - } - - private class StreamWebViewClient : WebViewClient() { - @Deprecated("Deprecated in Java") - override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { - if (url.isNullOrBlank()) { - return true - } - - return ALLOWED_PATHS.none { url.startsWith(it) } - } - - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val url = request?.url?.toString() - if (url.isNullOrBlank()) { - return true - } - - return ALLOWED_PATHS.none { url.startsWith(it) } - } - } - - companion object { - private const val BLANK_URL = "about:blank" - private val ALLOWED_PATHS = listOf( - BLANK_URL, - "https://id.twitch.tv/", - "https://www.twitch.tv/passport-callback", - "https://player.twitch.tv/", - ) - } - -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebViewWrapperFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebViewWrapperFragment.kt deleted file mode 100644 index a6e44fef6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/main/stream/StreamWebViewWrapperFragment.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.flxrs.dankchat.main.stream - -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.trackPipAnimationHintView -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.flxrs.dankchat.databinding.FragmentStreamWebViewWrapperBinding -import com.flxrs.dankchat.main.MainViewModel -import com.flxrs.dankchat.main.StreamWebViewModel -import com.flxrs.dankchat.utils.extensions.collectFlow -import kotlinx.coroutines.launch -import org.koin.androidx.viewmodel.ext.android.viewModel - -/** -This fragment's purpose is to manage the lifecycle of the WebView inside it -it removes the StreamWebView before the fragment is destroyed to prevent the WebView from being destroyed along with it. - */ -class StreamWebViewWrapperFragment : Fragment() { - private val mainViewModel: MainViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private val streamWebViewModel: StreamWebViewModel by viewModel(ownerProducer = { requireParentFragment() }) - private var bindingRef: FragmentStreamWebViewWrapperBinding? = null - private val binding get() = bindingRef!! - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View = FragmentStreamWebViewWrapperBinding.inflate(inflater, container, false).also { - bindingRef = it - }.root - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val streamWebView = streamWebViewModel.getOrCreateStreamWebView() - binding.streamWrapper.addView( - streamWebView, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - ) - collectFlow(mainViewModel.currentStreamedChannel) { - streamWebViewModel.setStream(it, streamWebView) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - activity?.trackPipAnimationHintView(streamWebView) - } - } - } - } - - override fun onDestroyView() { - binding.streamWrapper.removeAllViews() - bindingRef = null - super.onDestroyView() - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt index 5e6a2eb84..bf5585206 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/DankChatPreferenceStore.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.flxrs.dankchat.preferences import android.content.Context @@ -7,20 +5,20 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.R -import com.flxrs.dankchat.changelog.DankChatVersion import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName -import com.flxrs.dankchat.data.toDisplayName -import com.flxrs.dankchat.data.toUserId -import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.data.toUserNames import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore import com.flxrs.dankchat.preferences.model.ChannelWithRename +import com.flxrs.dankchat.ui.changelog.DankChatVersion import com.flxrs.dankchat.utils.extensions.decodeOrNull import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json import org.koin.core.annotation.Single @@ -29,6 +27,7 @@ class DankChatPreferenceStore( private val context: Context, private val json: Json, private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val authDataStore: AuthDataStore, ) { private val dankChatPreferences: SharedPreferences = context.getSharedPreferences(context.getString(R.string.shared_preference_key), Context.MODE_PRIVATE) @@ -36,43 +35,43 @@ class DankChatPreferenceStore( get() = dankChatPreferences.getString(RENAME_KEY, null) set(value) = dankChatPreferences.edit { putString(RENAME_KEY, value) } - var isLoggedIn: Boolean - get() = dankChatPreferences.getBoolean(LOGGED_IN_KEY, false) - set(value) = dankChatPreferences.edit { putBoolean(LOGGED_IN_KEY, value) } - - var oAuthKey: String? - get() = dankChatPreferences.getString(OAUTH_KEY, null) - set(value) = dankChatPreferences.edit { putString(OAUTH_KEY, value) } - - var clientId: String - get() = dankChatPreferences.getString(CLIENT_ID_KEY, null) ?: DEFAULT_CLIENT_ID - set(value) = dankChatPreferences.edit { putString(CLIENT_ID_KEY, value) } + val isLoggedIn: Boolean get() = authDataStore.isLoggedIn + val oAuthKey: String? get() = authDataStore.oAuthKey + val clientId: String get() = authDataStore.clientId var channels: List - get() = dankChatPreferences.getString(CHANNELS_AS_STRING_KEY, null)?.split(',').orEmpty().toUserNames() + get() = + dankChatPreferences + .getString(CHANNELS_AS_STRING_KEY, null) + ?.split(',') + .orEmpty() + .toUserNames() set(value) { - val channels = value - .takeIf { it.isNotEmpty() } - ?.joinToString(separator = ",") + val channels = + value + .takeIf { it.isNotEmpty() } + ?.joinToString(separator = ",") dankChatPreferences.edit { putString(CHANNELS_AS_STRING_KEY, channels) } } - var userName: UserName? - get() = dankChatPreferences.getString(NAME_KEY, null)?.ifBlank { null }?.toUserName() - set(value) = dankChatPreferences.edit { putString(NAME_KEY, value?.value?.ifBlank { null }) } + val userName: UserName? get() = authDataStore.userName var displayName: DisplayName? - get() = dankChatPreferences.getString(DISPLAY_NAME_KEY, null)?.ifBlank { null }?.toDisplayName() - set(value) = dankChatPreferences.edit { putString(DISPLAY_NAME_KEY, value?.value?.ifBlank { null }) } + get() = authDataStore.displayName + set(value) { + authDataStore.updateAsync { it.copy(displayName = value?.value) } + } + + var userIdString: UserId? + get() = authDataStore.userIdString + set(value) { + authDataStore.updateAsync { it.copy(userId = value?.value) } + } var userId: Int get() = dankChatPreferences.getInt(ID_KEY, 0) set(value) = dankChatPreferences.edit { putInt(ID_KEY, value) } - var userIdString: UserId? - get() = dankChatPreferences.getString(ID_STRING_KEY, null)?.ifBlank { null }?.toUserId() - set(value) = dankChatPreferences.edit { putString(ID_STRING_KEY, value?.value?.ifBlank { null }) } - var hasExternalHostingAcknowledged: Boolean get() = dankChatPreferences.getBoolean(EXTERNAL_HOSTING_ACK_KEY, false) set(value) = dankChatPreferences.edit { putBoolean(EXTERNAL_HOSTING_ACK_KEY, value) } @@ -81,37 +80,37 @@ class DankChatPreferenceStore( get() = dankChatPreferences.getBoolean(MESSAGES_HISTORY_ACK_KEY, false) set(value) = dankChatPreferences.edit { putBoolean(MESSAGES_HISTORY_ACK_KEY, value) } + var keyboardHeightPortrait: Int + get() = dankChatPreferences.getInt(KEYBOARD_HEIGHT_PORTRAIT_KEY, 0) + set(value) = dankChatPreferences.edit { putInt(KEYBOARD_HEIGHT_PORTRAIT_KEY, value) } + + var keyboardHeightLandscape: Int + get() = dankChatPreferences.getInt(KEYBOARD_HEIGHT_LANDSCAPE_KEY, 0) + set(value) = dankChatPreferences.edit { putInt(KEYBOARD_HEIGHT_LANDSCAPE_KEY, value) } + var isSecretDankerModeEnabled: Boolean get() = dankChatPreferences.getBoolean(SECRET_DANKER_MODE_KEY, false) set(value) = dankChatPreferences.edit { putBoolean(SECRET_DANKER_MODE_KEY, value) } val secretDankerModeClicks: Int = SECRET_DANKER_MODE_CLICKS - val currentUserAndDisplayFlow: Flow> = callbackFlow { - send(userName to displayName) - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == NAME_KEY || key == DISPLAY_NAME_KEY) { - trySend(userName to displayName) - } - } - - dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) - awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } - } - - fun formatViewersString(viewers: Int, uptime: String, category: String?): String { - return when (category) { - null -> context.resources.getQuantityString(R.plurals.viewers_and_uptime, viewers, viewers, uptime) - else -> context.resources.getQuantityString(R.plurals.viewers_and_uptime_with_cateogry, viewers, viewers, category, uptime) - } - } - - fun clearLogin() = dankChatPreferences.edit { - putBoolean(LOGGED_IN_KEY, false) - putString(OAUTH_KEY, null) - putString(NAME_KEY, null) - putString(ID_STRING_KEY, null) - putString(CLIENT_ID_KEY, null) + val isLoggedInFlow: Flow = + authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() + + val currentUserAndDisplayFlow: Flow> = + authDataStore.settings + .map { authDataStore.userName to authDataStore.displayName } + .distinctUntilChanged() + + fun formatViewersString( + viewers: Int, + uptime: String, + category: String?, + ): String = when (category) { + null -> context.resources.getQuantityString(R.plurals.viewers_and_uptime, viewers, viewers, uptime) + else -> context.resources.getQuantityString(R.plurals.viewers_and_uptime_with_cateogry, viewers, viewers, category, uptime) } fun removeChannel(channel: UserName): List { @@ -136,11 +135,12 @@ class DankChatPreferenceStore( fun getChannelsWithRenamesFlow(): Flow> = callbackFlow { send(getChannelsWithRenames()) - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == RENAME_KEY || key == CHANNELS_AS_STRING_KEY) { - trySend(getChannelsWithRenames()) + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == RENAME_KEY || key == CHANNELS_AS_STRING_KEY) { + trySend(getChannelsWithRenames()) + } } - } dankChatPreferences.registerOnSharedPreferenceChangeListener(listener) awaitClose { dankChatPreferences.unregisterOnSharedPreferenceChangeListener(listener) } @@ -195,22 +195,16 @@ class DankChatPreferenceStore( set(value) = dankChatPreferences.edit { putString(LAST_INSTALLED_VERSION_KEY, value) } companion object { - private const val LOGGED_IN_KEY = "loggedIn" - private const val OAUTH_KEY = "oAuthKey" - private const val CLIENT_ID_KEY = "clientIdKey" - private const val NAME_KEY = "nameKey" - private const val DISPLAY_NAME_KEY = "displayNameKey" + private const val ID_KEY = "idKey" private const val RENAME_KEY = "renameKey" private const val CHANNELS_AS_STRING_KEY = "channelsAsStringKey" - private const val ID_KEY = "idKey" - private const val ID_STRING_KEY = "idStringKey" private const val EXTERNAL_HOSTING_ACK_KEY = "nuulsAckKey" // the key is old key to prevent triggering the dialog for existing users private const val MESSAGES_HISTORY_ACK_KEY = "messageHistoryAckKey" + private const val KEYBOARD_HEIGHT_PORTRAIT_KEY = "keyboardHeightPortraitKey" + private const val KEYBOARD_HEIGHT_LANDSCAPE_KEY = "keyboardHeightLandscapeKey" private const val SECRET_DANKER_MODE_KEY = "secretDankerModeKey" private const val LAST_INSTALLED_VERSION_KEY = "lastInstalledVersionKey" private const val SECRET_DANKER_MODE_CLICKS = 5 - - const val DEFAULT_CLIENT_ID = "xu7vd1i6tlr0ak45q1li2wdc0lrma8" } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt deleted file mode 100644 index d7da086ee..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutFragment.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.flxrs.dankchat.preferences.about - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.theme.DankChatTheme -import com.flxrs.dankchat.utils.compose.textLinkStyles -import com.google.android.material.transition.MaterialFadeThrough -import com.mikepenz.aboutlibraries.Libs -import com.mikepenz.aboutlibraries.entity.Library -import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer -import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent -import com.mikepenz.aboutlibraries.util.withContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import sh.calvin.autolinktext.TextRuleDefaults -import sh.calvin.autolinktext.annotateString - -class AboutFragment : Fragment() { - - private val navController: NavController by lazy { findNavController() } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - DankChatTheme { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - Scaffold( - contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), - topBar = { - TopAppBar( - scrollBehavior = scrollBehavior, - title = { Text(stringResource(R.string.open_source_licenses)) }, - navigationIcon = { - IconButton( - onClick = { navController.popBackStack() }, - content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }, - ) - } - ) - }, - ) { padding -> - val context = LocalContext.current - val libraries = produceState(null) { - value = withContext(Dispatchers.IO) { - Libs.Builder().withContext(context).build() - } - } - var selectedLibrary by remember { mutableStateOf(null) } - LibrariesContainer( - libraries = libraries.value, - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - onLibraryClick = { selectedLibrary = it }, - ) - selectedLibrary?.let { library -> - val linkStyles = textLinkStyles() - val rules = TextRuleDefaults.defaultList() - val license = remember(library, rules) { - val mappedRules = rules.map { it.copy(styles = linkStyles) } - library.htmlReadyLicenseContent - .takeIf { it.isNotEmpty() } - ?.let { content -> - val html = AnnotatedString.fromHtml( - htmlString = content, - linkStyles = linkStyles, - ) - mappedRules.annotateString(html.text) - } - } - if (license != null) { - AlertDialog( - onDismissRequest = { selectedLibrary = null }, - title = { Text(text = library.name) }, - confirmButton = { - TextButton( - onClick = { selectedLibrary = null }, - content = { Text(stringResource(R.string.dialog_ok)) }, - ) - }, - text = { - Text( - text = license, - modifier = Modifier.verticalScroll(rememberScrollState()), - ) - } - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt new file mode 100644 index 000000000..f426a5913 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/about/AboutScreen.kt @@ -0,0 +1,152 @@ +package com.flxrs.dankchat.preferences.about + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.utils.compose.BottomSheetNestedScrollConnection +import com.flxrs.dankchat.utils.compose.textLinkStyles +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent +import com.mikepenz.aboutlibraries.util.withContext +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject +import sh.calvin.autolinktext.TextRuleDefaults +import sh.calvin.autolinktext.annotateString + +@Composable +fun AboutScreen(onBack: () -> Unit) { + val dispatchersProvider: DispatchersProvider = koinInject() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), + topBar = { + TopAppBar( + scrollBehavior = scrollBehavior, + title = { Text(stringResource(R.string.open_source_licenses)) }, + navigationIcon = { + IconButton( + onClick = onBack, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, + ) + }, + ) + }, + ) { padding -> + val context = LocalContext.current + val libraries = + produceState(null) { + value = + withContext(dispatchersProvider.io) { + Libs.Builder().withContext(context).build() + } + } + var selectedLibrary by remember { mutableStateOf(null) } + LibrariesContainer( + libraries = libraries.value, + modifier = + Modifier + .fillMaxSize() + .padding(padding), + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + onLibraryClick = { selectedLibrary = it }, + ) + selectedLibrary?.let { library -> + LibraryLicenseSheet( + library = library, + onDismiss = { selectedLibrary = null }, + ) + } + } +} + +@Composable +private fun LibraryLicenseSheet( + library: Library, + onDismiss: () -> Unit, +) { + val linkStyles = textLinkStyles() + val rules = TextRuleDefaults.defaultList() + val license = + remember(library, rules) { + val mappedRules = rules.map { it.copy(styles = linkStyles) } + library.htmlReadyLicenseContent + .takeIf { it.isNotEmpty() } + ?.let { content -> + val html = + AnnotatedString.fromHtml( + htmlString = content, + linkStyles = linkStyles, + ) + mappedRules.annotateString(html.text) + } + } ?: return + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentWindowInsets = { BottomSheetDefaults.windowInsets.exclude(WindowInsets.navigationBars) }, + ) { + Text( + text = library.name, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(12.dp)) + val navBarBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val scrollState = rememberScrollState() + Text( + text = license, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .weight(1f, fill = false) + .nestedScroll(BottomSheetNestedScrollConnection) + .verticalScroll(scrollState) + .padding(start = 16.dp, end = 16.dp, bottom = navBarBottom), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettings.kt index 9c07969a8..89f060bf1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettings.kt @@ -1,11 +1,28 @@ package com.flxrs.dankchat.preferences.appearance +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.flxrs.dankchat.R import kotlinx.serialization.Serializable +@Serializable +enum class InputAction { + Search, + LastMessage, + Stream, + ModActions, + Fullscreen, + HideInput, + Debug, +} + @Serializable data class AppearanceSettings( val theme: ThemePreference = ThemePreference.System, val trueDarkTheme: Boolean = false, + val accentColor: AccentColor? = null, + val paletteStyle: PaletteStylePreference = PaletteStylePreference.SystemDefault, val fontSize: Int = 14, val keepScreenOn: Boolean = true, val lineSeparator: Boolean = false, @@ -14,6 +31,56 @@ data class AppearanceSettings( val autoDisableInput: Boolean = true, val showChips: Boolean = true, val showChangelogs: Boolean = true, + val showCharacterCounter: Boolean = false, + val showClearInputButton: Boolean = true, + val showSendButton: Boolean = true, + val swipeNavigation: Boolean = true, + val inputActions: List = + listOf( + InputAction.Stream, + InputAction.ModActions, + InputAction.Search, + InputAction.LastMessage, + ), ) enum class ThemePreference { System, Dark, Light } + +@Immutable +@Serializable +enum class PaletteStylePreference( + @StringRes val labelRes: Int, + @StringRes val descriptionRes: Int, + val isStandard: Boolean = true, +) { + SystemDefault(R.string.palette_style_system_default, R.string.palette_style_system_default_desc), + TonalSpot(R.string.palette_style_tonal_spot, R.string.palette_style_tonal_spot_desc), + Neutral(R.string.palette_style_neutral, R.string.palette_style_neutral_desc), + Vibrant(R.string.palette_style_vibrant, R.string.palette_style_vibrant_desc), + Expressive(R.string.palette_style_expressive, R.string.palette_style_expressive_desc), + Rainbow(R.string.palette_style_rainbow, R.string.palette_style_rainbow_desc, isStandard = false), + FruitSalad(R.string.palette_style_fruit_salad, R.string.palette_style_fruit_salad_desc, isStandard = false), + Monochrome(R.string.palette_style_monochrome, R.string.palette_style_monochrome_desc, isStandard = false), + Fidelity(R.string.palette_style_fidelity, R.string.palette_style_fidelity_desc, isStandard = false), + Content(R.string.palette_style_content, R.string.palette_style_content_desc, isStandard = false), +} + +@Immutable +@Serializable +enum class AccentColor( + val seedColor: Color, + @StringRes val labelRes: Int, +) { + Blue(Color(0xFF1B6EF3), R.string.accent_color_blue), + Teal(Color(0xFF00796B), R.string.accent_color_teal), + Green(Color(0xFF2E7D32), R.string.accent_color_green), + Lime(Color(0xFF689F38), R.string.accent_color_lime), + Yellow(Color(0xFFF9A825), R.string.accent_color_yellow), + Orange(Color(0xFFEF6C00), R.string.accent_color_orange), + Red(Color(0xFFC62828), R.string.accent_color_red), + Pink(Color(0xFFAD1457), R.string.accent_color_pink), + Purple(Color(0xFF6A1B9A), R.string.accent_color_purple), + Indigo(Color(0xFF283593), R.string.accent_color_indigo), + Brown(Color(0xFF4E342E), R.string.accent_color_brown), + Grey(Color(0xFF546E7A), R.string.accent_color_grey), +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsDataStore.kt index 0679349d0..3a9e023f3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsDataStore.kt @@ -25,8 +25,9 @@ class AppearanceSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class AppearancePreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class AppearancePreferenceKeys( + override val id: Int, + ) : PreferenceKeys { Theme(R.string.preference_theme_key), TrueDark(R.string.preference_true_dark_theme_key), FontSize(R.string.preference_font_size_key), @@ -39,53 +40,104 @@ class AppearanceSettingsDataStore( ShowChangelogs(R.string.preference_show_changelogs_key), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - AppearancePreferenceKeys.Theme -> acc.copy( - theme = value.mappedStringOrDefault( - original = context.resources.getStringArray(R.array.theme_entry_values), - enumEntries = ThemePreference.entries, - default = acc.theme, - ) - ) - - AppearancePreferenceKeys.TrueDark -> acc.copy(trueDarkTheme = value.booleanOrDefault(acc.trueDarkTheme)) - AppearancePreferenceKeys.FontSize -> acc.copy(fontSize = value.intOrDefault(acc.fontSize)) - AppearancePreferenceKeys.KeepScreenOn -> acc.copy(keepScreenOn = value.booleanOrDefault(acc.keepScreenOn)) - AppearancePreferenceKeys.LineSeparator -> acc.copy(lineSeparator = value.booleanOrDefault(acc.lineSeparator)) - AppearancePreferenceKeys.CheckeredMessages -> acc.copy(checkeredMessages = value.booleanOrDefault(acc.checkeredMessages)) - AppearancePreferenceKeys.ShowInput -> acc.copy(showInput = value.booleanOrDefault(acc.showInput)) - AppearancePreferenceKeys.AutoDisableInput -> acc.copy(autoDisableInput = value.booleanOrDefault(acc.autoDisableInput)) - AppearancePreferenceKeys.ShowChips -> acc.copy(showChips = value.booleanOrDefault(acc.showChips)) - AppearancePreferenceKeys.ShowChangelogs -> acc.copy(showChangelogs = value.booleanOrDefault(acc.showChangelogs)) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + AppearancePreferenceKeys.Theme -> { + acc.copy( + theme = + value.mappedStringOrDefault( + original = context.resources.getStringArray(R.array.theme_entry_values), + enumEntries = ThemePreference.entries, + default = acc.theme, + ), + ) + } + + AppearancePreferenceKeys.TrueDark -> { + acc.copy(trueDarkTheme = value.booleanOrDefault(acc.trueDarkTheme)) + } + + AppearancePreferenceKeys.FontSize -> { + acc.copy(fontSize = value.intOrDefault(acc.fontSize)) + } + + AppearancePreferenceKeys.KeepScreenOn -> { + acc.copy(keepScreenOn = value.booleanOrDefault(acc.keepScreenOn)) + } + + AppearancePreferenceKeys.LineSeparator -> { + acc.copy(lineSeparator = value.booleanOrDefault(acc.lineSeparator)) + } + + AppearancePreferenceKeys.CheckeredMessages -> { + acc.copy(checkeredMessages = value.booleanOrDefault(acc.checkeredMessages)) + } + + AppearancePreferenceKeys.ShowInput -> { + acc.copy(showInput = value.booleanOrDefault(acc.showInput)) + } + + AppearancePreferenceKeys.AutoDisableInput -> { + acc.copy(autoDisableInput = value.booleanOrDefault(acc.autoDisableInput)) + } + + AppearancePreferenceKeys.ShowChips -> { + acc.copy(showChips = value.booleanOrDefault(acc.showChips)) + } + + AppearancePreferenceKeys.ShowChangelogs -> { + acc.copy(showChangelogs = value.booleanOrDefault(acc.showChangelogs)) + } + } } - } - private val dataStore = createDataStore( - fileName = "appearance", - context = context, - defaultValue = AppearanceSettings(), - serializer = AppearanceSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration), - ) + private val dataStore = + createDataStore( + fileName = "appearance", + context = context, + defaultValue = AppearanceSettings(), + serializer = AppearanceSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration), + ) val settings = dataStore.safeData(AppearanceSettings()) - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) - - val lineSeparator = settings - .map { it.lineSeparator } - .distinctUntilChanged() - val showChips = settings - .map { it.showChips } - .distinctUntilChanged() - val showInput = settings - .map { it.showInput } - .distinctUntilChanged() + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) + + val lineSeparator = + settings + .map { it.lineSeparator } + .distinctUntilChanged() + val showChips = + settings + .map { it.showChips } + .distinctUntilChanged() + val showInput = + settings + .map { it.showInput } + .distinctUntilChanged() + val inputActions = + settings + .map { it.inputActions } + .distinctUntilChanged() + val showCharacterCounter = + settings + .map { it.showCharacterCounter } + .distinctUntilChanged() + val showClearInputButton = + settings + .map { it.showClearInputButton } + .distinctUntilChanged() + val showSendButton = + settings + .map { it.showSendButton } + .distinctUntilChanged() fun current() = currentSettings.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt deleted file mode 100644 index 2beb529e2..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsFragment.kt +++ /dev/null @@ -1,368 +0,0 @@ -package com.flxrs.dankchat.preferences.appearance - -import android.app.Activity -import android.app.UiModeManager -import android.content.Context -import android.content.res.Configuration -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.compose.LocalActivity -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringArrayResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.AutoDisableInput -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.CheckeredMessages -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.FontSize -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.KeepScreenOn -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.LineSeparator -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowChangelogs -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowChips -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowInput -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.Theme -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.TrueDarkTheme -import com.flxrs.dankchat.preferences.components.NavigationBarSpacer -import com.flxrs.dankchat.preferences.components.PreferenceCategory -import com.flxrs.dankchat.preferences.components.PreferenceListDialog -import com.flxrs.dankchat.preferences.components.SliderPreferenceItem -import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem -import com.flxrs.dankchat.theme.DankChatTheme -import com.google.android.material.transition.MaterialFadeThrough -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import kotlin.math.roundToInt - -class AppearanceSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - - DankChatTheme { - AppearanceSettings( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, - onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, - onBackPressed = { findNavController().popBackStack() } - ) - } - } - } - } -} - -@Composable -private fun AppearanceSettings( - settings: AppearanceSettings, - onInteraction: (AppearanceSettingsInteraction) -> Unit, - onSuspendingInteraction: suspend (AppearanceSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, -) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - Scaffold( - contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - TopAppBar( - scrollBehavior = scrollBehavior, - title = { Text(stringResource(R.string.preference_appearance_header)) }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, - ) - } - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), - ) { - ThemeCategory( - theme = settings.theme, - trueDarkTheme = settings.trueDarkTheme, - onInteraction = onSuspendingInteraction, - ) - HorizontalDivider(thickness = Dp.Hairline) - DisplayCategory( - fontSize = settings.fontSize, - keepScreenOn = settings.keepScreenOn, - lineSeparator = settings.lineSeparator, - checkeredMessages = settings.checkeredMessages, - onInteraction = onInteraction, - ) - HorizontalDivider(thickness = Dp.Hairline) - ComponentsCategory( - showInput = settings.showInput, - autoDisableInput = settings.autoDisableInput, - showChips = settings.showChips, - showChangelogs = settings.showChangelogs, - onInteraction = onInteraction, - ) - NavigationBarSpacer() - } - } -} - -@Composable -private fun ComponentsCategory( - showInput: Boolean, - autoDisableInput: Boolean, - showChips: Boolean, - showChangelogs: Boolean, - onInteraction: (AppearanceSettingsInteraction) -> Unit, -) { - PreferenceCategory( - title = stringResource(R.string.preference_components_group_title), - ) { - SwitchPreferenceItem( - title = stringResource(R.string.preference_show_input_title), - summary = stringResource(R.string.preference_show_input_summary), - isChecked = showInput, - onClick = { onInteraction(ShowInput(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_auto_disable_input_title), - isEnabled = showInput, - isChecked = autoDisableInput, - onClick = { onInteraction(AutoDisableInput(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_show_chip_actions_title), - summary = stringResource(R.string.preference_show_chip_actions_summary), - isChecked = showChips, - onClick = { onInteraction(ShowChips(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_show_changelogs), - isChecked = showChangelogs, - onClick = { onInteraction(ShowChangelogs(it)) }, - ) - } -} - -@Composable -private fun DisplayCategory( - fontSize: Int, - keepScreenOn: Boolean, - lineSeparator: Boolean, - checkeredMessages: Boolean, - onInteraction: (AppearanceSettingsInteraction) -> Unit, -) { - PreferenceCategory( - title = stringResource(R.string.preference_display_group_title), - ) { - val context = LocalContext.current - var value by remember(fontSize) { mutableFloatStateOf(fontSize.toFloat()) } - val summary = remember(value) { getFontSizeSummary(value.roundToInt(), context) } - SliderPreferenceItem( - title = stringResource(R.string.preference_font_size_title), - value = value, - range = 10f..40f, - onDrag = { value = it }, - onDragFinished = { onInteraction(FontSize(value.roundToInt())) }, - summary = summary, - ) - - SwitchPreferenceItem( - title = stringResource(R.string.preference_keep_screen_on_title), - isChecked = keepScreenOn, - onClick = { onInteraction(KeepScreenOn(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_line_separator_title), - isChecked = lineSeparator, - onClick = { onInteraction(LineSeparator(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_checkered_lines_title), - summary = stringResource(R.string.preference_checkered_lines_summary), - isChecked = checkeredMessages, - onClick = { onInteraction(CheckeredMessages(it)) }, - ) - } -} - -@Composable -private fun ThemeCategory( - theme: ThemePreference, - trueDarkTheme: Boolean, - onInteraction: suspend (AppearanceSettingsInteraction) -> Unit, -) { - val scope = rememberCoroutineScope() - val themeState = rememberThemeState(theme, trueDarkTheme, isSystemInDarkTheme()) - PreferenceCategory( - title = stringResource(R.string.preference_theme_title), - ) { - val activity = LocalActivity.current - PreferenceListDialog( - title = stringResource(R.string.preference_theme_title), - summary = themeState.summary, - isEnabled = themeState.themeSwitcherEnabled, - values = themeState.values, - entries = themeState.entries, - selected = themeState.preference, - onChanged = { - scope.launch { - activity ?: return@launch - onInteraction(Theme(it)) - setDarkMode(it, activity) - } - } - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_true_dark_theme_title), - summary = stringResource(R.string.preference_true_dark_theme_summary), - isChecked = themeState.trueDarkPreference, - isEnabled = themeState.trueDarkEnabled, - onClick = { - scope.launch { - activity ?: return@launch - onInteraction(TrueDarkTheme(it)) - ActivityCompat.recreate(activity) - } - } - ) - } -} - -data class ThemeState( - val preference: ThemePreference, - val summary: String, - val trueDarkPreference: Boolean, - val values: ImmutableList, - val entries: ImmutableList, - val themeSwitcherEnabled: Boolean, - val trueDarkEnabled: Boolean, -) - -@Composable -@Stable -private fun rememberThemeState(theme: ThemePreference, trueDark: Boolean, systemDarkMode: Boolean): ThemeState { - val context = LocalContext.current - val defaultEntries = stringArrayResource(R.array.theme_entries).toImmutableList() - val darkThemeTitle = stringResource(R.string.preference_dark_theme_entry_title) - val lightThemeTitle = stringResource(R.string.preference_light_theme_entry_title) - val shouldDisable = remember { - val uiModeManager = getSystemService(context, UiModeManager::class.java) - val isTv = uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION - Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1 && !isTv - } - if (shouldDisable) { - return ThemeState( - preference = ThemePreference.Dark, - summary = darkThemeTitle, - trueDarkPreference = trueDark, - values = listOf(ThemePreference.Dark).toImmutableList(), - entries = listOf(darkThemeTitle).toImmutableList(), - themeSwitcherEnabled = false, - trueDarkEnabled = true, - ) - } - - val (entries, values) = remember { - when { - Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> Pair( - listOf(darkThemeTitle, lightThemeTitle).toImmutableList(), - listOf(ThemePreference.Dark, ThemePreference.Light).toImmutableList(), - ) - - else -> defaultEntries to ThemePreference.entries.toImmutableList() - } - } - - return remember(theme, trueDark) { - val selected = if (theme in values) theme else ThemePreference.Dark - val trueDarkEnabled = selected == ThemePreference.Dark || (selected == ThemePreference.System && systemDarkMode) - ThemeState( - preference = selected, - summary = entries[values.indexOf(selected)], - trueDarkPreference = trueDarkEnabled && trueDark, - values = values, - entries = entries, - themeSwitcherEnabled = true, - trueDarkEnabled = trueDarkEnabled, - ) - } -} - -private fun getFontSizeSummary(value: Int, context: Context): String { - return when { - value < 13 -> context.getString(R.string.preference_font_size_summary_very_small) - value in 13..17 -> context.getString(R.string.preference_font_size_summary_small) - value in 18..22 -> context.getString(R.string.preference_font_size_summary_large) - else -> context.getString(R.string.preference_font_size_summary_very_large) - } -} - -private fun setDarkMode(themePreference: ThemePreference, activity: Activity) { - AppCompatDelegate.setDefaultNightMode( - when (themePreference) { - ThemePreference.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - ThemePreference.Dark -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_NO - } - ) - ActivityCompat.recreate(activity) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt new file mode 100644 index 000000000..8a22d509c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsScreen.kt @@ -0,0 +1,620 @@ +package com.flxrs.dankchat.preferences.appearance + +import android.content.Context +import android.content.res.Configuration +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.AutoDisableInput +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.CheckeredMessages +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.FontSize +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.KeepScreenOn +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.LineSeparator +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowCharacterCounter +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowClearInputButton +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.ShowSendButton +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.SwipeNavigation +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.Theme +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsInteraction.TrueDarkTheme +import com.flxrs.dankchat.preferences.components.ExpandablePreferenceItem +import com.flxrs.dankchat.preferences.components.NavigationBarSpacer +import com.flxrs.dankchat.preferences.components.PreferenceCategory +import com.flxrs.dankchat.preferences.components.PreferenceListDialog +import com.flxrs.dankchat.preferences.components.SliderPreferenceItem +import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import kotlin.math.roundToInt + +@Composable +fun AppearanceSettingsScreen(onBack: () -> Unit) { + val viewModel = koinViewModel() + val uiState = viewModel.settings.collectAsStateWithLifecycle().value + + AppearanceSettingsContent( + settings = uiState.settings, + onInteraction = { viewModel.onInteraction(it) }, + onSuspendingInteraction = { viewModel.onSuspendingInteraction(it) }, + onBack = onBack, + ) +} + +@Composable +private fun AppearanceSettingsContent( + settings: AppearanceSettings, + onInteraction: (AppearanceSettingsInteraction) -> Unit, + onSuspendingInteraction: suspend (AppearanceSettingsInteraction) -> Unit, + onBack: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + scrollBehavior = scrollBehavior, + title = { Text(stringResource(R.string.preference_appearance_header)) }, + navigationIcon = { + IconButton( + onClick = onBack, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, + ) + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + ThemeCategory( + theme = settings.theme, + trueDarkTheme = settings.trueDarkTheme, + accentColor = settings.accentColor, + paletteStyle = settings.paletteStyle, + onInteraction = onSuspendingInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + DisplayCategory( + fontSize = settings.fontSize, + keepScreenOn = settings.keepScreenOn, + lineSeparator = settings.lineSeparator, + checkeredMessages = settings.checkeredMessages, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + InputCategory( + autoDisableInput = settings.autoDisableInput, + showCharacterCounter = settings.showCharacterCounter, + showClearInputButton = settings.showClearInputButton, + showSendButton = settings.showSendButton, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + ComponentsCategory( + swipeNavigation = settings.swipeNavigation, + onInteraction = onInteraction, + ) + NavigationBarSpacer() + } + } +} + +@Composable +private fun InputCategory( + autoDisableInput: Boolean, + showCharacterCounter: Boolean, + showClearInputButton: Boolean, + showSendButton: Boolean, + onInteraction: (AppearanceSettingsInteraction) -> Unit, +) { + PreferenceCategory( + title = stringResource(R.string.preference_input_group_title), + ) { + SwitchPreferenceItem( + title = stringResource(R.string.preference_auto_disable_input_title), + isChecked = autoDisableInput, + onClick = { onInteraction(AutoDisableInput(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_show_character_counter_title), + summary = stringResource(R.string.preference_show_character_counter_summary), + isChecked = showCharacterCounter, + onClick = { onInteraction(ShowCharacterCounter(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_show_clear_input_button_title), + isChecked = showClearInputButton, + onClick = { onInteraction(ShowClearInputButton(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_show_send_button_title), + isChecked = showSendButton, + onClick = { onInteraction(ShowSendButton(it)) }, + ) + } +} + +@Composable +private fun ComponentsCategory( + swipeNavigation: Boolean, + onInteraction: (AppearanceSettingsInteraction) -> Unit, +) { + PreferenceCategory( + title = stringResource(R.string.preference_components_group_title), + ) { + SwitchPreferenceItem( + title = stringResource(R.string.preference_swipe_navigation_title), + summary = stringResource(R.string.preference_swipe_navigation_summary), + isChecked = swipeNavigation, + onClick = { onInteraction(SwipeNavigation(it)) }, + ) + } +} + +@Composable +private fun DisplayCategory( + fontSize: Int, + keepScreenOn: Boolean, + lineSeparator: Boolean, + checkeredMessages: Boolean, + onInteraction: (AppearanceSettingsInteraction) -> Unit, +) { + PreferenceCategory( + title = stringResource(R.string.preference_display_group_title), + ) { + val context = LocalContext.current + var value by remember(fontSize) { mutableFloatStateOf(fontSize.toFloat()) } + val summary = remember(value) { getFontSizeSummary(value.roundToInt(), context) } + SliderPreferenceItem( + title = stringResource(R.string.preference_font_size_title), + value = value, + range = 10f..40f, + onDrag = { value = it }, + onDragFinish = { onInteraction(FontSize(value.roundToInt())) }, + summary = summary, + ) + + SwitchPreferenceItem( + title = stringResource(R.string.preference_keep_screen_on_title), + isChecked = keepScreenOn, + onClick = { onInteraction(KeepScreenOn(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_line_separator_title), + isChecked = lineSeparator, + onClick = { onInteraction(LineSeparator(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_checkered_lines_title), + summary = stringResource(R.string.preference_checkered_lines_summary), + isChecked = checkeredMessages, + onClick = { onInteraction(CheckeredMessages(it)) }, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ThemeCategory( + theme: ThemePreference, + trueDarkTheme: Boolean, + accentColor: AccentColor?, + paletteStyle: PaletteStylePreference, + onInteraction: suspend (AppearanceSettingsInteraction) -> Unit, +) { + val activity = LocalActivity.current as? ComponentActivity + val scope = rememberCoroutineScope() + val themeState = rememberThemeState(theme, trueDarkTheme, isSystemInDarkTheme()) + val hasCustomAccent = accentColor != null + PreferenceCategory( + title = stringResource(R.string.preference_theme_title), + ) { + PreferenceListDialog( + title = stringResource(R.string.preference_theme_title), + summary = themeState.summary, + isEnabled = themeState.themeSwitcherEnabled, + values = themeState.values, + entries = themeState.entries, + selected = themeState.preference, + onChange = { preference -> + scope.launch { + onInteraction(Theme(preference)) + setDarkMode(activity, preference) + } + }, + ) + AccentColorPicker( + selectedColor = accentColor, + onColorSelect = { color -> + scope.launch { onInteraction(AppearanceSettingsInteraction.SetAccentColor(color)) } + }, + ) + PaletteStyleDialog( + paletteStyle = paletteStyle, + showSystemDefault = !hasCustomAccent, + onChange = { scope.launch { onInteraction(AppearanceSettingsInteraction.SetPaletteStyle(it)) } }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_true_dark_theme_title), + summary = stringResource(R.string.preference_true_dark_theme_summary), + isChecked = themeState.trueDarkPreference, + isEnabled = themeState.trueDarkEnabled, + onClick = { scope.launch { onInteraction(TrueDarkTheme(it)) } }, + ) + } +} + +@Immutable +data class ThemeState( + val preference: ThemePreference, + val summary: String, + val trueDarkPreference: Boolean, + val values: ImmutableList, + val entries: ImmutableList, + val themeSwitcherEnabled: Boolean, + val trueDarkEnabled: Boolean, +) + +@Composable +@Stable +private fun rememberThemeState( + theme: ThemePreference, + trueDark: Boolean, + systemDarkMode: Boolean, +): ThemeState { + LocalContext.current + val defaultEntries = stringArrayResource(R.array.theme_entries).toImmutableList() + // minSdk 30 always supports light mode and system dark mode + stringResource(R.string.preference_dark_theme_entry_title) + stringResource(R.string.preference_light_theme_entry_title) + + val (entries, values) = + remember { + defaultEntries to ThemePreference.entries.toImmutableList() + } + + return remember(theme, trueDark) { + val selected = if (theme in values) theme else ThemePreference.Dark + val trueDarkEnabled = selected == ThemePreference.Dark || (selected == ThemePreference.System && systemDarkMode) + ThemeState( + preference = selected, + summary = entries[values.indexOf(selected)], + trueDarkPreference = trueDarkEnabled && trueDark, + values = values, + entries = entries, + themeSwitcherEnabled = true, + trueDarkEnabled = trueDarkEnabled, + ) + } +} + +private fun getFontSizeSummary( + value: Int, + context: Context, +): String = when { + value < 13 -> context.getString(R.string.preference_font_size_summary_very_small) + value in 13..17 -> context.getString(R.string.preference_font_size_summary_small) + value in 18..22 -> context.getString(R.string.preference_font_size_summary_large) + else -> context.getString(R.string.preference_font_size_summary_very_large) +} + +@Composable +private fun AccentColorPicker( + selectedColor: AccentColor?, + onColorSelect: (AccentColor?) -> Unit, +) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + text = stringResource(R.string.preference_accent_color_title), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = when (selectedColor) { + null -> stringResource(R.string.preference_accent_color_summary_default) + else -> stringResource(selectedColor.labelRes) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // System default option + AccentColorCircle( + color = null, + isSelected = selectedColor == null, + onClick = { onColorSelect(null) }, + ) + // Preset colors + AccentColor.entries.forEach { accent -> + AccentColorCircle( + color = accent, + isSelected = selectedColor == accent, + onClick = { onColorSelect(accent) }, + ) + } + } + } +} + +@Composable +private fun AccentColorCircle( + color: AccentColor?, + isSelected: Boolean, + onClick: () -> Unit, +) { + val circleSize = 40.dp + val borderColor = MaterialTheme.colorScheme.outline + Box( + modifier = + Modifier + .size(circleSize) + .clip(CircleShape) + .then( + when { + isSelected -> Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + else -> Modifier + }, + ).clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + if (color != null) { + Box( + modifier = + Modifier + .size(circleSize - 4.dp) + .background(color.seedColor, CircleShape), + ) + } else { + // System default: outlined circle with auto icon + Box( + modifier = + Modifier + .size(circleSize - 4.dp) + .border(1.dp, borderColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = stringResource(R.string.preference_accent_color_summary_default), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = when (color) { + null -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surface + }, + ) + } + } +} + +@Composable +private fun PaletteStyleDialog( + paletteStyle: PaletteStylePreference, + showSystemDefault: Boolean, + onChange: (PaletteStylePreference) -> Unit, +) { + val scope = rememberCoroutineScope() + ExpandablePreferenceItem( + title = stringResource(R.string.preference_palette_style_title), + summary = stringResource(paletteStyle.labelRes), + ) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val standardStyles = remember(showSystemDefault) { + PaletteStylePreference.entries.filter { + it.isStandard && (showSystemDefault || it != PaletteStylePreference.SystemDefault) + } + } + val extraStyles = remember { PaletteStylePreference.entries.filter { !it.isStandard } } + var showExtra by remember { mutableStateOf(!paletteStyle.isStandard) } + + ModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + standardStyles.forEach { style -> + PaletteStyleRow( + style = style, + isSelected = paletteStyle == style, + onClick = { + onChange(style) + scope.launch { + sheetState.hide() + dismiss() + } + }, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { showExtra = !showExtra } + .padding(start = 28.dp, end = 16.dp, top = 12.dp, bottom = 12.dp), + ) { + Icon( + imageVector = if (showExtra) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.palette_style_more), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp), + ) + } + + AnimatedVisibility(visible = showExtra) { + Column { + extraStyles.forEach { style -> + PaletteStyleRow( + style = style, + isSelected = paletteStyle == style, + onClick = { + onChange(style) + scope.launch { + sheetState.hide() + dismiss() + } + }, + ) + } + } + } + + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +private fun PaletteStyleRow( + style: PaletteStylePreference, + isSelected: Boolean, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = onClick, + interactionSource = interactionSource, + indication = ripple(), + ).padding(horizontal = 16.dp), + ) { + RadioButton( + selected = isSelected, + onClick = onClick, + interactionSource = interactionSource, + ) + Column(modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp)) { + Text( + text = stringResource(style.labelRes), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(style.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun setDarkMode( + activity: ComponentActivity?, + themePreference: ThemePreference, +) { + AppCompatDelegate.setDefaultNightMode( + when (themePreference) { + ThemePreference.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ThemePreference.Dark -> AppCompatDelegate.MODE_NIGHT_YES + ThemePreference.Light -> AppCompatDelegate.MODE_NIGHT_NO + }, + ) + + activity ?: return + val systemDark = (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val isDark = when (themePreference) { + ThemePreference.Dark -> true + ThemePreference.Light -> false + ThemePreference.System -> systemDark + } + val barStyle = when { + isDark -> SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + else -> SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + } + activity.enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt new file mode 100644 index 000000000..8126ab809 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsState.kt @@ -0,0 +1,66 @@ +package com.flxrs.dankchat.preferences.appearance + +import androidx.compose.runtime.Immutable + +sealed interface AppearanceSettingsInteraction { + data class Theme( + val theme: ThemePreference, + ) : AppearanceSettingsInteraction + + data class TrueDarkTheme( + val trueDarkTheme: Boolean, + ) : AppearanceSettingsInteraction + + data class FontSize( + val fontSize: Int, + ) : AppearanceSettingsInteraction + + data class KeepScreenOn( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class LineSeparator( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class CheckeredMessages( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class AutoDisableInput( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class ShowChangelogs( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class ShowCharacterCounter( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class ShowClearInputButton( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class ShowSendButton( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class SwipeNavigation( + val value: Boolean, + ) : AppearanceSettingsInteraction + + data class SetAccentColor( + val color: AccentColor?, + ) : AppearanceSettingsInteraction + + data class SetPaletteStyle( + val style: PaletteStylePreference, + ) : AppearanceSettingsInteraction +} + +@Immutable +data class AppearanceSettingsUiState( + val settings: AppearanceSettings, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsViewModel.kt index 9ad6e6f22..80a06dc19 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/appearance/AppearanceSettingsViewModel.kt @@ -4,51 +4,45 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class AppearanceSettingsViewModel( private val dataStore: AppearanceSettingsDataStore, ) : ViewModel() { - - val settings = dataStore.settings.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = dataStore.current(), - ) + val settings = + dataStore.settings + .map { AppearanceSettingsUiState(settings = it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = AppearanceSettingsUiState(settings = dataStore.current()), + ) suspend fun onSuspendingInteraction(interaction: AppearanceSettingsInteraction) { runCatching { when (interaction) { - is AppearanceSettingsInteraction.Theme -> dataStore.update { it.copy(theme = interaction.theme) } - is AppearanceSettingsInteraction.TrueDarkTheme -> dataStore.update { it.copy(trueDarkTheme = interaction.trueDarkTheme) } - is AppearanceSettingsInteraction.FontSize -> dataStore.update { it.copy(fontSize = interaction.fontSize) } - is AppearanceSettingsInteraction.KeepScreenOn -> dataStore.update { it.copy(keepScreenOn = interaction.value) } - is AppearanceSettingsInteraction.LineSeparator -> dataStore.update { it.copy(lineSeparator = interaction.value) } + is AppearanceSettingsInteraction.Theme -> dataStore.update { it.copy(theme = interaction.theme) } + is AppearanceSettingsInteraction.TrueDarkTheme -> dataStore.update { it.copy(trueDarkTheme = interaction.trueDarkTheme) } + is AppearanceSettingsInteraction.FontSize -> dataStore.update { it.copy(fontSize = interaction.fontSize) } + is AppearanceSettingsInteraction.KeepScreenOn -> dataStore.update { it.copy(keepScreenOn = interaction.value) } + is AppearanceSettingsInteraction.LineSeparator -> dataStore.update { it.copy(lineSeparator = interaction.value) } is AppearanceSettingsInteraction.CheckeredMessages -> dataStore.update { it.copy(checkeredMessages = interaction.value) } - is AppearanceSettingsInteraction.ShowInput -> dataStore.update { it.copy(showInput = interaction.value) } - is AppearanceSettingsInteraction.AutoDisableInput -> dataStore.update { it.copy(autoDisableInput = interaction.value) } - is AppearanceSettingsInteraction.ShowChips -> dataStore.update { it.copy(showChips = interaction.value) } - is AppearanceSettingsInteraction.ShowChangelogs -> dataStore.update { it.copy(showChangelogs = interaction.value) } + is AppearanceSettingsInteraction.AutoDisableInput -> dataStore.update { it.copy(autoDisableInput = interaction.value) } + is AppearanceSettingsInteraction.ShowChangelogs -> dataStore.update { it.copy(showChangelogs = interaction.value) } + is AppearanceSettingsInteraction.ShowCharacterCounter -> dataStore.update { it.copy(showCharacterCounter = interaction.value) } + is AppearanceSettingsInteraction.ShowClearInputButton -> dataStore.update { it.copy(showClearInputButton = interaction.value) } + is AppearanceSettingsInteraction.ShowSendButton -> dataStore.update { it.copy(showSendButton = interaction.value) } + is AppearanceSettingsInteraction.SwipeNavigation -> dataStore.update { it.copy(swipeNavigation = interaction.value) } + is AppearanceSettingsInteraction.SetAccentColor -> dataStore.update { it.copy(accentColor = interaction.color) } + is AppearanceSettingsInteraction.SetPaletteStyle -> dataStore.update { it.copy(paletteStyle = interaction.style) } } } } fun onInteraction(interaction: AppearanceSettingsInteraction) = viewModelScope.launch { onSuspendingInteraction(interaction) } } - -sealed interface AppearanceSettingsInteraction { - data class Theme(val theme: ThemePreference) : AppearanceSettingsInteraction - data class TrueDarkTheme(val trueDarkTheme: Boolean) : AppearanceSettingsInteraction - data class FontSize(val fontSize: Int) : AppearanceSettingsInteraction - data class KeepScreenOn(val value: Boolean) : AppearanceSettingsInteraction - data class LineSeparator(val value: Boolean) : AppearanceSettingsInteraction - data class CheckeredMessages(val value: Boolean) : AppearanceSettingsInteraction - data class ShowInput(val value: Boolean) : AppearanceSettingsInteraction - data class AutoDisableInput(val value: Boolean) : AppearanceSettingsInteraction - data class ShowChips(val value: Boolean) : AppearanceSettingsInteraction - data class ShowChangelogs(val value: Boolean) : AppearanceSettingsInteraction -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettings.kt index e6dd292bf..a0a5d0fdd 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettings.kt @@ -8,14 +8,17 @@ import kotlin.uuid.Uuid @Serializable data class ChatSettings( - val suggestions: Boolean = true, - val preferEmoteSuggestions: Boolean = false, - val supibotSuggestions: Boolean = false, + val suggestionTypes: List = SuggestionType.DEFAULT, + val suggestionMode: SuggestionMode = SuggestionMode.Automatic, + val suggestionsMigrated: Boolean = false, + @Deprecated("Migrated to suggestionTypes") val suggestions: Boolean = true, + @Deprecated("Migrated to suggestionTypes") val supibotSuggestions: Boolean = false, val customCommands: List = emptyList(), val animateGifs: Boolean = true, val scrollbackLength: Int = 500, val showUsernames: Boolean = true, val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, + val colorizeNicknames: Boolean = true, val showTimedOutMessages: Boolean = true, val showTimestamps: Boolean = true, val timestampFormat: String = DEFAULT_TIMESTAMP_FORMAT, @@ -29,7 +32,6 @@ data class ChatSettings( val showChatModes: Boolean = true, val sharedChatMigration: Boolean = false, ) { - @Transient val visibleBadgeTypes = visibleBadges.map { BadgeType.entries[it.ordinal] } @@ -42,7 +44,24 @@ data class ChatSettings( } @Serializable -data class CustomCommand(val trigger: String, val command: String, @Transient val id: String = Uuid.random().toString()) +data class CustomCommand( + val trigger: String, + val command: String, + @Transient val id: String = Uuid.random().toString(), +) + +@Serializable +enum class SuggestionType { + Emotes, + Users, + Commands, + SupibotCommands, + ; + + companion object { + val DEFAULT = listOf(Emotes, Users, Commands) + } +} enum class UserLongClickBehavior { MentionsUser, @@ -65,6 +84,12 @@ enum class VisibleThirdPartyEmotes { SevenTV, } +@Serializable +enum class SuggestionMode { + Automatic, + PrefixOnly, +} + enum class LiveUpdatesBackgroundBehavior { Never, OneMinute, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsDataStore.kt index 784fcfb8e..89975572b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsDataStore.kt @@ -34,10 +34,10 @@ class ChatSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class ChatPreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class ChatPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { Suggestions(R.string.preference_suggestions_key), - PreferEmoteSuggestions(R.string.preference_prefer_emote_suggestions_key), SupibotSuggestions(R.string.preference_supibot_suggestions_key), CustomCommands(R.string.preference_commands_key), AnimateGifs(R.string.preference_animate_gifs_key), @@ -57,120 +57,215 @@ class ChatSettingsDataStore( ShowRoomState(R.string.preference_roomstate_key), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - ChatPreferenceKeys.Suggestions -> acc.copy(suggestions = value.booleanOrDefault(acc.suggestions)) - ChatPreferenceKeys.PreferEmoteSuggestions -> acc.copy(preferEmoteSuggestions = value.booleanOrDefault(acc.preferEmoteSuggestions)) - ChatPreferenceKeys.SupibotSuggestions -> acc.copy(supibotSuggestions = value.booleanOrDefault(acc.supibotSuggestions)) - ChatPreferenceKeys.CustomCommands -> { - val commands = value.stringSetOrNull()?.mapNotNull { - Json.decodeOrNull(it) - } ?: acc.customCommands - acc.copy(customCommands = commands) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + @Suppress("DEPRECATION") + ChatPreferenceKeys.Suggestions, + -> { + acc.copy(suggestions = value.booleanOrDefault(acc.suggestions)) + } + + @Suppress("DEPRECATION") + ChatPreferenceKeys.SupibotSuggestions, + -> { + @Suppress("DEPRECATION") + acc.copy(supibotSuggestions = value.booleanOrDefault(acc.supibotSuggestions)) + } + + ChatPreferenceKeys.CustomCommands -> { + val commands = + value.stringSetOrNull()?.mapNotNull { + Json.decodeOrNull(it) + } ?: acc.customCommands + acc.copy(customCommands = commands) + } + + ChatPreferenceKeys.AnimateGifs -> { + acc.copy(animateGifs = value.booleanOrDefault(acc.animateGifs)) + } + + ChatPreferenceKeys.ScrollbackLength -> { + acc.copy(scrollbackLength = value.intOrNull()?.let { it * 50 } ?: acc.scrollbackLength) + } + + ChatPreferenceKeys.ShowUsernames -> { + acc.copy(showUsernames = value.booleanOrDefault(acc.showUsernames)) + } + + ChatPreferenceKeys.UserLongClickBehavior -> { + acc.copy( + userLongClickBehavior = + value.booleanOrNull()?.let { + if (it) UserLongClickBehavior.MentionsUser else UserLongClickBehavior.OpensPopup + } ?: acc.userLongClickBehavior, + ) + } + + ChatPreferenceKeys.ShowTimedOutMessages -> { + acc.copy(showTimedOutMessages = value.booleanOrDefault(acc.showTimedOutMessages)) + } + + ChatPreferenceKeys.ShowTimestamps -> { + acc.copy(showTimestamps = value.booleanOrDefault(acc.showTimestamps)) + } + + ChatPreferenceKeys.TimestampFormat -> { + acc.copy(timestampFormat = value.stringOrDefault(acc.timestampFormat)) + } + + ChatPreferenceKeys.VisibleBadges -> { + acc.copy( + visibleBadges = + value + .mappedStringSetOrDefault( + original = context.resources.getStringArray(R.array.badges_entry_values), + enumEntries = VisibleBadges.entries, + default = acc.visibleBadges, + ).plus(VisibleBadges.SharedChat) + .distinct(), + sharedChatMigration = true, + ) + } + + ChatPreferenceKeys.VisibleEmotes -> { + acc.copy( + visibleEmotes = + value.mappedStringSetOrDefault( + original = context.resources.getStringArray(R.array.emotes_entry_values), + enumEntries = VisibleThirdPartyEmotes.entries, + default = acc.visibleEmotes, + ), + ) + } + + ChatPreferenceKeys.UnlistedEmotes -> { + acc.copy(allowUnlistedSevenTvEmotes = value.booleanOrDefault(acc.allowUnlistedSevenTvEmotes)) + } + + ChatPreferenceKeys.LiveUpdates -> { + acc.copy(sevenTVLiveEmoteUpdates = value.booleanOrDefault(acc.sevenTVLiveEmoteUpdates)) + } + + ChatPreferenceKeys.LiveUpdatesTimeout -> { + acc.copy( + sevenTVLiveEmoteUpdatesBehavior = + value.mappedStringOrDefault( + original = context.resources.getStringArray(R.array.event_api_timeout_entry_values), + enumEntries = LiveUpdatesBackgroundBehavior.entries, + default = acc.sevenTVLiveEmoteUpdatesBehavior, + ), + ) + } + + ChatPreferenceKeys.LoadMessageHistory -> { + acc.copy(loadMessageHistory = value.booleanOrDefault(acc.loadMessageHistory)) + } + + ChatPreferenceKeys.LoadMessageHistoryOnReconnect -> { + acc.copy(loadMessageHistoryOnReconnect = value.booleanOrDefault(acc.loadMessageHistoryOnReconnect)) + } + + ChatPreferenceKeys.ShowRoomState -> { + acc.copy(showChatModes = value.booleanOrDefault(acc.showChatModes)) + } } + } + private val scrollbackResetMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = currentData.scrollbackLength <= 20 - ChatPreferenceKeys.AnimateGifs -> acc.copy(animateGifs = value.booleanOrDefault(acc.animateGifs)) - ChatPreferenceKeys.ScrollbackLength -> acc.copy(scrollbackLength = value.intOrNull()?.let { it * 50 } ?: acc.scrollbackLength) - ChatPreferenceKeys.ShowUsernames -> acc.copy(showUsernames = value.booleanOrDefault(acc.showUsernames)) - ChatPreferenceKeys.UserLongClickBehavior -> acc.copy( - userLongClickBehavior = value.booleanOrNull()?.let { - if (it) UserLongClickBehavior.MentionsUser else UserLongClickBehavior.OpensPopup - } ?: acc.userLongClickBehavior - ) + override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy(scrollbackLength = currentData.scrollbackLength * 50) + + override suspend fun cleanUp() = Unit + } - ChatPreferenceKeys.ShowTimedOutMessages -> acc.copy(showTimedOutMessages = value.booleanOrDefault(acc.showTimedOutMessages)) - ChatPreferenceKeys.ShowTimestamps -> acc.copy(showTimestamps = value.booleanOrDefault(acc.showTimestamps)) - ChatPreferenceKeys.TimestampFormat -> acc.copy(timestampFormat = value.stringOrDefault(acc.timestampFormat)) - ChatPreferenceKeys.VisibleBadges -> acc.copy( - visibleBadges = value.mappedStringSetOrDefault( - original = context.resources.getStringArray(R.array.badges_entry_values), - enumEntries = VisibleBadges.entries, - default = acc.visibleBadges, - ).plus(VisibleBadges.SharedChat).distinct(), + private val sharedChatMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = !currentData.sharedChatMigration + + override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy( + visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), sharedChatMigration = true, ) - ChatPreferenceKeys.VisibleEmotes -> acc.copy( - visibleEmotes = value.mappedStringSetOrDefault( - original = context.resources.getStringArray(R.array.emotes_entry_values), - enumEntries = VisibleThirdPartyEmotes.entries, - default = acc.visibleEmotes, - ) - ) + override suspend fun cleanUp() = Unit + } - ChatPreferenceKeys.UnlistedEmotes -> acc.copy(allowUnlistedSevenTvEmotes = value.booleanOrDefault(acc.allowUnlistedSevenTvEmotes)) - ChatPreferenceKeys.LiveUpdates -> acc.copy(sevenTVLiveEmoteUpdates = value.booleanOrDefault(acc.sevenTVLiveEmoteUpdates)) - ChatPreferenceKeys.LiveUpdatesTimeout -> acc.copy( - sevenTVLiveEmoteUpdatesBehavior = value.mappedStringOrDefault( - original = context.resources.getStringArray(R.array.event_api_timeout_entry_values), - enumEntries = LiveUpdatesBackgroundBehavior.entries, - default = acc.sevenTVLiveEmoteUpdatesBehavior, + @Suppress("DEPRECATION") + private val suggestionTypeMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = !currentData.suggestionsMigrated + + override suspend fun migrate(currentData: ChatSettings): ChatSettings { + val types = buildList { + if (currentData.suggestions) { + add(SuggestionType.Emotes) + add(SuggestionType.Users) + add(SuggestionType.Commands) + } + if (currentData.supibotSuggestions) { + add(SuggestionType.SupibotCommands) + } + } + return currentData.copy( + suggestionTypes = types, + suggestionsMigrated = true, ) - ) + } - ChatPreferenceKeys.LoadMessageHistory -> acc.copy(loadMessageHistory = value.booleanOrDefault(acc.loadMessageHistory)) - ChatPreferenceKeys.LoadMessageHistoryOnReconnect -> acc.copy(loadMessageHistoryOnReconnect = value.booleanOrDefault(acc.loadMessageHistoryOnReconnect)) - ChatPreferenceKeys.ShowRoomState -> acc.copy(showChatModes = value.booleanOrDefault(acc.showChatModes)) + override suspend fun cleanUp() = Unit } - } - private val scrollbackResetMigration = object : DataMigration { - override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = currentData.scrollbackLength <= 20 - override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy(scrollbackLength = currentData.scrollbackLength * 50) - override suspend fun cleanUp() = Unit - } - private val sharedChatMigration = object : DataMigration { - override suspend fun shouldMigrate(currentData: ChatSettings): Boolean = !currentData.sharedChatMigration - override suspend fun migrate(currentData: ChatSettings): ChatSettings = currentData.copy( - visibleBadges = currentData.visibleBadges.plus(VisibleBadges.SharedChat).distinct(), - sharedChatMigration = true, + private val dataStore = + createDataStore( + fileName = "chat", + context = context, + defaultValue = ChatSettings(), + serializer = ChatSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration, scrollbackResetMigration, sharedChatMigration, suggestionTypeMigration), ) - override suspend fun cleanUp() = Unit - } - - private val dataStore = createDataStore( - fileName = "chat", - context = context, - defaultValue = ChatSettings(), - serializer = ChatSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration, scrollbackResetMigration, sharedChatMigration), - ) val settings = dataStore.safeData(ChatSettings()) - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) - - val commands = settings - .map { it.customCommands } - .distinctUntilChanged() - val suggestions = settings - .map { it.suggestions } - .distinctUntilChanged() - val showChatModes = settings - .map { it.showChatModes } - .distinctUntilChanged() - - val debouncedScrollBack = settings - .map { it.scrollbackLength } - .distinctUntilChanged() - .debounce(1.seconds) - val debouncedSevenTvLiveEmoteUpdates = settings - .map { it.sevenTVLiveEmoteUpdates } - .distinctUntilChanged() - .debounce(2.seconds) - - val restartChat = settings.distinctUntilChanged { old, new -> - old.showTimestamps != new.showTimestamps || - old.timestampFormat != new.timestampFormat || - old.showTimedOutMessages != new.showTimedOutMessages || - old.animateGifs != new.animateGifs || - old.showUsernames != new.showUsernames || - old.visibleBadges != new.visibleBadges - } + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) + + val commands = + settings + .map { it.customCommands } + .distinctUntilChanged() + val suggestionTypes = + settings + .map { it.suggestionTypes } + .distinctUntilChanged() + val suggestionMode = + settings + .map { it.suggestionMode } + .distinctUntilChanged() + val showChatModes = + settings + .map { it.showChatModes } + .distinctUntilChanged() + val userLongClickBehavior = + settings + .map { it.userLongClickBehavior } + .distinctUntilChanged() + + val debouncedScrollBack = + settings + .map { it.scrollbackLength } + .distinctUntilChanged() + .debounce(1.seconds) + val debouncedSevenTvLiveEmoteUpdates = + settings + .map { it.sevenTVLiveEmoteUpdates } + .distinctUntilChanged() + .debounce(2.seconds) fun current() = currentSettings.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsFragment.kt deleted file mode 100644 index fae6cef19..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsFragment.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.flxrs.dankchat.preferences.chat - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.findNavController -import com.flxrs.dankchat.preferences.chat.commands.CustomCommandsScreen -import com.flxrs.dankchat.preferences.chat.userdisplay.UserDisplayScreen -import com.flxrs.dankchat.theme.DankChatTheme -import com.google.android.material.transition.MaterialFadeThrough - -class ChatSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - DankChatTheme { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = ChatSettingsRoute.ChatSettings, - enterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - exitTransition = { fadeOut() }, - popEnterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - popExitTransition = { fadeOut() }, - ) { - composable { - ChatSettingsScreen( - onNavToCommands = { navController.navigate(ChatSettingsRoute.Commands) }, - onNavToUserDisplays = { navController.navigate(ChatSettingsRoute.UserDisplay) }, - onNavBack = { findNavController().popBackStack() }, - ) - } - composable { - CustomCommandsScreen( - onNavBack = { navController.popBackStack() }, - ) - } - composable { - UserDisplayScreen( - onNavBack = { navController.popBackStack() }, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsScreen.kt index 1a9fa5208..fe2f1fab6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsScreen.kt @@ -73,7 +73,7 @@ fun ChatSettingsScreen( val result = snackbarHostState.showSnackbar( message = restartRequiredTitle, actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, + duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { ProcessPhoenix.triggerRebirth(context) @@ -116,31 +116,44 @@ private fun ChatSettingsScreen( onClick = onNavBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { - GeneralCategory( - suggestions = settings.suggestions, - preferEmoteSuggestions = settings.preferEmoteSuggestions, - supibotSuggestions = settings.supibotSuggestions, - animateGifs = settings.animateGifs, + SuggestionsCategory( + suggestionTypes = settings.suggestionTypes, + suggestionMode = settings.suggestionMode, + onNavToCommands = onNavToCommands, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + MessagesCategory( scrollbackLength = settings.scrollbackLength, - showUsernames = settings.showUsernames, - userLongClickBehavior = settings.userLongClickBehavior, showTimedOutMessages = settings.showTimedOutMessages, showTimestamps = settings.showTimestamps, timestampFormat = settings.timestampFormat, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + UsersCategory( + showUsernames = settings.showUsernames, + userLongClickBehavior = settings.userLongClickBehavior, + colorizeNicknames = settings.colorizeNicknames, + onNavToUserDisplays = onNavToUserDisplays, + onInteraction = onInteraction, + ) + HorizontalDivider(thickness = Dp.Hairline) + EmotesAndBadgesCategory( + animateGifs = settings.animateGifs, visibleBadges = settings.visibleBadges, visibleEmotes = settings.visibleEmotes, - onNavToCommands = onNavToCommands, - onNavToUserDisplays = onNavToUserDisplays, onInteraction = onInteraction, ) HorizontalDivider(thickness = Dp.Hairline) @@ -169,53 +182,62 @@ private fun ChatSettingsScreen( } @Composable -private fun GeneralCategory( - suggestions: Boolean, - preferEmoteSuggestions: Boolean, - supibotSuggestions: Boolean, - animateGifs: Boolean, - scrollbackLength: Int, - showUsernames: Boolean, - userLongClickBehavior: UserLongClickBehavior, - showTimedOutMessages: Boolean, - showTimestamps: Boolean, - timestampFormat: String, - visibleBadges: ImmutableList, - visibleEmotes: ImmutableList, +private fun SuggestionsCategory( + suggestionTypes: ImmutableList, + suggestionMode: SuggestionMode, onNavToCommands: () -> Unit, - onNavToUserDisplays: () -> Unit, onInteraction: (ChatSettingsInteraction) -> Unit, ) { - PreferenceCategory(title = stringResource(R.string.preference_general_header)) { - SwitchPreferenceItem( + PreferenceCategory(title = stringResource(R.string.preference_suggestions_header)) { + val suggestionEntries = listOf( + stringResource(R.string.preference_suggestions_emotes), + stringResource(R.string.preference_suggestions_users), + stringResource(R.string.preference_suggestions_commands), + stringResource(R.string.preference_suggestions_supibot), + ).toImmutableList() + val suggestionDescriptions = listOf( + stringResource(R.string.preference_suggestions_emotes_desc), + stringResource(R.string.preference_suggestions_users_desc), + stringResource(R.string.preference_suggestions_commands_desc), + stringResource(R.string.preference_suggestions_supibot_desc), + ).toImmutableList() + PreferenceMultiListDialog( title = stringResource(R.string.preference_suggestions_title), summary = stringResource(R.string.preference_suggestions_summary), - isChecked = suggestions, - onClick = { onInteraction(ChatSettingsInteraction.Suggestions(it)) }, + values = remember { SuggestionType.entries.toImmutableList() }, + initialSelected = suggestionTypes, + entries = suggestionEntries, + descriptions = suggestionDescriptions, + onChange = { onInteraction(ChatSettingsInteraction.SuggestionTypes(it)) }, ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_prefer_emote_suggestions_title), - summary = stringResource(R.string.preference_prefer_emote_suggestions_summary), - isChecked = preferEmoteSuggestions, - onClick = { onInteraction(ChatSettingsInteraction.PreferEmoteSuggestions(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_supibot_suggestions_title), - isChecked = supibotSuggestions, - onClick = { onInteraction(ChatSettingsInteraction.SupibotSuggestions(it)) }, + val modeAutomatic = stringResource(R.string.preference_suggestion_mode_automatic) + val modePrefixOnly = stringResource(R.string.preference_suggestion_mode_prefix_only) + val modeEntries = remember { listOf(modeAutomatic, modePrefixOnly).toImmutableList() } + PreferenceListDialog( + title = stringResource(R.string.preference_suggestion_mode_title), + summary = modeEntries[suggestionMode.ordinal], + values = SuggestionMode.entries.toImmutableList(), + entries = modeEntries, + selected = suggestionMode, + onChange = { onInteraction(ChatSettingsInteraction.SuggestionModeChange(it)) }, ) PreferenceItem( title = stringResource(R.string.commands_title), onClick = onNavToCommands, trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, ) + } +} - SwitchPreferenceItem( - title = stringResource(R.string.preference_animate_gifs_title), - isChecked = animateGifs, - onClick = { onInteraction(ChatSettingsInteraction.AnimateGifs(it)) }, - ) - +@Composable +private fun MessagesCategory( + scrollbackLength: Int, + showTimedOutMessages: Boolean, + showTimestamps: Boolean, + timestampFormat: String, + onInteraction: (ChatSettingsInteraction) -> Unit, +) { + PreferenceCategory(title = stringResource(R.string.preference_messages_header)) { var sliderValue by remember(scrollbackLength) { mutableFloatStateOf(scrollbackLength.toFloat()) } SliderPreferenceItem( title = stringResource(R.string.preference_scrollback_length_title), @@ -223,17 +245,46 @@ private fun GeneralCategory( range = 50f..1000f, steps = 18, onDrag = { sliderValue = it }, - onDragFinished = { onInteraction(ChatSettingsInteraction.ScrollbackLength(sliderValue.roundToInt())) }, + onDragFinish = { onInteraction(ChatSettingsInteraction.ScrollbackLength(sliderValue.roundToInt())) }, displayValue = false, summary = sliderValue.roundToInt().toString(), ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_show_timed_out_messages_title), + isChecked = showTimedOutMessages, + onClick = { onInteraction(ChatSettingsInteraction.ShowTimedOutMessages(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_timestamp_title), + isChecked = showTimestamps, + onClick = { onInteraction(ChatSettingsInteraction.ShowTimestamps(it)) }, + ) + val timestampFormats = stringArrayResource(R.array.timestamp_formats).toImmutableList() + PreferenceListDialog( + title = stringResource(R.string.preference_timestamp_format_title), + summary = timestampFormat, + values = timestampFormats, + entries = timestampFormats, + selected = timestampFormat, + onChange = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, + ) + } +} +@Composable +private fun UsersCategory( + showUsernames: Boolean, + userLongClickBehavior: UserLongClickBehavior, + colorizeNicknames: Boolean, + onNavToUserDisplays: () -> Unit, + onInteraction: (ChatSettingsInteraction) -> Unit, +) { + PreferenceCategory(title = stringResource(R.string.preference_users_header)) { SwitchPreferenceItem( title = stringResource(R.string.preference_show_username_title), isChecked = showUsernames, onClick = { onInteraction(ChatSettingsInteraction.ShowUsernames(it)) }, ) - val longClickSummaryOn = stringResource(R.string.preference_user_long_click_summary_on) val longClickSummaryOff = stringResource(R.string.preference_user_long_click_summary_off) val longClickEntries = remember { listOf(longClickSummaryOn, longClickSummaryOff).toImmutableList() } @@ -243,53 +294,53 @@ private fun GeneralCategory( values = UserLongClickBehavior.entries.toImmutableList(), entries = longClickEntries, selected = userLongClickBehavior, - onChanged = { onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.UserLongClick(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_colorize_nicknames_title), + summary = stringResource(R.string.preference_colorize_nicknames_summary), + isChecked = colorizeNicknames, + onClick = { onInteraction(ChatSettingsInteraction.ColorizeNicknames(it)) }, ) - PreferenceItem( title = stringResource(R.string.custom_user_display_title), summary = stringResource(R.string.custom_user_display_summary), onClick = onNavToUserDisplays, trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, ) + } +} +@Composable +private fun EmotesAndBadgesCategory( + animateGifs: Boolean, + visibleBadges: ImmutableList, + visibleEmotes: ImmutableList, + onInteraction: (ChatSettingsInteraction) -> Unit, +) { + PreferenceCategory(title = stringResource(R.string.preference_emotes_badges_header)) { SwitchPreferenceItem( - title = stringResource(R.string.preference_show_timed_out_messages_title), - isChecked = showTimedOutMessages, - onClick = { onInteraction(ChatSettingsInteraction.ShowTimedOutMessages(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_timestamp_title), - isChecked = showTimestamps, - onClick = { onInteraction(ChatSettingsInteraction.ShowTimestamps(it)) }, - ) - val timestampFormats = stringArrayResource(R.array.timestamp_formats).toImmutableList() - PreferenceListDialog( - title = stringResource(R.string.preference_timestamp_format_title), - summary = timestampFormat, - values = timestampFormats, - entries = timestampFormats, - selected = timestampFormat, - onChanged = { onInteraction(ChatSettingsInteraction.TimestampFormat(it)) }, + title = stringResource(R.string.preference_animate_gifs_title), + isChecked = animateGifs, + onClick = { onInteraction(ChatSettingsInteraction.AnimateGifs(it)) }, ) - - val entries = stringArrayResource(R.array.badges_entries) - .plus(stringResource(R.string.shared_chat)) - .toImmutableList() - + val badgeEntries = + stringArrayResource(R.array.badges_entries) + .plus(stringResource(R.string.shared_chat)) + .toImmutableList() PreferenceMultiListDialog( title = stringResource(R.string.preference_visible_badges_title), initialSelected = visibleBadges, values = VisibleBadges.entries.toImmutableList(), - entries = entries, - onChanged = { onInteraction(ChatSettingsInteraction.Badges(it)) }, + entries = badgeEntries, + onChange = { onInteraction(ChatSettingsInteraction.Badges(it)) }, ) PreferenceMultiListDialog( title = stringResource(R.string.preference_visible_emotes_title), initialSelected = visibleEmotes, values = VisibleThirdPartyEmotes.entries.toImmutableList(), entries = stringArrayResource(R.array.emotes_entries).toImmutableList(), - onChanged = { onInteraction(ChatSettingsInteraction.Emotes(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.Emotes(it)) }, ) } } @@ -317,11 +368,12 @@ private fun SevenTVCategory( onClick = { onInteraction(ChatSettingsInteraction.LiveEmoteUpdates(it)) }, ) val liveUpdateEntries = stringArrayResource(R.array.event_api_timeout_entries).toImmutableList() - val summary = when (sevenTVLiveEmoteUpdatesBehavior) { - LiveUpdatesBackgroundBehavior.Never -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_never_active) - LiveUpdatesBackgroundBehavior.Always -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_always_active) - else -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_timeout, liveUpdateEntries[sevenTVLiveEmoteUpdatesBehavior.ordinal]) - } + val summary = + when (sevenTVLiveEmoteUpdatesBehavior) { + LiveUpdatesBackgroundBehavior.Never -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_never_active) + LiveUpdatesBackgroundBehavior.Always -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_always_active) + else -> stringResource(R.string.preference_7tv_live_updates_timeout_summary_timeout, liveUpdateEntries[sevenTVLiveEmoteUpdatesBehavior.ordinal]) + } PreferenceListDialog( isEnabled = enabled && sevenTVLiveEmoteUpdates, title = stringResource(R.string.preference_7tv_live_updates_timeout_title), @@ -329,7 +381,7 @@ private fun SevenTVCategory( values = LiveUpdatesBackgroundBehavior.entries.toImmutableList(), entries = liveUpdateEntries, selected = sevenTVLiveEmoteUpdatesBehavior, - onChanged = { onInteraction(ChatSettingsInteraction.LiveEmoteUpdatesBehavior(it)) }, + onChange = { onInteraction(ChatSettingsInteraction.LiveEmoteUpdatesBehavior(it)) }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt new file mode 100644 index 000000000..73375f17e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsState.kt @@ -0,0 +1,110 @@ +package com.flxrs.dankchat.preferences.chat + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +sealed interface ChatSettingsEvent { + data object RestartRequired : ChatSettingsEvent +} + +sealed interface ChatSettingsInteraction { + data class SuggestionTypes( + val value: List, + ) : ChatSettingsInteraction + + data class SuggestionModeChange( + val value: SuggestionMode, + ) : ChatSettingsInteraction + + data class CustomCommands( + val value: List, + ) : ChatSettingsInteraction + + data class AnimateGifs( + val value: Boolean, + ) : ChatSettingsInteraction + + data class ScrollbackLength( + val value: Int, + ) : ChatSettingsInteraction + + data class ShowUsernames( + val value: Boolean, + ) : ChatSettingsInteraction + + data class UserLongClick( + val value: UserLongClickBehavior, + ) : ChatSettingsInteraction + + data class ColorizeNicknames( + val value: Boolean, + ) : ChatSettingsInteraction + + data class ShowTimedOutMessages( + val value: Boolean, + ) : ChatSettingsInteraction + + data class ShowTimestamps( + val value: Boolean, + ) : ChatSettingsInteraction + + data class TimestampFormat( + val value: String, + ) : ChatSettingsInteraction + + data class Badges( + val value: List, + ) : ChatSettingsInteraction + + data class Emotes( + val value: List, + ) : ChatSettingsInteraction + + data class AllowUnlisted( + val value: Boolean, + ) : ChatSettingsInteraction + + data class LiveEmoteUpdates( + val value: Boolean, + ) : ChatSettingsInteraction + + data class LiveEmoteUpdatesBehavior( + val value: LiveUpdatesBackgroundBehavior, + ) : ChatSettingsInteraction + + data class MessageHistory( + val value: Boolean, + ) : ChatSettingsInteraction + + data class MessageHistoryAfterReconnect( + val value: Boolean, + ) : ChatSettingsInteraction + + data class ChatModes( + val value: Boolean, + ) : ChatSettingsInteraction +} + +@Immutable +data class ChatSettingsState( + val suggestionTypes: ImmutableList, + val suggestionMode: SuggestionMode, + val customCommands: ImmutableList, + val animateGifs: Boolean, + val scrollbackLength: Int, + val showUsernames: Boolean, + val userLongClickBehavior: UserLongClickBehavior, + val colorizeNicknames: Boolean, + val showTimedOutMessages: Boolean, + val showTimestamps: Boolean, + val timestampFormat: String, + val visibleBadges: ImmutableList, + val visibleEmotes: ImmutableList, + val allowUnlistedSevenTvEmotes: Boolean, + val sevenTVLiveEmoteUpdates: Boolean, + val sevenTVLiveEmoteUpdatesBehavior: LiveUpdatesBackgroundBehavior, + val loadMessageHistory: Boolean, + val loadMessageHistoryAfterReconnect: Boolean, + val messageHistoryDashboardUrl: String, + val showChatModes: Boolean, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsViewModel.kt index 57d7f678a..afea8e56c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/ChatSettingsViewModel.kt @@ -2,7 +2,6 @@ package com.flxrs.dankchat.preferences.chat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted @@ -11,123 +10,124 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class ChatSettingsViewModel( private val chatSettingsDataStore: ChatSettingsDataStore, ) : ViewModel() { - private val _events = MutableSharedFlow() val events = _events.asSharedFlow() private val initial = chatSettingsDataStore.current() - val settings = chatSettingsDataStore.settings - .map { it.toState() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = initial.toState(), - ) + val settings = + chatSettingsDataStore.settings + .map { it.toState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = initial.toState(), + ) fun onInteraction(interaction: ChatSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is ChatSettingsInteraction.Suggestions -> chatSettingsDataStore.update { it.copy(suggestions = interaction.value) } - is ChatSettingsInteraction.PreferEmoteSuggestions -> chatSettingsDataStore.update { it.copy(preferEmoteSuggestions = interaction.value) } - is ChatSettingsInteraction.SupibotSuggestions -> chatSettingsDataStore.update { it.copy(supibotSuggestions = interaction.value) } - is ChatSettingsInteraction.CustomCommands -> chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } - is ChatSettingsInteraction.AnimateGifs -> chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } - is ChatSettingsInteraction.ScrollbackLength -> chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } - is ChatSettingsInteraction.ShowUsernames -> chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } - is ChatSettingsInteraction.UserLongClick -> chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } - is ChatSettingsInteraction.ShowTimedOutMessages -> chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } - is ChatSettingsInteraction.ShowTimestamps -> chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } - is ChatSettingsInteraction.TimestampFormat -> chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } - is ChatSettingsInteraction.Badges -> chatSettingsDataStore.update { it.copy(visibleBadges = interaction.value) } - is ChatSettingsInteraction.Emotes -> { + is ChatSettingsInteraction.SuggestionTypes -> { + chatSettingsDataStore.update { it.copy(suggestionTypes = interaction.value) } + } + + is ChatSettingsInteraction.SuggestionModeChange -> { + chatSettingsDataStore.update { it.copy(suggestionMode = interaction.value) } + } + + is ChatSettingsInteraction.CustomCommands -> { + chatSettingsDataStore.update { it.copy(customCommands = interaction.value) } + } + + is ChatSettingsInteraction.AnimateGifs -> { + chatSettingsDataStore.update { it.copy(animateGifs = interaction.value) } + } + + is ChatSettingsInteraction.ScrollbackLength -> { + chatSettingsDataStore.update { it.copy(scrollbackLength = interaction.value) } + } + + is ChatSettingsInteraction.ShowUsernames -> { + chatSettingsDataStore.update { it.copy(showUsernames = interaction.value) } + } + + is ChatSettingsInteraction.UserLongClick -> { + chatSettingsDataStore.update { it.copy(userLongClickBehavior = interaction.value) } + } + + is ChatSettingsInteraction.ColorizeNicknames -> { + chatSettingsDataStore.update { it.copy(colorizeNicknames = interaction.value) } + } + + is ChatSettingsInteraction.ShowTimedOutMessages -> { + chatSettingsDataStore.update { it.copy(showTimedOutMessages = interaction.value) } + } + + is ChatSettingsInteraction.ShowTimestamps -> { + chatSettingsDataStore.update { it.copy(showTimestamps = interaction.value) } + } + + is ChatSettingsInteraction.TimestampFormat -> { + chatSettingsDataStore.update { it.copy(timestampFormat = interaction.value) } + } + + is ChatSettingsInteraction.Badges -> { + chatSettingsDataStore.update { it.copy(visibleBadges = interaction.value) } + } + + is ChatSettingsInteraction.Emotes -> { chatSettingsDataStore.update { it.copy(visibleEmotes = interaction.value) } if (initial.visibleEmotes != interaction.value) { _events.emit(ChatSettingsEvent.RestartRequired) } } - is ChatSettingsInteraction.AllowUnlisted -> { + is ChatSettingsInteraction.AllowUnlisted -> { chatSettingsDataStore.update { it.copy(allowUnlistedSevenTvEmotes = interaction.value) } if (initial.allowUnlistedSevenTvEmotes != interaction.value) { _events.emit(ChatSettingsEvent.RestartRequired) } } - is ChatSettingsInteraction.LiveEmoteUpdates -> chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdates = interaction.value) } - is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } - is ChatSettingsInteraction.MessageHistory -> chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } - is ChatSettingsInteraction.MessageHistoryAfterReconnect -> chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } - is ChatSettingsInteraction.ChatModes -> chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } + is ChatSettingsInteraction.LiveEmoteUpdates -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdates = interaction.value) } + } + + is ChatSettingsInteraction.LiveEmoteUpdatesBehavior -> { + chatSettingsDataStore.update { it.copy(sevenTVLiveEmoteUpdatesBehavior = interaction.value) } + } + + is ChatSettingsInteraction.MessageHistory -> { + chatSettingsDataStore.update { it.copy(loadMessageHistory = interaction.value) } + } + + is ChatSettingsInteraction.MessageHistoryAfterReconnect -> { + chatSettingsDataStore.update { it.copy(loadMessageHistoryOnReconnect = interaction.value) } + } + + is ChatSettingsInteraction.ChatModes -> { + chatSettingsDataStore.update { it.copy(showChatModes = interaction.value) } + } } } } } -sealed interface ChatSettingsEvent { - data object RestartRequired : ChatSettingsEvent -} - -sealed interface ChatSettingsInteraction { - data class Suggestions(val value: Boolean) : ChatSettingsInteraction - data class PreferEmoteSuggestions(val value: Boolean) : ChatSettingsInteraction - data class SupibotSuggestions(val value: Boolean) : ChatSettingsInteraction - data class CustomCommands(val value: List) : ChatSettingsInteraction - data class AnimateGifs(val value: Boolean) : ChatSettingsInteraction - data class ScrollbackLength(val value: Int) : ChatSettingsInteraction - data class ShowUsernames(val value: Boolean) : ChatSettingsInteraction - data class UserLongClick(val value: UserLongClickBehavior) : ChatSettingsInteraction - data class ShowTimedOutMessages(val value: Boolean) : ChatSettingsInteraction - data class ShowTimestamps(val value: Boolean) : ChatSettingsInteraction - data class TimestampFormat(val value: String) : ChatSettingsInteraction - data class Badges(val value: List) : ChatSettingsInteraction - data class Emotes(val value: List) : ChatSettingsInteraction - data class AllowUnlisted(val value: Boolean) : ChatSettingsInteraction - data class LiveEmoteUpdates(val value: Boolean) : ChatSettingsInteraction - data class LiveEmoteUpdatesBehavior(val value: LiveUpdatesBackgroundBehavior) : ChatSettingsInteraction - data class MessageHistory(val value: Boolean) : ChatSettingsInteraction - data class MessageHistoryAfterReconnect(val value: Boolean) : ChatSettingsInteraction - data class ChatModes(val value: Boolean) : ChatSettingsInteraction -} - -data class ChatSettingsState( - val suggestions: Boolean, - val preferEmoteSuggestions: Boolean, - val supibotSuggestions: Boolean, - val customCommands: ImmutableList, - val animateGifs: Boolean, - val scrollbackLength: Int, - val showUsernames: Boolean, - val userLongClickBehavior: UserLongClickBehavior, - val showTimedOutMessages: Boolean, - val showTimestamps: Boolean, - val timestampFormat: String, - val visibleBadges: ImmutableList, - val visibleEmotes: ImmutableList, - val allowUnlistedSevenTvEmotes: Boolean, - val sevenTVLiveEmoteUpdates: Boolean, - val sevenTVLiveEmoteUpdatesBehavior: LiveUpdatesBackgroundBehavior, - val loadMessageHistory: Boolean, - val loadMessageHistoryAfterReconnect: Boolean, - val messageHistoryDashboardUrl: String, - val showChatModes: Boolean, -) - private fun ChatSettings.toState() = ChatSettingsState( - suggestions = suggestions, - preferEmoteSuggestions = preferEmoteSuggestions, - supibotSuggestions = supibotSuggestions, + suggestionTypes = suggestionTypes.toImmutableList(), + suggestionMode = suggestionMode, customCommands = customCommands.toImmutableList(), animateGifs = animateGifs, scrollbackLength = scrollbackLength, showUsernames = showUsernames, userLongClickBehavior = userLongClickBehavior, + colorizeNicknames = colorizeNicknames, showTimedOutMessages = showTimedOutMessages, showTimestamps = showTimestamps, timestampFormat = timestampFormat, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsScreen.kt index c9700af22..38e601493 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -65,6 +64,7 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { val commands = viewModel.commands.collectAsStateWithLifecycle().value CustomCommandsScreen( initialCommands = commands, + reservedTriggers = viewModel.reservedTriggers, onSaveAndNavBack = { viewModel.save(it) onNavBack() @@ -76,16 +76,18 @@ fun CustomCommandsScreen(onNavBack: () -> Unit) { @Composable private fun CustomCommandsScreen( initialCommands: ImmutableList, + reservedTriggers: Set, onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val commands = remember { initialCommands.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val listState = rememberLazyListState() val snackbarHost = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val itemRemovedMsg = stringResource(R.string.item_removed) + val undoMsg = stringResource(R.string.undo) LifecycleStartEffect(Unit) { onStopOrDispose { @@ -95,9 +97,10 @@ private fun CustomCommandsScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( @@ -108,7 +111,7 @@ private fun CustomCommandsScreen( onClick = { onSaveAndNavBack(commands) }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -116,16 +119,17 @@ private fun CustomCommandsScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.add_command)) }, icon = { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.add_command)) }, - modifier = Modifier - .navigationBarsPadding() - .padding(8.dp), + modifier = + Modifier + .navigationBarsPadding() + .padding(8.dp), onClick = { focusManager.clearFocus() commands += CustomCommand(trigger = "", command = "") scope.launch { when { listState.canScrollForward -> listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1) - else -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) + else -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) } } }, @@ -137,27 +141,36 @@ private fun CustomCommandsScreen( DankBackground(visible = commands.isEmpty()) LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { - itemsIndexed(commands, key = { _, it -> it.id }) { idx, command -> + itemsIndexed(commands, key = { _, cmd -> cmd.id }) { idx, command -> + val triggerError = when { + command.trigger.isBlank() -> null + command.trigger in reservedTriggers -> TriggerError.Reserved + commands.indexOfFirst { it.trigger == command.trigger } != idx -> TriggerError.Duplicate + else -> null + } CustomCommandItem( trigger = command.trigger, command = command.command, - onTriggerChanged = { commands[idx] = command.copy(trigger = it) }, - onCommandChanged = { commands[idx] = command.copy(command = it) }, + triggerError = triggerError, + onTriggerChange = { commands[idx] = command.copy(trigger = it) }, + onCommandChange = { commands[idx] = command.copy(command = it) }, onRemove = { focusManager.clearFocus() val removed = commands.removeAt(idx) scope.launch { snackbarHost.currentSnackbarData?.dismiss() - val result = snackbarHost.showSnackbar( - message = context.getString(R.string.item_removed), - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { focusManager.clearFocus() commands.add(idx, removed) @@ -165,12 +178,13 @@ private fun CustomCommandsScreen( } } }, - modifier = Modifier - .padding(bottom = 16.dp) - .animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), + modifier = + Modifier + .padding(bottom = 16.dp) + .animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), ) } item(key = "spacer") { @@ -180,12 +194,18 @@ private fun CustomCommandsScreen( } } +private enum class TriggerError { + Reserved, + Duplicate, +} + @Composable private fun CustomCommandItem( trigger: String, command: String, - onTriggerChanged: (String) -> Unit, - onCommandChanged: (String) -> Unit, + triggerError: TriggerError?, + onTriggerChange: (String) -> Unit, + onCommandChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -193,15 +213,22 @@ private fun CustomCommandItem( ElevatedCard { Row { Column( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = trigger, - onValueChange = onTriggerChanged, + onValueChange = onTriggerChange, label = { Text(stringResource(R.string.command_trigger_hint)) }, + isError = triggerError != null, + supportingText = when (triggerError) { + TriggerError.Reserved -> ({ Text(stringResource(R.string.command_trigger_reserved)) }) + TriggerError.Duplicate -> ({ Text(stringResource(R.string.command_trigger_duplicate)) }) + null -> null + }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), maxLines = 1, ) @@ -209,7 +236,7 @@ private fun CustomCommandItem( OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = command, - onValueChange = onCommandChanged, + onValueChange = onCommandChange, label = { Text(stringResource(R.string.command__hint)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -218,7 +245,7 @@ private fun CustomCommandItem( IconButton( modifier = Modifier.align(Alignment.Top), onClick = onRemove, - content = { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove_command)) } + content = { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove_command)) }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsViewModel.kt index ab43a8b6b..0499f7237 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/commands/CommandsViewModel.kt @@ -2,6 +2,7 @@ package com.flxrs.dankchat.preferences.chat.commands import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.repo.command.CommandRepository import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import com.flxrs.dankchat.preferences.chat.CustomCommand import kotlinx.collections.immutable.toImmutableList @@ -10,21 +11,31 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel -class CommandsViewModel(private val chatSettingsDataStore: ChatSettingsDataStore) : ViewModel() { - val commands = chatSettingsDataStore.settings - .map { it.customCommands.toImmutableList() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = chatSettingsDataStore.current().customCommands.toImmutableList(), - ) +class CommandsViewModel( + private val chatSettingsDataStore: ChatSettingsDataStore, + private val commandRepository: CommandRepository, +) : ViewModel() { + val commands = + chatSettingsDataStore.settings + .map { it.customCommands.toImmutableList() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = chatSettingsDataStore.current().customCommands.toImmutableList(), + ) + + val reservedTriggers: Set get() = commandRepository.getReservedTriggers() fun save(commands: List) = viewModelScope.launch { - val filtered = commands.filter { it.trigger.isNotBlank() && it.command.isNotBlank() } + val reserved = commandRepository.getReservedTriggers() + val filtered = commands + .filter { it.trigger.isNotBlank() && it.command.isNotBlank() } + .filter { it.trigger !in reserved } + .distinctBy { it.trigger } chatSettingsDataStore.update { it.copy(customCommands = filtered) } } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayEvent.kt index 1ffecd004..b27522df1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayEvent.kt @@ -1,12 +1,23 @@ package com.flxrs.dankchat.preferences.chat.userdisplay +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.Flow +@Immutable sealed interface UserDisplayEvent { - data class ItemRemoved(val item: UserDisplayItem, val position: Int) : UserDisplayEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : UserDisplayEvent + data class ItemRemoved( + val item: UserDisplayItem, + val position: Int, + ) : UserDisplayEvent + + data class ItemAdded( + val position: Int, + val isLast: Boolean, + ) : UserDisplayEvent } @Stable -data class UserDisplayEventsWrapper(val events: Flow) +data class UserDisplayEventsWrapper( + val events: Flow, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayItem.kt index eb34a6e34..79123c98a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayItem.kt @@ -10,7 +10,7 @@ data class UserDisplayItem( val colorEnabled: Boolean, val color: Int, // color needs to be opaque val aliasEnabled: Boolean, - val alias: String + val alias: String, ) fun UserDisplayItem.toEntity() = UserDisplayEntity( @@ -21,7 +21,7 @@ fun UserDisplayItem.toEntity() = UserDisplayEntity( colorEnabled = colorEnabled, color = color, aliasEnabled = aliasEnabled, - alias = alias.ifEmpty { null } + alias = alias.ifEmpty { null }, ) fun UserDisplayEntity.toItem() = UserDisplayItem( @@ -31,8 +31,7 @@ fun UserDisplayEntity.toItem() = UserDisplayItem( colorEnabled = colorEnabled, color = color, aliasEnabled = aliasEnabled, - alias = alias.orEmpty() + alias = alias.orEmpty(), ) val UserDisplayItem.formattedDisplayColor: String get() = "#" + color.hexCode - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayScreen.kt index ff79172c8..86a5d4ffc 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayScreen.kt @@ -57,7 +57,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -105,32 +104,34 @@ private fun UserDisplayScreen( onAdd: (UserDisplayItem, Int) -> Unit, onNavBack: () -> Unit, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val listState = rememberLazyListState() + val itemRemovedMsg = stringResource(R.string.item_removed) + val undoMsg = stringResource(R.string.undo) LaunchedEffect(eventsWrapper) { eventsWrapper.events.collectLatest { focusManager.clearFocus() when (it) { is UserDisplayEvent.ItemRemoved -> { - val result = snackbarHost.showSnackbar( - message = context.getString(R.string.item_removed), - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(it.item, it.position) } } - is UserDisplayEvent.ItemAdded -> { + is UserDisplayEvent.ItemAdded -> { when { it.isLast && listState.canScrollForward -> listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1) - it.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) - else -> listState.animateScrollToItem(it.position) + it.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) + else -> listState.animateScrollToItem(it.position) } } } @@ -145,9 +146,10 @@ private fun UserDisplayScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( @@ -161,7 +163,7 @@ private fun UserDisplayScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -169,9 +171,10 @@ private fun UserDisplayScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.multi_entry_add_entry)) }, icon = { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.multi_entry_add_entry)) }, - modifier = Modifier - .navigationBarsPadding() - .padding(8.dp), + modifier = + Modifier + .navigationBarsPadding() + .padding(8.dp), onClick = onAddNew, ) } @@ -181,22 +184,24 @@ private fun UserDisplayScreen( DankBackground(visible = userDisplays.isEmpty()) LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { - itemsIndexed(userDisplays, key = { _, it -> it.id }) { idx, item -> + itemsIndexed(userDisplays, key = { _, display -> display.id }) { idx, item -> UserDisplayItem( item = item, onChange = { userDisplays[idx] = it }, onRemove = { onRemove(userDisplays[idx]) }, - modifier = Modifier - .padding(bottom = 16.dp) - .animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), + modifier = + Modifier + .padding(bottom = 16.dp) + .animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), ) } item(key = "spacer") { @@ -218,9 +223,10 @@ private fun UserDisplayItem( ElevatedCard { Row { Column( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -278,21 +284,24 @@ private fun UserDisplayItem( onChange(item.copy(color = selectedColor)) showColorPicker = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Text( text = stringResource(R.string.pick_custom_user_color_title), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), ) TextButton( onClick = { selectedColor = Message.DEFAULT_COLOR }, content = { Text(stringResource(R.string.reset)) }, - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 16.dp), + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), ) AndroidView( factory = { context -> @@ -307,7 +316,7 @@ private fun UserDisplayItem( }, update = { it.setCurrentColor(selectedColor) - } + }, ) } } @@ -316,11 +325,9 @@ private fun UserDisplayItem( IconButton( modifier = Modifier.align(Alignment.Top), onClick = onRemove, - content = { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove_custom_user_display)) } + content = { Icon(Icons.Default.Clear, contentDescription = stringResource(R.string.remove_custom_user_display)) }, ) } } } } - - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayViewModel.kt index ad4ce90d9..879e2a56c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/chat/userdisplay/UserDisplayViewModel.kt @@ -4,19 +4,19 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.repo.UserDisplayRepository +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.utils.extensions.replaceAll -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel @KoinViewModel class UserDisplayViewModel( - private val userDisplayRepository: UserDisplayRepository + private val userDisplayRepository: UserDisplayRepository, + private val dispatchersProvider: DispatchersProvider, ) : ViewModel() { - private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() @@ -30,12 +30,14 @@ class UserDisplayViewModel( fun addUserDisplay() = viewModelScope.launch { val entity = userDisplayRepository.addUserDisplay() userDisplays += entity.toItem() - println("XXX ${userDisplays.toList()}") val position = userDisplays.lastIndex sendEvent(UserDisplayEvent.ItemAdded(position, isLast = true)) } - fun addUserDisplayItem(item: UserDisplayItem, position: Int) = viewModelScope.launch { + fun addUserDisplayItem( + item: UserDisplayItem, + position: Int, + ) = viewModelScope.launch { userDisplayRepository.updateUserDisplay(item.toEntity()) userDisplays.add(position, item) val isLast = position == userDisplays.lastIndex @@ -58,7 +60,7 @@ class UserDisplayViewModel( userDisplayRepository.updateUserDisplays(entries) } - private suspend fun sendEvent(event: UserDisplayEvent) = withContext(Dispatchers.Main.immediate) { + private suspend fun sendEvent(event: UserDisplayEvent) = withContext(dispatchersProvider.immediate) { eventChannel.send(event) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/CheckboxWithText.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/CheckboxWithText.kt index 7dcce9a3f..82be69369 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/CheckboxWithText.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/CheckboxWithText.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +@Suppress("LambdaParameterEventTrailing") @Composable fun CheckboxWithText( text: String, @@ -27,16 +28,17 @@ fun CheckboxWithText( val interactionSource = remember { MutableInteractionSource() } Row( verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .padding(4.dp) - .selectable( - selected = checked, - interactionSource = interactionSource, - indication = null, - enabled = enabled, - onClick = { onCheckedChange(!checked) }, - role = Role.Checkbox, - ), + modifier = + modifier + .padding(4.dp) + .selectable( + selected = checked, + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = { onCheckedChange(!checked) }, + role = Role.Checkbox, + ), ) { Checkbox( checked = checked, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/DankBackground.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/DankBackground.kt index 2993dccc6..d0528f0da 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/DankBackground.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/DankBackground.kt @@ -28,9 +28,10 @@ fun DankBackground(visible: Boolean) { Icon( tint = MaterialTheme.colorScheme.inverseOnSurface, painter = dank, - modifier = Modifier - .fillMaxSize() - .align(Alignment.Center), + modifier = + Modifier + .fillMaxSize() + .align(Alignment.Center), contentDescription = null, ) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt index 57718c5f1..9c3ec2600 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceCategory.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.theme.DankChatTheme +import com.flxrs.dankchat.ui.theme.DankChatTheme @Composable fun PreferenceCategory( @@ -46,7 +46,10 @@ fun PreferenceCategoryWithSummary( } @Composable -fun PreferenceCategoryTitle(text: String, modifier: Modifier = Modifier) { +fun PreferenceCategoryTitle( + text: String, + modifier: Modifier = Modifier, +) { Text( text = text, style = MaterialTheme.typography.titleSmall, @@ -55,29 +58,35 @@ fun PreferenceCategoryTitle(text: String, modifier: Modifier = Modifier) { ) } +@Suppress("UnusedPrivateFunction") @Composable @PreviewLightDark -fun PreferenceCategoryPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryPreview( + @PreviewParameter(provider = LoremIpsum::class) loremIpsum: String, +) { DankChatTheme { Surface { PreferenceCategoryWithSummary( title = { PreferenceCategoryTitle("Title") }, - summary = { PreferenceSummary(loremIpsum.take(100)) } + summary = { PreferenceSummary(loremIpsum.take(100)) }, ) } } } +@Suppress("UnusedPrivateFunction", "UnusedParameter") @Composable @PreviewLightDark -fun PreferenceCategoryWithItemsPreview(@PreviewParameter(provider = LoremIpsum::class) loremIpsum: String) { +private fun PreferenceCategoryWithItemsPreview( + @PreviewParameter(provider = LoremIpsum::class) loremIpsum: String, +) { DankChatTheme { Surface { PreferenceCategory( title = "Title", content = { PreferenceItem("Appearence", Icons.Default.Palette) - } + }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceItem.kt index 52f0aedb3..6f0d3d303 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceItem.kt @@ -1,6 +1,5 @@ package com.flxrs.dankchat.preferences.components -import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -14,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Palette @@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import com.flxrs.dankchat.theme.DankChatTheme +import com.flxrs.dankchat.ui.theme.DankChatTheme import com.flxrs.dankchat.utils.compose.ContentAlpha import kotlin.math.roundToInt @@ -72,16 +72,18 @@ fun ExpandablePreferenceItem( content: @Composable ExpandablePreferenceScope.() -> Unit, ) { var contentVisible by remember { mutableStateOf(false) } - val scope = object : ExpandablePreferenceScope { - override fun dismiss() { - contentVisible = false + val scope = + object : ExpandablePreferenceScope { + override fun dismiss() { + contentVisible = false + } } - } val contentColor = LocalContentColor.current - val color = when { - isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) - } + val color = + when { + isEnabled -> contentColor + else -> contentColor.copy(alpha = ContentAlpha.disabled) + } HorizontalPreferenceItemWrapper( title = title, icon = icon, @@ -101,7 +103,7 @@ fun SliderPreferenceItem( value: Float, onDrag: (Float) -> Unit, range: ClosedFloatingPointRange, - onDragFinished: () -> Unit, + onDragFinish: () -> Unit, steps: Int = range.endInclusive.toInt() - range.start.toInt() - 1, isEnabled: Boolean = true, displayValue: Boolean = true, @@ -121,12 +123,14 @@ fun SliderPreferenceItem( Slider( value = value, onValueChange = onDrag, - onValueChangeFinished = onDragFinished, + onValueChangeFinished = onDragFinish, valueRange = range, steps = steps, - modifier = Modifier - .weight(1f) - .padding(top = 4.dp), + enabled = isEnabled, + modifier = + Modifier + .weight(1f) + .padding(top = 4.dp), ) if (displayValue) { Text( @@ -136,7 +140,7 @@ fun SliderPreferenceItem( ) } } - } + }, ) } @@ -155,16 +159,18 @@ fun PreferenceItem( summary = summary, isEnabled = isEnabled, onClick = onClick, - content = trailingIcon?.let { - { - val contentColor = LocalContentColor.current - val color = when { - isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) + content = + trailingIcon?.let { + { + val contentColor = LocalContentColor.current + val color = + when { + isEnabled -> contentColor + else -> contentColor.copy(alpha = ContentAlpha.disabled) + } + Icon(it, title, Modifier.padding(end = 4.dp), color) } - Icon(it, title, Modifier.padding(end = 4.dp), color) - } - } + }, ) } @@ -179,16 +185,16 @@ private fun HorizontalPreferenceItemWrapper( content: (@Composable RowScope.() -> Unit)? = null, ) { Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(48.dp) - .clickable( - enabled = isEnabled, - onClick = onClick, - interactionSource = interactionSource, - indication = ripple(), - ) - .padding(horizontal = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(48.dp) + .clickable( + enabled = isEnabled, + onClick = onClick, + interactionSource = interactionSource, + indication = ripple(), + ).padding(horizontal = 16.dp), verticalArrangement = Arrangement.Center, ) { Row( @@ -201,7 +207,6 @@ private fun HorizontalPreferenceItemWrapper( content() } } - } } @@ -216,16 +221,16 @@ private fun VerticalPreferenceItemWrapper( content: @Composable ColumnScope.() -> Unit = {}, ) { Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(48.dp) - .clickable( - enabled = isEnabled, - onClick = onClick, - interactionSource = interactionSource, - indication = ripple(), - ) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(48.dp) + .clickable( + enabled = isEnabled, + onClick = onClick, + interactionSource = interactionSource, + indication = ripple(), + ).padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.Center, ) { Row( @@ -247,10 +252,11 @@ private fun RowScope.PreferenceItemContent( textPaddingValues: PaddingValues = PaddingValues(vertical = 16.dp), ) { val contentColor = LocalContentColor.current - val color = when { - isEnabled -> contentColor - else -> contentColor.copy(alpha = ContentAlpha.disabled) - } + val color = + when { + isEnabled -> contentColor + else -> contentColor.copy(alpha = ContentAlpha.disabled) + } if (icon != null) { Icon( imageVector = icon, @@ -262,12 +268,12 @@ private fun RowScope.PreferenceItemContent( Column( Modifier .padding(textPaddingValues) - .weight(1f) + .weight(1f), ) { Text( text = title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.basicMarquee(), + autoSize = TextAutoSize.StepBased(maxFontSize = MaterialTheme.typography.titleMedium.fontSize), maxLines = 1, color = color, ) @@ -277,9 +283,10 @@ private fun RowScope.PreferenceItemContent( } } +@Suppress("UnusedPrivateFunction") @Composable @PreviewLightDark -fun PreferenceItemPreview() { +private fun PreferenceItemPreview() { DankChatTheme { Surface { PreferenceItem("Appearance", Icons.Default.Palette, summary = "Summary") diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceListDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceListDialog.kt index 3513cf8fe..581ca196f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceListDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceListDialog.kt @@ -30,7 +30,7 @@ fun PreferenceListDialog( values: ImmutableList, entries: ImmutableList, selected: T, - onChanged: (T) -> Unit, + onChange: (T) -> Unit, isEnabled: Boolean = true, summary: String? = null, icon: ImageVector? = null, @@ -46,31 +46,32 @@ fun PreferenceListDialog( ModalBottomSheet( onDismissRequest = ::dismiss, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - values.forEachIndexed { idx, it -> + values.forEachIndexed { idx, value -> val interactionSource = remember { MutableInteractionSource() } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = selected == it, - onClick = { - onChanged(it) - scope.launch { - sheetState.hide() - dismiss() - } - }, - interactionSource = interactionSource, - indication = ripple(), - ) - .padding(horizontal = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = selected == value, + onClick = { + onChange(value) + scope.launch { + sheetState.hide() + dismiss() + } + }, + interactionSource = interactionSource, + indication = ripple(), + ).padding(horizontal = 16.dp), ) { RadioButton( - selected = selected == it, + selected = selected == value, onClick = { - onChanged(it) + onChange(value) scope.launch { sheetState.hide() dismiss() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceMultiListDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceMultiListDialog.kt index f4862ea33..2e84f51a1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceMultiListDialog.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceMultiListDialog.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.preferences.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -32,10 +33,11 @@ fun PreferenceMultiListDialog( values: ImmutableList, initialSelected: ImmutableList, entries: ImmutableList, - onChanged: (List) -> Unit, + onChange: (List) -> Unit, isEnabled: Boolean = true, summary: String? = null, icon: ImageVector? = null, + descriptions: ImmutableList? = null, ) { var selected by remember(initialSelected) { mutableStateOf(values.map(initialSelected::contains).toPersistentList()) } ExpandablePreferenceItem( @@ -48,36 +50,46 @@ fun PreferenceMultiListDialog( ModalBottomSheet( onDismissRequest = { dismiss() - onChanged(values.filterIndexed { idx, _ -> selected[idx] }) + onChange(values.filterIndexed { idx, _ -> selected[idx] }) }, sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - entries.forEachIndexed { idx, it -> + entries.forEachIndexed { idx, entry -> val interactionSource = remember { MutableInteractionSource() } val itemSelected = selected[idx] Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = itemSelected, - onClick = { selected = selected.set(idx, !itemSelected) }, - interactionSource = interactionSource, - indication = ripple(), - ) - .padding(horizontal = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = itemSelected, + onClick = { selected = selected.set(idx, !itemSelected) }, + interactionSource = interactionSource, + indication = ripple(), + ).padding(horizontal = 16.dp), ) { Checkbox( checked = itemSelected, onCheckedChange = { selected = selected.set(idx, it) }, interactionSource = interactionSource, ) - Text( - text = it, - modifier = Modifier.padding(start = 16.dp), - style = MaterialTheme.typography.bodyLarge, - lineHeight = 18.sp, - ) + Column(modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp)) { + Text( + text = entry, + style = MaterialTheme.typography.bodyLarge, + lineHeight = 18.sp, + ) + val description = descriptions?.getOrNull(idx) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } Spacer(Modifier.height(32.dp)) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceSummary.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceSummary.kt index 672b286b1..257bc0853 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceSummary.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceSummary.kt @@ -9,12 +9,17 @@ import androidx.compose.ui.text.AnnotatedString import com.flxrs.dankchat.utils.compose.ContentAlpha @Composable -fun PreferenceSummary(summary: AnnotatedString, modifier: Modifier = Modifier, isEnabled: Boolean = true) { +fun PreferenceSummary( + summary: AnnotatedString, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { val contentColor = LocalContentColor.current - val color = when { - isEnabled -> contentColor.copy(alpha = ContentAlpha.high) - else -> contentColor.copy(alpha = ContentAlpha.disabled) - } + val color = + when { + isEnabled -> contentColor.copy(alpha = ContentAlpha.high) + else -> contentColor.copy(alpha = ContentAlpha.disabled) + } Text( text = summary, style = MaterialTheme.typography.bodyMedium, @@ -24,12 +29,16 @@ fun PreferenceSummary(summary: AnnotatedString, modifier: Modifier = Modifier, i } @Composable -fun PreferenceSummary(summary: String, isEnabled: Boolean = true) { +fun PreferenceSummary( + summary: String, + isEnabled: Boolean = true, +) { val contentColor = LocalContentColor.current - val color = when { - isEnabled -> contentColor.copy(alpha = ContentAlpha.high) - else -> contentColor.copy(alpha = ContentAlpha.disabled) - } + val color = + when { + isEnabled -> contentColor.copy(alpha = ContentAlpha.high) + else -> contentColor.copy(alpha = ContentAlpha.disabled) + } Text( text = summary, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceTabRow.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceTabRow.kt index 186a4f553..e53bb4201 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceTabRow.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/components/PreferenceTabRow.kt @@ -6,21 +6,20 @@ import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import kotlinx.coroutines.launch @Composable fun PreferenceTabRow( - appBarContainerColor: State, + appBarContainerColor: Color, pagerState: PagerState, tabCount: Int, tabText: @Composable (Int) -> String, ) { val scope = rememberCoroutineScope() PrimaryTabRow( - containerColor = appBarContainerColor.value, + containerColor = appBarContainerColor, selectedTabIndex = pagerState.currentPage, ) { val unselectedColor = MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/ChatSendProtocol.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/ChatSendProtocol.kt new file mode 100644 index 000000000..bd88e2db6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/ChatSendProtocol.kt @@ -0,0 +1,9 @@ +package com.flxrs.dankchat.preferences.developer + +import kotlinx.serialization.Serializable + +@Serializable +enum class ChatSendProtocol { + IRC, + Helix, +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettings.kt index ed5093901..59ba7e991 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettings.kt @@ -10,8 +10,8 @@ data class DeveloperSettings( val customRecentMessagesHost: String = RM_HOST_DEFAULT, val eventSubEnabled: Boolean = true, val eventSubDebugOutput: Boolean = false, + val chatSendProtocol: ChatSendProtocol = ChatSendProtocol.IRC, ) { - val isPubSubShutdown: Boolean get() = System.currentTimeMillis() > PUBSUB_SHUTDOWN_MILLIS val shouldUseEventSub: Boolean get() = eventSubEnabled || isPubSubShutdown val shouldUsePubSub: Boolean get() = !shouldUseEventSub diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsDataStore.kt index c78435b9f..8167f105d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsDataStore.kt @@ -22,38 +22,42 @@ class DeveloperSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class DeveloperPreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class DeveloperPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { DebugMode(R.string.preference_debug_mode_key), RepeatedSending(R.string.preference_repeated_sending_key), BypassCommandHandling(R.string.preference_bypass_command_handling_key), - CustomRecentMessagesHost(R.string.preference_rm_host_key) + CustomRecentMessagesHost(R.string.preference_rm_host_key), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - DeveloperPreferenceKeys.DebugMode -> acc.copy(debugMode = value.booleanOrDefault(acc.debugMode)) - DeveloperPreferenceKeys.RepeatedSending -> acc.copy(repeatedSending = value.booleanOrDefault(acc.repeatedSending)) - DeveloperPreferenceKeys.BypassCommandHandling -> acc.copy(bypassCommandHandling = value.booleanOrDefault(acc.bypassCommandHandling)) - DeveloperPreferenceKeys.CustomRecentMessagesHost -> acc.copy(customRecentMessagesHost = value.stringOrDefault(acc.customRecentMessagesHost)) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + DeveloperPreferenceKeys.DebugMode -> acc.copy(debugMode = value.booleanOrDefault(acc.debugMode)) + DeveloperPreferenceKeys.RepeatedSending -> acc.copy(repeatedSending = value.booleanOrDefault(acc.repeatedSending)) + DeveloperPreferenceKeys.BypassCommandHandling -> acc.copy(bypassCommandHandling = value.booleanOrDefault(acc.bypassCommandHandling)) + DeveloperPreferenceKeys.CustomRecentMessagesHost -> acc.copy(customRecentMessagesHost = value.stringOrDefault(acc.customRecentMessagesHost)) + } } - } - private val dataStore = createDataStore( - fileName = "developer", - context = context, - defaultValue = DeveloperSettings(), - serializer = DeveloperSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration), - ) + private val dataStore = + createDataStore( + fileName = "developer", + context = context, + defaultValue = DeveloperSettings(), + serializer = DeveloperSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration), + ) val settings = dataStore.safeData(DeveloperSettings()) - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) fun current() = currentSettings.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt deleted file mode 100644 index 507459bea..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsFragment.kt +++ /dev/null @@ -1,465 +0,0 @@ -package com.flxrs.dankchat.preferences.developer - -import android.content.ClipData -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextObfuscationMode -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedSecureTextField -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.R -import com.flxrs.dankchat.preferences.components.ExpandablePreferenceItem -import com.flxrs.dankchat.preferences.components.NavigationBarSpacer -import com.flxrs.dankchat.preferences.components.PreferenceCategory -import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem -import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubDebugOutput -import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubEnabled -import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState -import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginViewModel -import com.flxrs.dankchat.theme.DankChatTheme -import com.flxrs.dankchat.utils.extensions.truncate -import com.google.android.material.transition.MaterialFadeThrough -import com.jakewharton.processphoenix.ProcessPhoenix -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel - -class DeveloperSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setContent { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value - - val context = LocalContext.current - val restartRequiredTitle = stringResource(R.string.restart_required) - val restartRequiredAction = stringResource(R.string.restart) - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { - when (it) { - DeveloperSettingsEvent.RestartRequired -> { - val result = snackbarHostState.showSnackbar( - message = restartRequiredTitle, - actionLabel = restartRequiredAction, - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed) { - ProcessPhoenix.triggerRebirth(context) - } - } - } - } - } - - DankChatTheme { - DeveloperSettings( - settings = settings, - snackbarHostState = snackbarHostState, - onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = { findNavController().popBackStack() }, - ) - } - } - } - } -} - -@Composable -private fun DeveloperSettings( - settings: DeveloperSettings, - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - onInteraction: (DeveloperSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, -) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() - Scaffold( - contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - snackbarHost = { SnackbarHost(snackbarHostState, modifier = Modifier.navigationBarsPadding()) }, - topBar = { - TopAppBar( - scrollBehavior = scrollBehavior, - title = { Text(stringResource(R.string.preference_developer_header)) }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, - ) - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), - ) { - SwitchPreferenceItem( - title = stringResource(R.string.preference_debug_mode_title), - summary = stringResource(R.string.preference_debug_mode_summary), - isChecked = settings.debugMode, - onClick = { onInteraction(DeveloperSettingsInteraction.DebugMode(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_repeated_sending_title), - summary = stringResource(R.string.preference_repeated_sending_summary), - isChecked = settings.repeatedSending, - onClick = { onInteraction(DeveloperSettingsInteraction.RepeatedSending(it)) }, - ) - SwitchPreferenceItem( - title = stringResource(R.string.preference_bypass_command_handling_title), - summary = stringResource(R.string.preference_bypass_command_handling_summary), - isChecked = settings.bypassCommandHandling, - onClick = { onInteraction(DeveloperSettingsInteraction.BypassCommandHandling(it)) }, - ) - ExpandablePreferenceItem(title = stringResource(R.string.preference_custom_login_title)) { - CustomLoginBottomSheet( - onDismissRequested = ::dismiss, - onRestartRequiredRequested = { - dismiss() - onInteraction(DeveloperSettingsInteraction.RestartRequired) - } - ) - } - ExpandablePreferenceItem(title = stringResource(R.string.preference_rm_host_title)) { - CustomRecentMessagesHostBottomSheet( - initialHost = settings.customRecentMessagesHost, - onInteraction = { - dismiss() - onInteraction(it) - }, - ) - } - - PreferenceCategory(title = "EventSub") { - if (!settings.isPubSubShutdown) { - SwitchPreferenceItem( - title = "Enable Twitch EventSub", - summary = "Uses EventSub for various real-time events instead of deprecated PubSub", - isChecked = settings.shouldUseEventSub, - onClick = { onInteraction(EventSubEnabled(it)) }, - ) - } - SwitchPreferenceItem( - title = "Enable EventSub debug output", - summary = "Prints debug output related to EventSub as system messages", - isEnabled = settings.shouldUseEventSub, - isChecked = settings.eventSubDebugOutput, - onClick = { onInteraction(EventSubDebugOutput(it)) }, - ) - } - - NavigationBarSpacer() - } - } -} - -@Composable -private fun CustomRecentMessagesHostBottomSheet( - initialHost: String, - onInteraction: (DeveloperSettingsInteraction) -> Unit, -) { - var host by remember(initialHost) { mutableStateOf(initialHost) } - ModalBottomSheet(onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }) { - Text( - text = stringResource(R.string.preference_rm_host_title), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - ) - TextButton( - onClick = { host = DeveloperSettings.RM_HOST_DEFAULT }, - content = { Text(stringResource(R.string.reset)) }, - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 16.dp), - ) - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - value = host, - onValueChange = { host = it }, - label = { Text(stringResource(R.string.host)) }, - maxLines = 1, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - ), - ) - Spacer(Modifier.height(64.dp)) - } -} - -@Composable -private fun CustomLoginBottomSheet( - onDismissRequested: () -> Unit, - onRestartRequiredRequested: () -> Unit, -) { - val scope = rememberCoroutineScope() - val customLoginViewModel = koinInject() - val state = customLoginViewModel.customLoginState.collectAsStateWithLifecycle().value - val token = rememberTextFieldState(customLoginViewModel.getToken()) - var showScopesDialog by remember { mutableStateOf(false) } - - val error = when (state) { - is CustomLoginState.Failure -> stringResource(R.string.custom_login_error_fallback, state.error.truncate()) - CustomLoginState.TokenEmpty -> stringResource(R.string.custom_login_error_empty_token) - CustomLoginState.TokenInvalid -> stringResource(R.string.custom_login_error_invalid_token) - is CustomLoginState.MissingScopes -> stringResource(R.string.custom_login_error_missing_scopes, state.missingScopes.truncate()) - else -> null - } - - LaunchedEffect(state) { - if (state is CustomLoginState.Validated) { - onRestartRequiredRequested() - } - } - - ModalBottomSheet(onDismissRequest = onDismissRequested) { - Column(Modifier.padding(horizontal = 16.dp)) { - Text( - text = stringResource(R.string.preference_custom_login_title), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - Text( - text = stringResource(R.string.custom_login_hint), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - ) - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.End), - ) { - TextButton( - onClick = { showScopesDialog = true }, - content = { Text(stringResource(R.string.custom_login_show_scopes)) }, - ) - TextButton( - onClick = { token.setTextAndPlaceCursorAtEnd(customLoginViewModel.getToken()) }, - content = { Text(stringResource(R.string.reset)) }, - ) - } - - var showPassword by remember { mutableStateOf(false) } - OutlinedSecureTextField( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - state = token, - textObfuscationMode = when { - showPassword -> TextObfuscationMode.Visible - else -> TextObfuscationMode.Hidden - }, - label = { Text(stringResource(R.string.oauth_token)) }, - isError = error != null, - supportingText = { error?.let { Text(it) } }, - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - content = { - Icon( - imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, - contentDescription = null, - ) - } - ) - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - ), - ) - - AnimatedVisibility(visible = state !is CustomLoginState.Loading, modifier = Modifier.fillMaxWidth()) { - TextButton( - onClick = { - scope.launch { customLoginViewModel.validateCustomLogin(token.text.toString()) } - }, - contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, - content = { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Save, contentDescription = stringResource(R.string.verify_login)) - Spacer(Modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.verify_login)) - } - }, - ) - } - AnimatedVisibility(visible = state is CustomLoginState.Loading, modifier = Modifier.fillMaxWidth()) { - LinearProgressIndicator() - } - Spacer(Modifier.height(64.dp)) - } - } - - if (showScopesDialog) { - ShowScopesBottomSheet( - scopes = customLoginViewModel.getScopes(), - onDismissRequested = { showScopesDialog = false }, - ) - } - - if (state is CustomLoginState.MissingScopes && state.dialogOpen) { - MissingScopesDialog( - missing = state.missingScopes, - onDismissRequested = { customLoginViewModel.dismissMissingScopesDialog() }, - onContinueRequested = { - customLoginViewModel.saveLogin(state.token, state.validation) - onRestartRequiredRequested() - }, - ) - } -} - -@Composable -private fun ShowScopesBottomSheet(scopes: String, onDismissRequested: () -> Unit) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - ModalBottomSheet(onDismissRequested, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { - Column(Modifier.padding(horizontal = 16.dp)) { - Text( - text = stringResource(R.string.custom_login_required_scopes), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - OutlinedTextField( - value = scopes, - onValueChange = {}, - readOnly = true, - trailingIcon = { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("scopes", scopes))) - } - }, - content = { Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null) } - ) - } - ) - } - Spacer(Modifier.height(16.dp)) - } -} - -@Composable -private fun MissingScopesDialog(missing: String, onDismissRequested: () -> Unit, onContinueRequested: () -> Unit) { - AlertDialog( - onDismissRequest = onDismissRequested, - title = { Text(stringResource(R.string.custom_login_missing_scopes_title)) }, - text = { Text(stringResource(R.string.custom_login_missing_scopes_text, missing)) }, - confirmButton = { - TextButton( - onClick = onContinueRequested, - content = { Text(stringResource(R.string.custom_login_missing_scopes_continue)) } - ) - }, - dismissButton = { - TextButton( - onClick = onDismissRequested, - content = { Text(stringResource(R.string.dialog_cancel)) } - ) - }, - ) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt new file mode 100644 index 000000000..082570693 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsScreen.kt @@ -0,0 +1,695 @@ +package com.flxrs.dankchat.preferences.developer + +import android.content.ClipData +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.repo.crash.CrashRepository +import com.flxrs.dankchat.data.repo.log.LogRepository +import com.flxrs.dankchat.preferences.components.ExpandablePreferenceItem +import com.flxrs.dankchat.preferences.components.NavigationBarSpacer +import com.flxrs.dankchat.preferences.components.PreferenceCategory +import com.flxrs.dankchat.preferences.components.PreferenceItem +import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubDebugOutput +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsInteraction.EventSubEnabled +import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState +import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginViewModel +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet +import com.flxrs.dankchat.utils.extensions.truncate +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun DeveloperSettingsScreen( + onBack: () -> Unit, + onOpenLogViewer: (fileName: String) -> Unit = {}, + onOpenCrashViewer: (crashId: Long) -> Unit = {}, +) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value + + val context = LocalContext.current + val restartRequiredTitle = stringResource(R.string.restart_required) + val restartRequiredAction = stringResource(R.string.restart) + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(viewModel) { + viewModel.events.collectLatest { + when (it) { + DeveloperSettingsEvent.RestartRequired -> { + val result = + snackbarHostState.showSnackbar( + message = restartRequiredTitle, + actionLabel = restartRequiredAction, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + ProcessPhoenix.triggerRebirth(context) + } + } + + DeveloperSettingsEvent.ImmediateRestart -> { + ProcessPhoenix.triggerRebirth(context) + } + } + } + } + + DeveloperSettingsContent( + settings = settings, + snackbarHostState = snackbarHostState, + onInteraction = { viewModel.onInteraction(it) }, + onBack = onBack, + onOpenLogViewer = onOpenLogViewer, + onOpenCrashViewer = onOpenCrashViewer, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeveloperSettingsContent( + settings: DeveloperSettings, + snackbarHostState: SnackbarHostState, + onInteraction: (DeveloperSettingsInteraction) -> Unit, + onBack: () -> Unit, + onOpenLogViewer: (fileName: String) -> Unit, + onOpenCrashViewer: (crashId: Long) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState, modifier = Modifier.navigationBarsPadding()) }, + topBar = { + TopAppBar( + scrollBehavior = scrollBehavior, + title = { Text(stringResource(R.string.preference_developer_header)) }, + navigationIcon = { + IconButton( + onClick = onBack, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, + ) + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + PreferenceCategory(title = stringResource(R.string.preference_developer_category_general)) { + run { + val logRepository: LogRepository = koinInject() + val logFiles = remember { logRepository.getLogFiles() } + ExpandablePreferenceItem( + title = stringResource(R.string.preference_log_viewer_title), + summary = stringResource(R.string.preference_log_viewer_summary), + ) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + if (logFiles.isEmpty()) { + Text( + text = stringResource(R.string.preference_log_viewer_empty), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(16.dp), + ) + } else { + logFiles.forEach { file -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + scope.launch { + sheetState.hide() + dismiss() + } + onOpenLogViewer(file.name) + }.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + Spacer(Modifier.height(32.dp)) + } + } + } + SwitchPreferenceItem( + title = stringResource(R.string.preference_debug_mode_title), + summary = stringResource(R.string.preference_debug_mode_summary), + isChecked = settings.debugMode, + onClick = { onInteraction(DeveloperSettingsInteraction.DebugMode(it)) }, + ) + val crashRepository: CrashRepository = koinInject() + var crashes by remember { mutableStateOf(crashRepository.getRecentCrashes()) } + ExpandablePreferenceItem( + title = stringResource(R.string.preference_crash_viewer_title), + summary = stringResource(R.string.preference_crash_viewer_summary), + isEnabled = settings.debugMode, + ) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() + var confirmationState by remember { mutableStateOf(CrashDeleteConfirmation.None) } + ModalBottomSheet( + onDismissRequest = ::dismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = confirmationState, + label = "CrashDeleteConfirmation", + ) { state -> + when (state) { + is CrashDeleteConfirmation.None -> { + if (crashes.isEmpty()) { + Text( + text = stringResource(R.string.preference_crash_viewer_empty), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(16.dp), + ) + } else { + Column { + TextButton( + onClick = { confirmationState = CrashDeleteConfirmation.All }, + modifier = Modifier + .align(Alignment.End) + .padding(end = 8.dp), + ) { + Text( + text = stringResource(R.string.crash_viewer_clear_all), + color = MaterialTheme.colorScheme.error, + ) + } + crashes.forEach { crash -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { + scope.launch { + sheetState.hide() + dismiss() + } + onOpenCrashViewer(crash.id) + }.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Column { + Text( + text = crash.exceptionHeader, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = crash.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + + is CrashDeleteConfirmation.All -> { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.crash_viewer_clear_all_confirm), + style = MaterialTheme.typography.bodyLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { confirmationState = CrashDeleteConfirmation.None }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.dialog_cancel)) + } + Button( + onClick = { + crashRepository.deleteAllCrashes() + crashes = emptyList() + confirmationState = CrashDeleteConfirmation.None + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.crash_viewer_clear_all)) + } + } + } + } + } + } + Spacer(Modifier.height(32.dp)) + } + } + SwitchPreferenceItem( + title = stringResource(R.string.preference_repeated_sending_title), + summary = stringResource(R.string.preference_repeated_sending_summary), + isChecked = settings.repeatedSending, + onClick = { onInteraction(DeveloperSettingsInteraction.RepeatedSending(it)) }, + ) + ExpandablePreferenceItem(title = stringResource(R.string.preference_rm_host_title)) { + CustomRecentMessagesHostBottomSheet( + initialHost = settings.customRecentMessagesHost, + onInteraction = { + dismiss() + onInteraction(it) + }, + ) + } + } + + PreferenceCategory(title = stringResource(R.string.preference_developer_category_twitch)) { + SwitchPreferenceItem( + title = stringResource(R.string.preference_bypass_command_handling_title), + summary = stringResource(R.string.preference_bypass_command_handling_summary), + isChecked = settings.bypassCommandHandling, + onClick = { onInteraction(DeveloperSettingsInteraction.BypassCommandHandling(it)) }, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_helix_sending_title), + summary = stringResource(R.string.preference_helix_sending_summary), + isChecked = settings.chatSendProtocol == ChatSendProtocol.Helix, + onClick = { enabled -> + val protocol = + when { + enabled -> ChatSendProtocol.Helix + else -> ChatSendProtocol.IRC + } + onInteraction(DeveloperSettingsInteraction.ChatSendProtocolChanged(protocol)) + }, + ) + if (!settings.isPubSubShutdown) { + SwitchPreferenceItem( + title = stringResource(R.string.preference_eventsub_title), + summary = stringResource(R.string.preference_eventsub_summary), + isChecked = settings.shouldUseEventSub, + onClick = { onInteraction(EventSubEnabled(it)) }, + ) + } + SwitchPreferenceItem( + title = stringResource(R.string.preference_eventsub_debug_title), + summary = stringResource(R.string.preference_eventsub_debug_summary), + isEnabled = settings.shouldUseEventSub, + isChecked = settings.eventSubDebugOutput, + onClick = { onInteraction(EventSubDebugOutput(it)) }, + ) + } + + PreferenceCategory(title = stringResource(R.string.preference_developer_category_auth)) { + ExpandablePreferenceItem(title = stringResource(R.string.preference_custom_login_title)) { + CustomLoginBottomSheet( + onDismissRequest = ::dismiss, + onRequestRestart = { + dismiss() + onInteraction(DeveloperSettingsInteraction.RestartRequired) + }, + ) + } + PreferenceItem( + title = stringResource(R.string.preference_revoke_token_title), + summary = stringResource(R.string.preference_revoke_token_summary), + onClick = { onInteraction(DeveloperSettingsInteraction.RevokeToken) }, + ) + } + + PreferenceCategory(title = stringResource(R.string.preference_reset_onboarding_category)) { + PreferenceItem( + title = stringResource(R.string.preference_reset_onboarding_title), + summary = stringResource(R.string.preference_reset_onboarding_summary), + onClick = { onInteraction(DeveloperSettingsInteraction.ResetOnboarding) }, + ) + PreferenceItem( + title = stringResource(R.string.preference_reset_tour_title), + summary = stringResource(R.string.preference_reset_tour_summary), + onClick = { onInteraction(DeveloperSettingsInteraction.ResetTour) }, + ) + } + + NavigationBarSpacer() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomRecentMessagesHostBottomSheet( + initialHost: String, + onInteraction: (DeveloperSettingsInteraction) -> Unit, +) { + var host by remember(initialHost) { mutableStateOf(initialHost) } + ModalBottomSheet( + onDismissRequest = { onInteraction(DeveloperSettingsInteraction.CustomRecentMessagesHost(host)) }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = stringResource(R.string.preference_rm_host_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + TextButton( + onClick = { host = DeveloperSettings.RM_HOST_DEFAULT }, + content = { Text(stringResource(R.string.reset)) }, + modifier = + Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + ) + OutlinedTextField( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = host, + onValueChange = { host = it }, + label = { Text(stringResource(R.string.host)) }, + maxLines = 1, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + ) + Spacer(Modifier.height(64.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomLoginBottomSheet( + onDismissRequest: () -> Unit, + onRequestRestart: () -> Unit, +) { + val scope = rememberCoroutineScope() + val customLoginViewModel = koinInject() + val state = customLoginViewModel.customLoginState.collectAsStateWithLifecycle().value + val token = rememberTextFieldState(customLoginViewModel.getToken()) + var showScopesDialog by remember { mutableStateOf(false) } + + val error = + when (state) { + is CustomLoginState.Failure -> stringResource(R.string.custom_login_error_fallback, state.error.truncate()) + CustomLoginState.TokenEmpty -> stringResource(R.string.custom_login_error_empty_token) + CustomLoginState.TokenInvalid -> stringResource(R.string.custom_login_error_invalid_token) + is CustomLoginState.MissingScopes -> stringResource(R.string.custom_login_error_missing_scopes, state.missingScopes.truncate()) + else -> null + } + + LaunchedEffect(state) { + if (state is CustomLoginState.Validated) { + onRequestRestart() + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + text = stringResource(R.string.preference_custom_login_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + Text( + text = stringResource(R.string.custom_login_hint), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + Row( + horizontalArrangement = Arrangement.End, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.End), + ) { + TextButton( + onClick = { showScopesDialog = true }, + content = { Text(stringResource(R.string.custom_login_show_scopes)) }, + ) + TextButton( + onClick = { token.setTextAndPlaceCursorAtEnd(customLoginViewModel.getToken()) }, + content = { Text(stringResource(R.string.reset)) }, + ) + } + + var showPassword by remember { mutableStateOf(false) } + androidx.compose.material3.OutlinedSecureTextField( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + state = token, + textObfuscationMode = + when { + showPassword -> TextObfuscationMode.Visible + else -> TextObfuscationMode.Hidden + }, + label = { Text(stringResource(R.string.oauth_token)) }, + isError = error != null, + supportingText = { error?.let { Text(it) } }, + trailingIcon = { + IconButton( + onClick = { showPassword = !showPassword }, + content = { + Icon( + imageVector = if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff, + contentDescription = null, + ) + }, + ) + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + ) + + AnimatedVisibility(visible = state !is CustomLoginState.Loading, modifier = Modifier.fillMaxWidth()) { + TextButton( + onClick = { + scope.launch { customLoginViewModel.validateCustomLogin(token.text.toString()) } + }, + contentPadding = ButtonDefaults.TextButtonWithIconContentPadding, + content = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Save, contentDescription = stringResource(R.string.verify_login)) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.verify_login)) + } + }, + ) + } + AnimatedVisibility(visible = state is CustomLoginState.Loading, modifier = Modifier.fillMaxWidth()) { + LinearProgressIndicator() + } + Spacer(Modifier.height(64.dp)) + } + } + + if (showScopesDialog) { + ShowScopesBottomSheet( + scopes = customLoginViewModel.getScopes(), + onDismissRequest = { showScopesDialog = false }, + ) + } + + if (state is CustomLoginState.MissingScopes && state.dialogOpen) { + MissingScopesDialog( + missing = state.missingScopes, + onDismissRequest = { customLoginViewModel.dismissMissingScopesDialog() }, + onContinue = { + customLoginViewModel.saveLogin(state.token, state.validation) + onRequestRestart() + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShowScopesBottomSheet( + scopes: String, + onDismissRequest: () -> Unit, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + text = stringResource(R.string.custom_login_required_scopes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + OutlinedTextField( + value = scopes, + onValueChange = {}, + readOnly = true, + trailingIcon = { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("scopes", scopes))) + } + }, + content = { Icon(imageVector = Icons.Default.ContentCopy, contentDescription = null) }, + ) + }, + ) + } + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun MissingScopesDialog( + missing: String, + onDismissRequest: () -> Unit, + onContinue: () -> Unit, +) { + ConfirmationBottomSheet( + title = stringResource(R.string.custom_login_missing_scopes_title), + message = stringResource(R.string.custom_login_missing_scopes_text, missing), + confirmText = stringResource(R.string.custom_login_missing_scopes_continue), + onConfirm = onContinue, + onDismiss = onDismissRequest, + ) +} + +private sealed interface CrashDeleteConfirmation { + data object None : CrashDeleteConfirmation + + data object All : CrashDeleteConfirmation +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt new file mode 100644 index 000000000..113830ace --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsState.kt @@ -0,0 +1,45 @@ +package com.flxrs.dankchat.preferences.developer + +sealed interface DeveloperSettingsEvent { + data object RestartRequired : DeveloperSettingsEvent + + data object ImmediateRestart : DeveloperSettingsEvent +} + +sealed interface DeveloperSettingsInteraction { + data class DebugMode( + val value: Boolean, + ) : DeveloperSettingsInteraction + + data class RepeatedSending( + val value: Boolean, + ) : DeveloperSettingsInteraction + + data class BypassCommandHandling( + val value: Boolean, + ) : DeveloperSettingsInteraction + + data class CustomRecentMessagesHost( + val host: String, + ) : DeveloperSettingsInteraction + + data class EventSubEnabled( + val value: Boolean, + ) : DeveloperSettingsInteraction + + data class EventSubDebugOutput( + val value: Boolean, + ) : DeveloperSettingsInteraction + + data class ChatSendProtocolChanged( + val protocol: ChatSendProtocol, + ) : DeveloperSettingsInteraction + + data object RestartRequired : DeveloperSettingsInteraction + + data object ResetOnboarding : DeveloperSettingsInteraction + + data object ResetTour : DeveloperSettingsInteraction + + data object RevokeToken : DeveloperSettingsInteraction +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsViewModel.kt index 8903249e2..c7211f0a3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/DeveloperSettingsViewModel.kt @@ -2,8 +2,11 @@ package com.flxrs.dankchat.preferences.developer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.api.auth.AuthApiClient +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore import com.flxrs.dankchat.utils.extensions.withTrailingSlash +import com.flxrs.dankchat.utils.extensions.withoutOAuthPrefix import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed @@ -11,21 +14,23 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class DeveloperSettingsViewModel( private val developerSettingsDataStore: DeveloperSettingsDataStore, - private val dankchatPreferenceStore: DankChatPreferenceStore, + private val onboardingDataStore: OnboardingDataStore, + private val authApiClient: AuthApiClient, + private val authDataStore: AuthDataStore, ) : ViewModel() { - private val initial = developerSettingsDataStore.current() - val settings = developerSettingsDataStore.settings.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = initial, - ) + val settings = + developerSettingsDataStore.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = initial, + ) private val _events = MutableSharedFlow() val events = _events.asSharedFlow() @@ -33,44 +38,76 @@ class DeveloperSettingsViewModel( fun onInteraction(interaction: DeveloperSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is DeveloperSettingsInteraction.DebugMode -> developerSettingsDataStore.update { it.copy(debugMode = interaction.value) } - is DeveloperSettingsInteraction.RepeatedSending -> developerSettingsDataStore.update { it.copy(repeatedSending = interaction.value) } - is DeveloperSettingsInteraction.BypassCommandHandling -> developerSettingsDataStore.update { it.copy(bypassCommandHandling = interaction.value) } + is DeveloperSettingsInteraction.DebugMode -> { + developerSettingsDataStore.update { it.copy(debugMode = interaction.value) } + } + + is DeveloperSettingsInteraction.RepeatedSending -> { + developerSettingsDataStore.update { it.copy(repeatedSending = interaction.value) } + } + + is DeveloperSettingsInteraction.BypassCommandHandling -> { + developerSettingsDataStore.update { it.copy(bypassCommandHandling = interaction.value) } + } + is DeveloperSettingsInteraction.CustomRecentMessagesHost -> { - val withSlash = interaction.host - .ifBlank { DeveloperSettings.RM_HOST_DEFAULT } - .withTrailingSlash + val withSlash = + interaction.host + .ifBlank { DeveloperSettings.RM_HOST_DEFAULT } + .withTrailingSlash if (withSlash == developerSettingsDataStore.settings.first().customRecentMessagesHost) return@launch developerSettingsDataStore.update { it.copy(customRecentMessagesHost = withSlash) } _events.emit(DeveloperSettingsEvent.RestartRequired) } - is DeveloperSettingsInteraction.EventSubEnabled -> { + is DeveloperSettingsInteraction.EventSubEnabled -> { developerSettingsDataStore.update { it.copy(eventSubEnabled = interaction.value) } if (initial.eventSubEnabled != interaction.value) { _events.emit(DeveloperSettingsEvent.RestartRequired) } } - is DeveloperSettingsInteraction.EventSubDebugOutput -> developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } - is DeveloperSettingsInteraction.RestartRequired -> _events.emit(DeveloperSettingsEvent.RestartRequired) - } - } - } -} + is DeveloperSettingsInteraction.EventSubDebugOutput -> { + developerSettingsDataStore.update { it.copy(eventSubDebugOutput = interaction.value) } + } -sealed interface DeveloperSettingsEvent { - data object RestartRequired : DeveloperSettingsEvent -} + is DeveloperSettingsInteraction.ChatSendProtocolChanged -> { + developerSettingsDataStore.update { it.copy(chatSendProtocol = interaction.protocol) } + } -sealed interface DeveloperSettingsInteraction { - data class DebugMode(val value: Boolean) : DeveloperSettingsInteraction - data class RepeatedSending(val value: Boolean) : DeveloperSettingsInteraction - data class BypassCommandHandling(val value: Boolean) : DeveloperSettingsInteraction - data class CustomRecentMessagesHost(val host: String) : DeveloperSettingsInteraction - data class EventSubEnabled(val value: Boolean) : DeveloperSettingsInteraction - data class EventSubDebugOutput(val value: Boolean) : DeveloperSettingsInteraction - data object RestartRequired : DeveloperSettingsInteraction -} + is DeveloperSettingsInteraction.RestartRequired -> { + _events.emit(DeveloperSettingsEvent.RestartRequired) + } + is DeveloperSettingsInteraction.ResetOnboarding -> { + onboardingDataStore.update { + it.copy( + hasCompletedOnboarding = false, + onboardingPage = 0, + ) + } + _events.emit(DeveloperSettingsEvent.RestartRequired) + } + + is DeveloperSettingsInteraction.ResetTour -> { + onboardingDataStore.update { + it.copy( + featureTourVersion = 0, + featureTourStep = 0, + hasShownAddChannelHint = false, + hasShownToolbarHint = false, + ) + } + _events.emit(DeveloperSettingsEvent.RestartRequired) + } + is DeveloperSettingsInteraction.RevokeToken -> { + val token = authDataStore.oAuthKey?.withoutOAuthPrefix ?: return@launch + val clientId = authDataStore.clientId + authApiClient.revokeToken(token, clientId) + _events.emit(DeveloperSettingsEvent.ImmediateRestart) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginState.kt index 7fa72b57b..d243a0775 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginState.kt @@ -4,17 +4,23 @@ import com.flxrs.dankchat.data.api.auth.dto.ValidateDto sealed interface CustomLoginState { object Default : CustomLoginState + object Validated : CustomLoginState + object Loading : CustomLoginState object TokenEmpty : CustomLoginState + object TokenInvalid : CustomLoginState + data class MissingScopes( val missingScopes: String, val validation: ValidateDto, val token: String, - val dialogOpen: Boolean + val dialogOpen: Boolean, ) : CustomLoginState - data class Failure(val error: String) : CustomLoginState + data class Failure( + val error: String, + ) : CustomLoginState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginViewModel.kt index c43f1c33b..1bde6b477 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/developer/customlogin/CustomLoginViewModel.kt @@ -3,7 +3,7 @@ package com.flxrs.dankchat.preferences.developer.customlogin import com.flxrs.dankchat.data.api.ApiException import com.flxrs.dankchat.data.api.auth.AuthApiClient import com.flxrs.dankchat.data.api.auth.dto.ValidateDto -import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.data.auth.AuthDataStore import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState.Default import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState.Failure import com.flxrs.dankchat.preferences.developer.customlogin.CustomLoginState.Loading @@ -21,9 +21,8 @@ import org.koin.core.annotation.Factory @Factory class CustomLoginViewModel( private val authApiClient: AuthApiClient, - private val dankChatPreferenceStore: DankChatPreferenceStore + private val authDataStore: AuthDataStore, ) { - private val _customLoginState = MutableStateFlow(Default) val customLoginState = _customLoginState.asStateFlow() @@ -36,30 +35,33 @@ class CustomLoginViewModel( _customLoginState.update { Loading } val token = oAuthToken.withoutOAuthPrefix - val result = authApiClient.validateUser(token).fold( - onSuccess = { result -> - val scopes = result.scopes.orEmpty() - when { - !authApiClient.validateScopes(scopes) -> MissingScopes( - missingScopes = authApiClient.missingScopes(scopes).joinToString(), - validation = result, - token = token, - dialogOpen = true, - ) + val result = + authApiClient.validateUser(token).fold( + onSuccess = { result -> + val scopes = result.scopes.orEmpty() + when { + !authApiClient.validateScopes(scopes) -> { + MissingScopes( + missingScopes = authApiClient.missingScopes(scopes).joinToString(), + validation = result, + token = token, + dialogOpen = true, + ) + } - else -> { - saveLogin(token, result) - Validated + else -> { + saveLogin(token, result) + Validated + } + } + }, + onFailure = { + when { + it is ApiException && it.status == HttpStatusCode.Unauthorized -> TokenInvalid + else -> Failure(it.message.orEmpty()) } - } - }, - onFailure = { - when { - it is ApiException && it.status == HttpStatusCode.Unauthorized -> TokenInvalid - else -> Failure(it.message.orEmpty()) - } - } - ) + }, + ) _customLoginState.update { result } } @@ -68,14 +70,22 @@ class CustomLoginViewModel( _customLoginState.update { (it as? MissingScopes)?.copy(dialogOpen = false) ?: it } } - fun saveLogin(token: String, validateDto: ValidateDto) = with(dankChatPreferenceStore) { - clientId = validateDto.clientId - oAuthKey = "oauth:$token" - userIdString = validateDto.userId - userName = validateDto.login - isLoggedIn = true + fun saveLogin( + token: String, + validateDto: ValidateDto, + ) { + authDataStore.updateAsync { + it.copy( + oAuthKey = "oauth:$token", + userName = validateDto.login.value, + userId = validateDto.userId.value, + clientId = validateDto.clientId, + isLoggedIn = true, + ) + } } fun getScopes() = AuthApiClient.SCOPES.joinToString(separator = "+") - fun getToken() = dankChatPreferenceStore.oAuthKey?.withoutOAuthPrefix.orEmpty() + + fun getToken() = authDataStore.oAuthKey?.withoutOAuthPrefix.orEmpty() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/model/ChannelWithRename.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/model/ChannelWithRename.kt index 6ffad85d2..2636dbbe7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/model/ChannelWithRename.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/model/ChannelWithRename.kt @@ -5,4 +5,7 @@ import com.flxrs.dankchat.data.UserName import kotlinx.parcelize.Parcelize @Parcelize -data class ChannelWithRename(val channel: UserName, val rename: UserName?) : Parcelable +data class ChannelWithRename( + val channel: UserName, + val rename: UserName?, +) : Parcelable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt index d1bf1dfb4..3c8e0b158 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettings.kt @@ -9,9 +9,11 @@ data class NotificationsSettings( val mentionFormat: MentionFormat = MentionFormat.Name, ) -enum class MentionFormat(val template: String) { +enum class MentionFormat( + val template: String, +) { Name("name"), NameComma("name,"), AtName("@name"), - AtNameComma("@name,"); + AtNameComma("@name,"), } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt index cf87efc1d..f9078636d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsDataStore.kt @@ -23,44 +23,58 @@ class NotificationsSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class NotificationsPreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class NotificationsPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { ShowNotifications(R.string.preference_notification_key), ShowWhisperNotifications(R.string.preference_notification_whisper_key), MentionFormat(R.string.preference_mention_format_key), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - NotificationsPreferenceKeys.ShowNotifications -> acc.copy(showNotifications = value.booleanOrDefault(acc.showNotifications)) - NotificationsPreferenceKeys.ShowWhisperNotifications -> acc.copy(showWhisperNotifications = value.booleanOrDefault(acc.showWhisperNotifications)) - NotificationsPreferenceKeys.MentionFormat -> acc.copy( - mentionFormat = value.stringOrNull()?.let { format -> - MentionFormat.entries.find { it.template == format } - } ?: acc.mentionFormat - ) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + NotificationsPreferenceKeys.ShowNotifications -> { + acc.copy(showNotifications = value.booleanOrDefault(acc.showNotifications)) + } + + NotificationsPreferenceKeys.ShowWhisperNotifications -> { + acc.copy(showWhisperNotifications = value.booleanOrDefault(acc.showWhisperNotifications)) + } + + NotificationsPreferenceKeys.MentionFormat -> { + acc.copy( + mentionFormat = + value.stringOrNull()?.let { format -> + MentionFormat.entries.find { it.template == format } + } ?: acc.mentionFormat, + ) + } + } } - } - private val dataStore = createDataStore( - fileName = "notifications", - context = context, - defaultValue = NotificationsSettings(), - serializer = NotificationsSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration), - ) + private val dataStore = + createDataStore( + fileName = "notifications", + context = context, + defaultValue = NotificationsSettings(), + serializer = NotificationsSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration), + ) val settings = dataStore.data - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) - val showNotifications = settings - .map { it.showNotifications } - .distinctUntilChanged() + val showNotifications = + settings + .map { it.showNotifications } + .distinctUntilChanged() fun current() = currentSettings.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsFragment.kt deleted file mode 100644 index db80369e6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsFragment.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.flxrs.dankchat.preferences.notifications - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.fragment.findNavController -import com.flxrs.dankchat.preferences.notifications.highlights.HighlightsScreen -import com.flxrs.dankchat.preferences.notifications.ignores.IgnoresScreen -import com.flxrs.dankchat.theme.DankChatTheme -import com.google.android.material.transition.MaterialFadeThrough - -class NotificationsSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - DankChatTheme { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = NotificationsSettingsRoute.NotificationsSettings, - enterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - exitTransition = { fadeOut() }, - popEnterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - popExitTransition = { fadeOut() }, - ) { - composable { - NotificationsSettingsScreen( - onNavToHighlights = { navController.navigate(NotificationsSettingsRoute.Highlights) }, - onNavToIgnores = { navController.navigate(NotificationsSettingsRoute.Ignores) }, - onNavBack = { findNavController().popBackStack() }, - ) - } - composable { - HighlightsScreen( - onNavBack = { navController.popBackStack() }, - ) - } - composable { - IgnoresScreen( - onNavBack = { navController.popBackStack() }, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsScreen.kt index 1d2b37b56..4e4a597e5 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsScreen.kt @@ -73,15 +73,16 @@ private fun NotificationsSettingsScreen( onClick = onNavBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { NotificationsCategory( showNotifications = settings.showNotifications, @@ -137,7 +138,7 @@ fun MentionsCategory( values = MentionFormat.entries.toImmutableList(), entries = entries, selected = mentionFormat, - onChanged = { onInteraction(NotificationsSettingsInteraction.Mention(it)) }, + onChange = { onInteraction(NotificationsSettingsInteraction.Mention(it)) }, ) PreferenceItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsViewModel.kt index dcc6e6030..6d89affb8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/NotificationsSettingsViewModel.kt @@ -6,34 +6,42 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class NotificationsSettingsViewModel( private val notificationsSettingsDataStore: NotificationsSettingsDataStore, ) : ViewModel() { - - val settings = notificationsSettingsDataStore.settings - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = notificationsSettingsDataStore.current(), - ) + val settings = + notificationsSettingsDataStore.settings + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = notificationsSettingsDataStore.current(), + ) fun onInteraction(interaction: NotificationsSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is NotificationsSettingsInteraction.Notifications -> notificationsSettingsDataStore.update { it.copy(showNotifications = interaction.value) } + is NotificationsSettingsInteraction.Notifications -> notificationsSettingsDataStore.update { it.copy(showNotifications = interaction.value) } is NotificationsSettingsInteraction.WhisperNotifications -> notificationsSettingsDataStore.update { it.copy(showWhisperNotifications = interaction.value) } - is NotificationsSettingsInteraction.Mention -> notificationsSettingsDataStore.update { it.copy(mentionFormat = interaction.value) } + is NotificationsSettingsInteraction.Mention -> notificationsSettingsDataStore.update { it.copy(mentionFormat = interaction.value) } } } } } sealed interface NotificationsSettingsInteraction { - data class Notifications(val value: Boolean) : NotificationsSettingsInteraction - data class WhisperNotifications(val value: Boolean) : NotificationsSettingsInteraction - data class Mention(val value: MentionFormat) : NotificationsSettingsInteraction + data class Notifications( + val value: Boolean, + ) : NotificationsSettingsInteraction + + data class WhisperNotifications( + val value: Boolean, + ) : NotificationsSettingsInteraction + + data class Mention( + val value: MentionFormat, + ) : NotificationsSettingsInteraction } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightEvent.kt index 352b4593f..bd7c31406 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightEvent.kt @@ -1,12 +1,23 @@ package com.flxrs.dankchat.preferences.notifications.highlights +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.Flow +@Immutable sealed interface HighlightEvent { - data class ItemRemoved(val item: HighlightItem, val position: Int) : HighlightEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : HighlightEvent + data class ItemRemoved( + val item: HighlightItem, + val position: Int, + ) : HighlightEvent + + data class ItemAdded( + val position: Int, + val isLast: Boolean, + ) : HighlightEvent } @Stable -data class HighlightEventsWrapper(val events: Flow) +data class HighlightEventsWrapper( + val events: Flow, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightItem.kt index e29f6f17d..b88889dc6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightItem.kt @@ -5,7 +5,6 @@ import com.flxrs.dankchat.data.database.entity.BlacklistedUserEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntity import com.flxrs.dankchat.data.database.entity.MessageHighlightEntityType import com.flxrs.dankchat.data.database.entity.UserHighlightEntity -import kotlinx.collections.immutable.ImmutableList sealed interface HighlightItem { val id: Long @@ -27,11 +26,12 @@ data class MessageHighlightItem( Username, Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, Reply, - Custom + Custom, } val canNotify = type in WITH_NOTIFIES @@ -67,7 +67,10 @@ data class BlacklistedUserItem( val isRegex: Boolean, ) : HighlightItem -fun MessageHighlightEntity.toItem(loggedIn: Boolean, notificationsEnabled: Boolean) = MessageHighlightItem( +fun MessageHighlightEntity.toItem( + loggedIn: Boolean, + notificationsEnabled: Boolean, +) = MessageHighlightItem( id = id, enabled = enabled, type = type.toItemType(), @@ -92,25 +95,27 @@ fun MessageHighlightItem.toEntity() = MessageHighlightEntity( ) fun MessageHighlightItem.Type.toEntityType(): MessageHighlightEntityType = when (this) { - MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username - MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription - MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement + MessageHighlightItem.Type.Username -> MessageHighlightEntityType.Username + MessageHighlightItem.Type.Subscription -> MessageHighlightEntityType.Subscription + MessageHighlightItem.Type.Announcement -> MessageHighlightEntityType.Announcement + MessageHighlightItem.Type.WatchStreak -> MessageHighlightEntityType.WatchStreak MessageHighlightItem.Type.ChannelPointRedemption -> MessageHighlightEntityType.ChannelPointRedemption - MessageHighlightItem.Type.FirstMessage -> MessageHighlightEntityType.FirstMessage - MessageHighlightItem.Type.ElevatedMessage -> MessageHighlightEntityType.ElevatedMessage - MessageHighlightItem.Type.Reply -> MessageHighlightEntityType.Reply - MessageHighlightItem.Type.Custom -> MessageHighlightEntityType.Custom + MessageHighlightItem.Type.FirstMessage -> MessageHighlightEntityType.FirstMessage + MessageHighlightItem.Type.ElevatedMessage -> MessageHighlightEntityType.ElevatedMessage + MessageHighlightItem.Type.Reply -> MessageHighlightEntityType.Reply + MessageHighlightItem.Type.Custom -> MessageHighlightEntityType.Custom } fun MessageHighlightEntityType.toItemType(): MessageHighlightItem.Type = when (this) { - MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username - MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription - MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement + MessageHighlightEntityType.Username -> MessageHighlightItem.Type.Username + MessageHighlightEntityType.Subscription -> MessageHighlightItem.Type.Subscription + MessageHighlightEntityType.Announcement -> MessageHighlightItem.Type.Announcement + MessageHighlightEntityType.WatchStreak -> MessageHighlightItem.Type.WatchStreak MessageHighlightEntityType.ChannelPointRedemption -> MessageHighlightItem.Type.ChannelPointRedemption - MessageHighlightEntityType.FirstMessage -> MessageHighlightItem.Type.FirstMessage - MessageHighlightEntityType.ElevatedMessage -> MessageHighlightItem.Type.ElevatedMessage - MessageHighlightEntityType.Reply -> MessageHighlightItem.Type.Reply - MessageHighlightEntityType.Custom -> MessageHighlightItem.Type.Custom + MessageHighlightEntityType.FirstMessage -> MessageHighlightItem.Type.FirstMessage + MessageHighlightEntityType.ElevatedMessage -> MessageHighlightItem.Type.ElevatedMessage + MessageHighlightEntityType.Reply -> MessageHighlightItem.Type.Reply + MessageHighlightEntityType.Custom -> MessageHighlightItem.Type.Custom } fun UserHighlightEntity.toItem(notificationsEnabled: Boolean) = UserHighlightItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt index dca3540e2..162b73e0e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsScreen.kt @@ -1,6 +1,7 @@ package com.flxrs.dankchat.preferences.notifications.highlights import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -74,19 +75,21 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.message.HighlightType +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.components.CheckboxWithText import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.PreferenceTabRow +import com.flxrs.dankchat.ui.chat.ChatMessageMapper import com.flxrs.dankchat.utils.compose.animatedAppBarColor import com.rarepebble.colorpicker.ColorPickerView -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @Composable @@ -110,7 +113,7 @@ fun HighlightsScreen(onNavBack: () -> Unit) { onRemove = viewModel::removeHighlight, onAddNew = viewModel::addHighlight, onAdd = viewModel::addHighlightItem, - onPageChanged = viewModel::setCurrentTab, + onPageChange = viewModel::setCurrentTab, onNavBack = onNavBack, ) } @@ -127,47 +130,50 @@ private fun HighlightsScreen( onRemove: (HighlightItem) -> Unit, onAddNew: () -> Unit, onAdd: (HighlightItem, Int) -> Unit, - onPageChanged: (Int) -> Unit, + onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { - val context = LocalContext.current + val dispatchersProvider = koinInject() val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } val pagerState = rememberPagerState { HighlightsTab.entries.size } val listStates = HighlightsTab.entries.map { rememberLazyListState() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val appBarContainerColor = animatedAppBarColor(scrollBehavior) + val itemRemovedMsg = stringResource(R.string.item_removed) + val undoMsg = stringResource(R.string.undo) LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { snackbarHost.currentSnackbarData?.dismiss() - onPageChanged(it) + onPageChange(it) } } LaunchedEffect(eventsWrapper) { eventsWrapper.events - .flowOn(Dispatchers.Main.immediate) + .flowOn(dispatchersProvider.immediate) .collectLatest { event -> focusManager.clearFocus() when (event) { is HighlightEvent.ItemRemoved -> { - val result = snackbarHost.showSnackbar( - message = context.getString(R.string.item_removed), - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(event.item, event.position) } } - is HighlightEvent.ItemAdded -> { + is HighlightEvent.ItemAdded -> { val listState = listStates[pagerState.currentPage] when { event.isLast && listState.canScrollForward -> listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1) - event.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) - else -> listState.animateScrollToItem(event.position) + event.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) + else -> listState.animateScrollToItem(event.position) } } } @@ -182,9 +188,10 @@ private fun HighlightsScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( @@ -198,7 +205,7 @@ private fun HighlightsScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -206,9 +213,10 @@ private fun HighlightsScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.multi_entry_add_entry)) }, icon = { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.multi_entry_add_entry)) }, - modifier = Modifier - .navigationBarsPadding() - .padding(8.dp), + modifier = + Modifier + .navigationBarsPadding() + .padding(8.dp), onClick = onAddNew, ) } @@ -217,105 +225,121 @@ private fun HighlightsScreen( ) { padding -> Column(modifier = Modifier.padding(padding)) { Column( - modifier = Modifier - .background(color = appBarContainerColor.value) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), + modifier = + Modifier + .background(color = appBarContainerColor.value) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { - val subtitle = when (currentTab) { - HighlightsTab.Messages -> stringResource(R.string.highlights_messages_title) - HighlightsTab.Users -> stringResource(R.string.highlights_users_title) - HighlightsTab.Badges -> stringResource(R.string.highlights_badges_title) - HighlightsTab.BlacklistedUsers -> stringResource(R.string.highlights_blacklisted_users_title) - } + val subtitle = + when (currentTab) { + HighlightsTab.Messages -> stringResource(R.string.highlights_messages_title) + HighlightsTab.Users -> stringResource(R.string.highlights_users_title) + HighlightsTab.Badges -> stringResource(R.string.highlights_badges_title) + HighlightsTab.BlacklistedUsers -> stringResource(R.string.highlights_blacklisted_users_title) + } Text( text = subtitle, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), style = MaterialTheme.typography.titleSmall, textAlign = TextAlign.Center, ) PreferenceTabRow( - appBarContainerColor = appBarContainerColor, + appBarContainerColor = appBarContainerColor.value, pagerState = pagerState, tabCount = HighlightsTab.entries.size, tabText = { when (HighlightsTab.entries[it]) { - HighlightsTab.Messages -> stringResource(R.string.tab_messages) - HighlightsTab.Users -> stringResource(R.string.tab_users) - HighlightsTab.Badges -> stringResource(R.string.tab_badges) + HighlightsTab.Messages -> stringResource(R.string.tab_messages) + HighlightsTab.Users -> stringResource(R.string.tab_users) + HighlightsTab.Badges -> stringResource(R.string.tab_badges) HighlightsTab.BlacklistedUsers -> stringResource(R.string.tab_blacklisted_users) } - } + }, ) } HorizontalPager( state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(start = 16.dp, end = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp), ) { page -> val listState = listStates[page] when (HighlightsTab.entries[page]) { - HighlightsTab.Messages -> HighlightsList( - highlights = messageHighlights, - listState = listState, - ) { idx, item -> - MessageHighlightItem( - item = item, - onChanged = { messageHighlights[idx] = it }, - onRemove = { onRemove(messageHighlights[idx]) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + HighlightsTab.Messages -> { + HighlightsList( + highlights = messageHighlights, + listState = listState, + ) { idx, item -> + MessageHighlightItem( + item = item, + onChange = { messageHighlights[idx] = it }, + onRemove = { onRemove(messageHighlights[idx]) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } - HighlightsTab.Users -> HighlightsList( - highlights = userHighlights, - listState = listState, - ) { idx, item -> - UserHighlightItem( - item = item, - onChanged = { userHighlights[idx] = it }, - onRemove = { onRemove(userHighlights[idx]) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + HighlightsTab.Users -> { + HighlightsList( + highlights = userHighlights, + listState = listState, + ) { idx, item -> + UserHighlightItem( + item = item, + onChange = { userHighlights[idx] = it }, + onRemove = { onRemove(userHighlights[idx]) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } - HighlightsTab.Badges -> HighlightsList( - highlights = badgeHighlights, - listState = listState, - ) { idx, item -> - BadgeHighlightItem( - item = item, - onChanged = { badgeHighlights[idx] = it }, - onRemove = { onRemove(badgeHighlights[idx]) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + HighlightsTab.Badges -> { + HighlightsList( + highlights = badgeHighlights, + listState = listState, + ) { idx, item -> + BadgeHighlightItem( + item = item, + onChange = { badgeHighlights[idx] = it }, + onRemove = { onRemove(badgeHighlights[idx]) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } - HighlightsTab.BlacklistedUsers -> HighlightsList( - highlights = blacklistedUsers, - listState = listState, - ) { idx, item -> - BlacklistedUserItem( - item = item, - onChanged = { blacklistedUsers[idx] = it }, - onRemove = { onRemove(blacklistedUsers[idx]) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + HighlightsTab.BlacklistedUsers -> { + HighlightsList( + highlights = blacklistedUsers, + listState = listState, + ) { idx, item -> + BlacklistedUserItem( + item = item, + onChange = { blacklistedUsers[idx] = it }, + onRemove = { onRemove(blacklistedUsers[idx]) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } } } @@ -329,7 +353,6 @@ private fun HighlightsList( listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit, ) { - DankBackground(visible = highlights.isEmpty()) LazyColumn( @@ -340,7 +363,7 @@ private fun HighlightsList( item(key = "top-spacer") { Spacer(Modifier.height(16.dp)) } - itemsIndexed(highlights, key = { _, it -> it.id }) { idx, item -> + itemsIndexed(highlights, key = { _, highlight -> highlight.id }) { idx, item -> itemContent(idx, item) } item(key = "bottom-spacer") { @@ -352,32 +375,35 @@ private fun HighlightsList( @Composable private fun MessageHighlightItem( item: MessageHighlightItem, - onChanged: (MessageHighlightItem) -> Unit, + onChange: (MessageHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { val launcher = LocalUriHandler.current - val titleText = when (item.type) { - MessageHighlightItem.Type.Username -> R.string.highlights_entry_username - MessageHighlightItem.Type.Subscription -> R.string.highlights_ignores_entry_subscriptions - MessageHighlightItem.Type.Announcement -> R.string.highlights_ignores_entry_announcements - MessageHighlightItem.Type.FirstMessage -> R.string.highlights_ignores_entry_first_messages - MessageHighlightItem.Type.ElevatedMessage -> R.string.highlights_ignores_entry_elevated_messages - MessageHighlightItem.Type.ChannelPointRedemption -> R.string.highlights_ignores_entry_redemptions - MessageHighlightItem.Type.Reply -> R.string.highlights_ignores_entry_replies - MessageHighlightItem.Type.Custom -> R.string.highlights_ignores_entry_custom - } + val titleText = + when (item.type) { + MessageHighlightItem.Type.Username -> R.string.highlights_entry_username + MessageHighlightItem.Type.Subscription -> R.string.highlights_ignores_entry_subscriptions + MessageHighlightItem.Type.Announcement -> R.string.highlights_ignores_entry_announcements + MessageHighlightItem.Type.WatchStreak -> R.string.highlights_ignores_entry_watch_streaks + MessageHighlightItem.Type.FirstMessage -> R.string.highlights_ignores_entry_first_messages + MessageHighlightItem.Type.ElevatedMessage -> R.string.highlights_ignores_entry_elevated_messages + MessageHighlightItem.Type.ChannelPointRedemption -> R.string.highlights_ignores_entry_redemptions + MessageHighlightItem.Type.Reply -> R.string.highlights_ignores_entry_replies + MessageHighlightItem.Type.Custom -> R.string.highlights_ignores_entry_custom + } val isCustom = item.type == MessageHighlightItem.Type.Custom ElevatedCard(modifier) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), ) { Text( text = stringResource(titleText), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.Center), ) if (isCustom) { IconButton( @@ -389,47 +415,50 @@ private fun MessageHighlightItem( } if (isCustom) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.pattern, - onValueChange = { onChanged(item.copy(pattern = it)) }, + onValueChange = { onChange(item.copy(pattern = it)) }, label = { Text(stringResource(R.string.pattern)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, ) } FlowRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) if (isCustom) { CheckboxWithText( text = stringResource(R.string.multi_entry_header_regex), checked = item.isRegex, - onCheckedChange = { onChanged(item.copy(isRegex = it)) }, + onCheckedChange = { onChange(item.copy(isRegex = it)) }, enabled = item.enabled, ) IconButton( onClick = { launcher.openUri(HighlightsViewModel.REGEX_INFO_URL) }, content = { Icon(Icons.Outlined.Info, contentDescription = "regex info") }, enabled = item.enabled, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(end = 8.dp), + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.case_sensitive), checked = item.isCaseSensitive, - onCheckedChange = { onChanged(item.copy(isCaseSensitive = it)) }, + onCheckedChange = { onChange(item.copy(isCaseSensitive = it)) }, enabled = item.enabled, modifier = Modifier.padding(end = 8.dp), ) @@ -439,104 +468,50 @@ private fun MessageHighlightItem( CheckboxWithText( text = stringResource(R.string.create_notification), checked = item.createNotification, - onCheckedChange = { onChanged(item.copy(createNotification = it)) }, + onCheckedChange = { onChange(item.copy(createNotification = it)) }, enabled = item.enabled && enabled, ) } } - val defaultColor = when(item.type) { - MessageHighlightItem.Type.Subscription, MessageHighlightItem.Type.Announcement -> ContextCompat.getColor(LocalContext.current, R.color.color_sub_highlight) - MessageHighlightItem.Type.ChannelPointRedemption -> ContextCompat.getColor(LocalContext.current, R.color.color_redemption_highlight) - MessageHighlightItem.Type.ElevatedMessage -> ContextCompat.getColor(LocalContext.current, R.color.color_elevated_message_highlight) - MessageHighlightItem.Type.FirstMessage -> ContextCompat.getColor(LocalContext.current, R.color.color_first_message_highlight) - MessageHighlightItem.Type.Username -> ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) - MessageHighlightItem.Type.Reply, MessageHighlightItem.Type.Custom -> ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) - } - val color = item.customColor ?: defaultColor - var showColorPicker by remember { mutableStateOf(false) } - var selectedColor by remember(color) { mutableIntStateOf(color) } - OutlinedButton( - onClick = { showColorPicker = true }, + val isDark = isSystemInDarkTheme() + val defaultColor = + when (item.type) { + MessageHighlightItem.Type.Subscription, MessageHighlightItem.Type.Announcement -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.Subscription, isDark) + MessageHighlightItem.Type.WatchStreak -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.WatchStreak, isDark) + MessageHighlightItem.Type.ChannelPointRedemption -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.ChannelPointRedemption, isDark) + MessageHighlightItem.Type.ElevatedMessage -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.ElevatedMessage, isDark) + MessageHighlightItem.Type.FirstMessage -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.FirstMessage, isDark) + MessageHighlightItem.Type.Username -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.Username, isDark) + MessageHighlightItem.Type.Reply, MessageHighlightItem.Type.Custom -> ChatMessageMapper.defaultHighlightColorInt(HighlightType.Reply, isDark) + } + HighlightColorPicker( + color = item.customColor ?: defaultColor, + defaultColor = defaultColor, enabled = item.enabled, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - content = { - Spacer( - Modifier - .size(ButtonDefaults.IconSize) - .background(color = Color(color), shape = CircleShape) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.choose_highlight_color)) - }, - modifier = Modifier.padding(12.dp) + onColorSelect = { onChange(item.copy(customColor = it)) }, ) - if (showColorPicker) { - ModalBottomSheet( - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onDismissRequest = { - onChanged(item.copy(customColor = selectedColor)) - showColorPicker = false - }, - ) { - Text( - text = stringResource(R.string.pick_highlight_color_title), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - ) - Row ( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = { selectedColor = defaultColor }, - content = { Text(stringResource(R.string.reset_default_highlight_color)) }, - ) - TextButton( - onClick = { selectedColor = color }, - content = { Text(stringResource(R.string.reset)) }, - ) - } - AndroidView( - factory = { context -> - ColorPickerView(context).apply { - showAlpha(true) - setOriginalColor(color) - setCurrentColor(selectedColor) - addColorObserver { - selectedColor = it.color - } - } - }, - update = { - it.setCurrentColor(selectedColor) - } - ) - } - } } } @Composable private fun UserHighlightItem( item: UserHighlightItem, - onChanged: (UserHighlightItem) -> Unit, + onChange: (UserHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = item.username, - onValueChange = { onChanged(item.copy(username = it)) }, + onValueChange = { onChange(item.copy(username = it)) }, label = { Text(stringResource(R.string.username)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -548,81 +523,23 @@ private fun UserHighlightItem( CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.create_notification), checked = item.createNotification, - onCheckedChange = { onChanged(item.copy(createNotification = it)) }, + onCheckedChange = { onChange(item.copy(createNotification = it)) }, enabled = item.enabled && item.notificationsEnabled, ) } - val defaultColor = ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) - val color = item.customColor ?: defaultColor - var showColorPicker by remember { mutableStateOf(false) } - var selectedColor by remember(color) { mutableIntStateOf(color) } - OutlinedButton( - onClick = { showColorPicker = true }, + val defaultColor = ChatMessageMapper.defaultHighlightColorInt(HighlightType.Username, isSystemInDarkTheme()) + HighlightColorPicker( + color = item.customColor ?: defaultColor, + defaultColor = defaultColor, enabled = item.enabled, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - content = { - Spacer( - Modifier - .size(ButtonDefaults.IconSize) - .background(color = Color(color), shape = CircleShape) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.choose_highlight_color)) - }, - modifier = Modifier.padding(12.dp) + onColorSelect = { onChange(item.copy(customColor = it)) }, ) - if (showColorPicker) { - ModalBottomSheet( - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onDismissRequest = { - onChanged(item.copy(customColor = selectedColor)) - showColorPicker = false - }, - ) { - Text( - text = stringResource(R.string.pick_highlight_color_title), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - ) - Row ( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = { selectedColor = defaultColor }, - content = { Text(stringResource(R.string.reset_default_highlight_color)) }, - ) - TextButton( - onClick = { selectedColor = color }, - content = { Text(stringResource(R.string.reset)) }, - ) - } - AndroidView( - factory = { context -> - ColorPickerView(context).apply { - showAlpha(true) - setOriginalColor(color) - setCurrentColor(selectedColor) - addColorObserver { - selectedColor = it.color - } - } - }, - update = { - it.setCurrentColor(selectedColor) - } - ) - } - } } IconButton( onClick = onRemove, @@ -635,22 +552,23 @@ private fun UserHighlightItem( @Composable private fun BadgeHighlightItem( item: BadgeHighlightItem, - onChanged: (BadgeHighlightItem) -> Unit, + onChange: (BadgeHighlightItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { if (item.isCustom) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = item.badgeName, - onValueChange = { onChanged(item.copy(badgeName = it)) }, + onValueChange = { onChange(item.copy(badgeName = it)) }, label = { Text(stringResource(R.string.badge)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -658,25 +576,26 @@ private fun BadgeHighlightItem( } else { var name = "" when (item.badgeName) { - "broadcaster"-> name = stringResource(R.string.badge_broadcaster) - "admin"-> name = stringResource(R.string.badge_admin) - "staff"-> name = stringResource(R.string.badge_staff) - "moderator"-> name = stringResource(R.string.badge_moderator) - "lead_moderator"-> name = stringResource(R.string.badge_lead_moderator) - "partner"-> name = stringResource(R.string.badge_verified) - "vip"-> name = stringResource(R.string.badge_vip) - "founder"-> name = stringResource(R.string.badge_founder) - "subscriber"-> name = stringResource(R.string.badge_subscriber) + "broadcaster" -> name = stringResource(R.string.badge_broadcaster) + "admin" -> name = stringResource(R.string.badge_admin) + "staff" -> name = stringResource(R.string.badge_staff) + "moderator" -> name = stringResource(R.string.badge_moderator) + "lead_moderator" -> name = stringResource(R.string.badge_lead_moderator) + "partner" -> name = stringResource(R.string.badge_verified) + "vip" -> name = stringResource(R.string.badge_vip) + "founder" -> name = stringResource(R.string.badge_founder) + "subscriber" -> name = stringResource(R.string.badge_subscriber) } Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), ) { Text( text = name, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.Center), ) } } @@ -687,81 +606,23 @@ private fun BadgeHighlightItem( CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.create_notification), checked = item.createNotification, - onCheckedChange = { onChanged(item.copy(createNotification = it)) }, + onCheckedChange = { onChange(item.copy(createNotification = it)) }, enabled = item.enabled && item.notificationsEnabled, ) } - val defaultColor = ContextCompat.getColor(LocalContext.current, R.color.color_mention_highlight) - val color = item.customColor ?: defaultColor - var showColorPicker by remember { mutableStateOf(false) } - var selectedColor by remember(color) { mutableIntStateOf(color) } - OutlinedButton( - onClick = { showColorPicker = true }, + val defaultColor = ChatMessageMapper.defaultHighlightColorInt(HighlightType.Username, isSystemInDarkTheme()) + HighlightColorPicker( + color = item.customColor ?: defaultColor, + defaultColor = defaultColor, enabled = item.enabled, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - content = { - Spacer( - Modifier - .size(ButtonDefaults.IconSize) - .background(color = Color(color), shape = CircleShape) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.choose_highlight_color)) - }, - modifier = Modifier.padding(12.dp) + onColorSelect = { onChange(item.copy(customColor = it)) }, ) - if (showColorPicker) { - ModalBottomSheet( - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onDismissRequest = { - onChanged(item.copy(customColor = selectedColor)) - showColorPicker = false - }, - ) { - Text( - text = stringResource(R.string.pick_highlight_color_title), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - ) - Row ( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton( - onClick = { selectedColor = defaultColor }, - content = { Text(stringResource(R.string.reset_default_highlight_color)) }, - ) - TextButton( - onClick = { selectedColor = color }, - content = { Text(stringResource(R.string.reset)) }, - ) - } - AndroidView( - factory = { context -> - ColorPickerView(context).apply { - showAlpha(true) - setOriginalColor(color) - setCurrentColor(selectedColor) - addColorObserver { - selectedColor = it.color - } - } - }, - update = { - it.setCurrentColor(selectedColor) - } - ) - } - } } if (item.isCustom) { IconButton( @@ -776,7 +637,7 @@ private fun BadgeHighlightItem( @Composable private fun BlacklistedUserItem( item: BlacklistedUserItem, - onChanged: (BlacklistedUserItem) -> Unit, + onChange: (BlacklistedUserItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -784,14 +645,15 @@ private fun BlacklistedUserItem( ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = item.username, - onValueChange = { onChanged(item.copy(username = it)) }, + onValueChange = { onChange(item.copy(username = it)) }, label = { Text(stringResource(R.string.username)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -802,13 +664,13 @@ private fun BlacklistedUserItem( CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.multi_entry_header_regex), checked = item.isRegex, - onCheckedChange = { onChanged(item.copy(isRegex = it)) }, + onCheckedChange = { onChange(item.copy(isRegex = it)) }, enabled = item.enabled, ) IconButton( @@ -826,3 +688,77 @@ private fun BlacklistedUserItem( } } } + +@Composable +private fun HighlightColorPicker( + color: Int, + defaultColor: Int, + enabled: Boolean, + onColorSelect: (Int) -> Unit, +) { + var showColorPicker by remember { mutableStateOf(false) } + var selectedColor by remember(color) { mutableIntStateOf(color) } + OutlinedButton( + onClick = { showColorPicker = true }, + enabled = enabled, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + content = { + Spacer( + Modifier + .size(ButtonDefaults.IconSize) + .background(color = Color(color), shape = CircleShape), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = stringResource(R.string.choose_highlight_color)) + }, + modifier = Modifier.padding(12.dp), + ) + if (showColorPicker) { + ModalBottomSheet( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = { + onColorSelect(selectedColor) + showColorPicker = false + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = stringResource(R.string.pick_highlight_color_title), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = { selectedColor = defaultColor }, + content = { Text(stringResource(R.string.reset_default_highlight_color)) }, + ) + TextButton( + onClick = { selectedColor = color }, + content = { Text(stringResource(R.string.reset)) }, + ) + } + AndroidView( + factory = { context -> + ColorPickerView(context).apply { + showAlpha(true) + setOriginalColor(color) + setCurrentColor(selectedColor) + addColorObserver { + selectedColor = it.color + } + } + }, + update = { + it.setCurrentColor(selectedColor) + }, + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsViewModel.kt index 99699ce75..e255e35e1 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/highlights/HighlightsViewModel.kt @@ -5,10 +5,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.database.entity.MessageHighlightEntityType import com.flxrs.dankchat.data.repo.HighlightsRepository +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore import com.flxrs.dankchat.utils.extensions.replaceAll -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,15 +16,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel @KoinViewModel class HighlightsViewModel( private val highlightsRepository: HighlightsRepository, private val preferenceStore: DankChatPreferenceStore, private val notificationsSettingsDataStore: NotificationsSettingsDataStore, + private val dispatchersProvider: DispatchersProvider, ) : ViewModel() { - private val _currentTab = MutableStateFlow(HighlightsTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -59,21 +59,21 @@ class HighlightsViewModel( val notificationsEnabled = notificationsSettingsDataStore.settings.first().showNotifications val position: Int when (_currentTab.value) { - HighlightsTab.Messages -> { + HighlightsTab.Messages -> { val entity = highlightsRepository.addMessageHighlight() messageHighlights += entity.toItem(loggedIn, notificationsEnabled) position = messageHighlights.lastIndex } - HighlightsTab.Users -> { + HighlightsTab.Users -> { val entity = highlightsRepository.addUserHighlight() - userHighlights += entity.toItem(notificationsEnabled) + userHighlights += entity.toItem(notificationsEnabled) position = userHighlights.lastIndex } - HighlightsTab.Badges -> { + HighlightsTab.Badges -> { val entity = highlightsRepository.addBadgeHighlight() - badgeHighlights += entity.toItem(notificationsEnabled) + badgeHighlights += entity.toItem(notificationsEnabled) position = badgeHighlights.lastIndex } @@ -86,7 +86,10 @@ class HighlightsViewModel( sendEvent(HighlightEvent.ItemAdded(position, isLast = true)) } - fun addHighlightItem(item: HighlightItem, position: Int) = viewModelScope.launch { + fun addHighlightItem( + item: HighlightItem, + position: Int, + ) = viewModelScope.launch { val isLast: Boolean when (item) { is MessageHighlightItem -> { @@ -95,19 +98,19 @@ class HighlightsViewModel( isLast = position == messageHighlights.lastIndex } - is UserHighlightItem -> { + is UserHighlightItem -> { highlightsRepository.updateUserHighlight(item.toEntity()) userHighlights.add(position, item) isLast = position == userHighlights.lastIndex } - is BadgeHighlightItem -> { + is BadgeHighlightItem -> { highlightsRepository.updateBadgeHighlight(item.toEntity()) badgeHighlights.add(position, item) isLast = position == badgeHighlights.lastIndex } - is BlacklistedUserItem -> { + is BlacklistedUserItem -> { highlightsRepository.updateBlacklistedUser(item.toEntity()) blacklistedUsers.add(position, item) isLast = position == blacklistedUsers.lastIndex @@ -125,19 +128,19 @@ class HighlightsViewModel( messageHighlights.removeAt(position) } - is UserHighlightItem -> { + is UserHighlightItem -> { position = userHighlights.indexOfFirst { it.id == item.id } highlightsRepository.removeUserHighlight(item.toEntity()) userHighlights.removeAt(position) } - is BadgeHighlightItem -> { + is BadgeHighlightItem -> { position = badgeHighlights.indexOfFirst { it.id == item.id } highlightsRepository.removeBadgeHighlight(item.toEntity()) badgeHighlights.removeAt(position) } - is BlacklistedUserItem -> { + is BlacklistedUserItem -> { position = blacklistedUsers.indexOfFirst { it.id == item.id } highlightsRepository.removeBlacklistedUser(item.toEntity()) blacklistedUsers.removeAt(position) @@ -189,7 +192,7 @@ class HighlightsViewModel( .map { it.toEntity() } .partition { it.username.isBlank() } - private suspend fun sendEvent(event: HighlightEvent) = withContext(Dispatchers.Main.immediate) { + private suspend fun sendEvent(event: HighlightEvent) = withContext(dispatchersProvider.immediate) { eventChannel.send(event) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreEvent.kt index bfdb33f10..31ee08cb6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreEvent.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreEvent.kt @@ -1,14 +1,31 @@ package com.flxrs.dankchat.preferences.notifications.ignores +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.Flow +@Immutable sealed interface IgnoreEvent { - data class ItemRemoved(val item: IgnoreItem, val position: Int) : IgnoreEvent - data class ItemAdded(val position: Int, val isLast: Boolean) : IgnoreEvent - data class BlockError(val item: TwitchBlockItem) : IgnoreEvent - data class UnblockError(val item: TwitchBlockItem) : IgnoreEvent + data class ItemRemoved( + val item: IgnoreItem, + val position: Int, + ) : IgnoreEvent + + data class ItemAdded( + val position: Int, + val isLast: Boolean, + ) : IgnoreEvent + + data class BlockError( + val item: TwitchBlockItem, + ) : IgnoreEvent + + data class UnblockError( + val item: TwitchBlockItem, + ) : IgnoreEvent } @Stable -data class IgnoreEventsWrapper(val events: Flow) +data class IgnoreEventsWrapper( + val events: Flow, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreItem.kt index e0e7ba490..86c2d63cb 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoreItem.kt @@ -24,10 +24,11 @@ data class MessageIgnoreItem( enum class Type { Subscription, Announcement, + WatchStreak, ChannelPointRedemption, FirstMessage, ElevatedMessage, - Custom + Custom, } } @@ -36,7 +37,7 @@ data class UserIgnoreItem( val enabled: Boolean, val username: String, val isRegex: Boolean, - val isCaseSensitive: Boolean + val isCaseSensitive: Boolean, ) : IgnoreItem data class TwitchBlockItem( @@ -53,7 +54,7 @@ fun MessageIgnoreEntity.toItem() = MessageIgnoreItem( isRegex = isRegex, isCaseSensitive = isCaseSensitive, isBlockMessage = isBlockMessage, - replacement = replacement ?: "" + replacement = replacement.orEmpty(), ) fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( @@ -64,28 +65,31 @@ fun MessageIgnoreItem.toEntity() = MessageIgnoreEntity( isRegex = isRegex, isCaseSensitive = isCaseSensitive, isBlockMessage = isBlockMessage, - replacement = when { - isBlockMessage -> null - else -> replacement - } + replacement = + when { + isBlockMessage -> null + else -> replacement + }, ) fun MessageIgnoreItem.Type.toEntityType(): MessageIgnoreEntityType = when (this) { - MessageIgnoreItem.Type.Subscription -> MessageIgnoreEntityType.Subscription - MessageIgnoreItem.Type.Announcement -> MessageIgnoreEntityType.Announcement + MessageIgnoreItem.Type.Subscription -> MessageIgnoreEntityType.Subscription + MessageIgnoreItem.Type.Announcement -> MessageIgnoreEntityType.Announcement + MessageIgnoreItem.Type.WatchStreak -> MessageIgnoreEntityType.WatchStreak MessageIgnoreItem.Type.ChannelPointRedemption -> MessageIgnoreEntityType.ChannelPointRedemption - MessageIgnoreItem.Type.FirstMessage -> MessageIgnoreEntityType.FirstMessage - MessageIgnoreItem.Type.ElevatedMessage -> MessageIgnoreEntityType.ElevatedMessage - MessageIgnoreItem.Type.Custom -> MessageIgnoreEntityType.Custom + MessageIgnoreItem.Type.FirstMessage -> MessageIgnoreEntityType.FirstMessage + MessageIgnoreItem.Type.ElevatedMessage -> MessageIgnoreEntityType.ElevatedMessage + MessageIgnoreItem.Type.Custom -> MessageIgnoreEntityType.Custom } fun MessageIgnoreEntityType.toItemType(): MessageIgnoreItem.Type = when (this) { - MessageIgnoreEntityType.Subscription -> MessageIgnoreItem.Type.Subscription - MessageIgnoreEntityType.Announcement -> MessageIgnoreItem.Type.Announcement + MessageIgnoreEntityType.Subscription -> MessageIgnoreItem.Type.Subscription + MessageIgnoreEntityType.Announcement -> MessageIgnoreItem.Type.Announcement + MessageIgnoreEntityType.WatchStreak -> MessageIgnoreItem.Type.WatchStreak MessageIgnoreEntityType.ChannelPointRedemption -> MessageIgnoreItem.Type.ChannelPointRedemption - MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage - MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage - MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom + MessageIgnoreEntityType.FirstMessage -> MessageIgnoreItem.Type.FirstMessage + MessageIgnoreEntityType.ElevatedMessage -> MessageIgnoreItem.Type.ElevatedMessage + MessageIgnoreEntityType.Custom -> MessageIgnoreItem.Type.Custom } fun UserIgnoreEntity.toItem() = UserIgnoreItem( @@ -101,7 +105,7 @@ fun UserIgnoreItem.toEntity() = UserIgnoreEntity( enabled = enabled, username = username, isRegex = isRegex, - isCaseSensitive = isCaseSensitive + isCaseSensitive = isCaseSensitive, ) fun IgnoresRepository.TwitchBlock.toItem() = TwitchBlockItem( diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresScreen.kt index 53808cbf4..cfc79b0bf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresScreen.kt @@ -60,8 +60,8 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -71,15 +71,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.components.CheckboxWithText import com.flxrs.dankchat.preferences.components.DankBackground import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.PreferenceTabRow import com.flxrs.dankchat.preferences.notifications.highlights.HighlightsViewModel import com.flxrs.dankchat.utils.compose.animatedAppBarColor -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @Composable @@ -102,7 +103,7 @@ fun IgnoresScreen(onNavBack: () -> Unit) { onRemove = viewModel::removeIgnore, onAddNew = viewModel::addIgnore, onAdd = viewModel::addIgnoreItem, - onPageChanged = viewModel::setCurrentTab, + onPageChange = viewModel::setCurrentTab, onNavBack = onNavBack, ) } @@ -118,62 +119,67 @@ private fun IgnoresScreen( onRemove: (IgnoreItem) -> Unit, onAddNew: () -> Unit, onAdd: (IgnoreItem, Int) -> Unit, - onPageChanged: (Int) -> Unit, + onPageChange: (Int) -> Unit, onNavBack: () -> Unit, ) { - val context = LocalContext.current + val dispatchersProvider = koinInject() + val resources = LocalResources.current val focusManager = LocalFocusManager.current val snackbarHost = remember { SnackbarHostState() } val pagerState = rememberPagerState { IgnoresTab.entries.size } val listStates = IgnoresTab.entries.map { rememberLazyListState() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val appbarContainerColor = animatedAppBarColor(scrollBehavior) + val itemRemovedMsg = stringResource(R.string.item_removed) + val undoMsg = stringResource(R.string.undo) LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { snackbarHost.currentSnackbarData?.dismiss() - onPageChanged(it) + onPageChange(it) } } LaunchedEffect(eventsWrapper) { eventsWrapper.events - .flowOn(Dispatchers.Main.immediate) + .flowOn(dispatchersProvider.immediate) .collectLatest { event -> focusManager.clearFocus() when (event) { - is IgnoreEvent.ItemRemoved -> { - val message = when (event.item) { - is TwitchBlockItem -> context.getString(R.string.unblocked_user, event.item.username) - else -> context.getString(R.string.item_removed) - } + is IgnoreEvent.ItemRemoved -> { + val message = + when (event.item) { + is TwitchBlockItem -> resources.getString(R.string.unblocked_user, event.item.username) + else -> itemRemovedMsg + } - val result = snackbarHost.showSnackbar( - message = message, - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short, - ) + val result = + snackbarHost.showSnackbar( + message = message, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { onAdd(event.item, event.position) } } - is IgnoreEvent.ItemAdded -> { + is IgnoreEvent.ItemAdded -> { val listState = listStates[pagerState.currentPage] when { event.isLast && listState.canScrollForward -> listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1) - event.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) - else -> listState.animateScrollToItem(event.position) + event.isLast -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) + else -> listState.animateScrollToItem(event.position) } } - is IgnoreEvent.BlockError -> { - val message = context.getString(R.string.blocked_user_failed, event.item.username) + is IgnoreEvent.BlockError -> { + val message = resources.getString(R.string.blocked_user_failed, event.item.username) snackbarHost.showSnackbar(message) } is IgnoreEvent.UnblockError -> { - val message = context.getString(R.string.unblocked_user_failed, event.item.username) + val message = resources.getString(R.string.unblocked_user_failed, event.item.username) snackbarHost.showSnackbar(message) } } @@ -188,9 +194,10 @@ private fun IgnoresScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( @@ -204,7 +211,7 @@ private fun IgnoresScreen( }, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, floatingActionButton = { @@ -217,9 +224,10 @@ private fun IgnoresScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.multi_entry_add_entry)) }, icon = { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.multi_entry_add_entry)) }, - modifier = Modifier - .navigationBarsPadding() - .padding(8.dp), + modifier = + Modifier + .navigationBarsPadding() + .padding(8.dp), onClick = onAddNew, ) } @@ -229,90 +237,103 @@ private fun IgnoresScreen( ) { padding -> Column(modifier = Modifier.padding(padding)) { Column( - modifier = Modifier - .background(color = appbarContainerColor.value) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), + modifier = + Modifier + .background(color = appbarContainerColor.value) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { - val subtitle = when (currentTab) { - IgnoresTab.Messages -> stringResource(R.string.ignores_messages_title) - IgnoresTab.Users -> stringResource(R.string.ignores_users_title) - IgnoresTab.Twitch -> stringResource(R.string.ignores_twitch_title) - } + val subtitle = + when (currentTab) { + IgnoresTab.Messages -> stringResource(R.string.ignores_messages_title) + IgnoresTab.Users -> stringResource(R.string.ignores_users_title) + IgnoresTab.Twitch -> stringResource(R.string.ignores_twitch_title) + } Text( text = subtitle, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), style = MaterialTheme.typography.titleSmall, textAlign = TextAlign.Center, ) PreferenceTabRow( - appBarContainerColor = appbarContainerColor, + appBarContainerColor = appbarContainerColor.value, pagerState = pagerState, tabCount = IgnoresTab.entries.size, tabText = { when (IgnoresTab.entries[it]) { IgnoresTab.Messages -> stringResource(R.string.tab_messages) - IgnoresTab.Users -> stringResource(R.string.tab_users) - IgnoresTab.Twitch -> stringResource(R.string.tab_twitch) + IgnoresTab.Users -> stringResource(R.string.tab_users) + IgnoresTab.Twitch -> stringResource(R.string.tab_twitch) } - } + }, ) } HorizontalPager( state = pagerState, - modifier = Modifier - .fillMaxSize() - .padding(start = 16.dp, end = 16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp), ) { page -> val listState = listStates[page] when (val tab = IgnoresTab.entries[page]) { - IgnoresTab.Messages -> IgnoresList( - tab = tab, - ignores = messageIgnores, - listState = listState, - ) { idx, item -> - MessageIgnoreItem( - item = item, - onChanged = { messageIgnores[idx] = it }, - onRemove = { onRemove(item) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + IgnoresTab.Messages -> { + IgnoresList( + tab = tab, + ignores = messageIgnores, + listState = listState, + ) { idx, item -> + MessageIgnoreItem( + item = item, + onChange = { messageIgnores[idx] = it }, + onRemove = { onRemove(item) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } - IgnoresTab.Users -> IgnoresList( - tab = tab, - ignores = userIgnores, - listState = listState, - ) { idx, item -> - UserIgnoreItem( - item = item, - onChanged = { userIgnores[idx] = it }, - onRemove = { onRemove(item) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + IgnoresTab.Users -> { + IgnoresList( + tab = tab, + ignores = userIgnores, + listState = listState, + ) { idx, item -> + UserIgnoreItem( + item = item, + onChange = { userIgnores[idx] = it }, + onRemove = { onRemove(item) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } - IgnoresTab.Twitch -> IgnoresList( - tab = tab, - ignores = twitchBlocks, - listState = listState, - ) { idx, item -> - TwitchBlockItem( - item = item, - onRemove = { onRemove(item) }, - modifier = Modifier.animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), - ) + IgnoresTab.Twitch -> { + IgnoresList( + tab = tab, + ignores = twitchBlocks, + listState = listState, + ) { idx, item -> + TwitchBlockItem( + item = item, + onRemove = { onRemove(item) }, + modifier = + Modifier.animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), + ) + } } } } @@ -327,7 +348,6 @@ private fun IgnoresList( listState: LazyListState, itemContent: @Composable LazyItemScope.(Int, T) -> Unit, ) { - DankBackground(visible = ignores.isEmpty()) LazyColumn( @@ -338,14 +358,15 @@ private fun IgnoresList( item(key = "top-spacer") { Spacer(Modifier.height(16.dp)) } - itemsIndexed(ignores, key = { _, it -> it.id }) { idx, item -> + itemsIndexed(ignores, key = { _, ignore -> ignore.id }) { idx, item -> itemContent(idx, item) } item(key = "bottom-spacer") { - val height = when (tab) { - IgnoresTab.Messages, IgnoresTab.Users -> 112.dp - IgnoresTab.Twitch -> Dp.Unspecified - } + val height = + when (tab) { + IgnoresTab.Messages, IgnoresTab.Users -> 112.dp + IgnoresTab.Twitch -> Dp.Unspecified + } NavigationBarSpacer(Modifier.height(height)) } } @@ -354,30 +375,33 @@ private fun IgnoresList( @Composable private fun MessageIgnoreItem( item: MessageIgnoreItem, - onChanged: (MessageIgnoreItem) -> Unit, + onChange: (MessageIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { val launcher = LocalUriHandler.current - val titleText = when (item.type) { - MessageIgnoreItem.Type.Subscription -> R.string.highlights_ignores_entry_subscriptions - MessageIgnoreItem.Type.Announcement -> R.string.highlights_ignores_entry_announcements - MessageIgnoreItem.Type.ChannelPointRedemption -> R.string.highlights_ignores_entry_first_messages - MessageIgnoreItem.Type.FirstMessage -> R.string.highlights_ignores_entry_elevated_messages - MessageIgnoreItem.Type.ElevatedMessage -> R.string.highlights_ignores_entry_redemptions - MessageIgnoreItem.Type.Custom -> R.string.highlights_ignores_entry_custom - } + val titleText = + when (item.type) { + MessageIgnoreItem.Type.Subscription -> R.string.highlights_ignores_entry_subscriptions + MessageIgnoreItem.Type.Announcement -> R.string.highlights_ignores_entry_announcements + MessageIgnoreItem.Type.WatchStreak -> R.string.highlights_ignores_entry_watch_streaks + MessageIgnoreItem.Type.ChannelPointRedemption -> R.string.highlights_ignores_entry_redemptions + MessageIgnoreItem.Type.FirstMessage -> R.string.highlights_ignores_entry_first_messages + MessageIgnoreItem.Type.ElevatedMessage -> R.string.highlights_ignores_entry_elevated_messages + MessageIgnoreItem.Type.Custom -> R.string.highlights_ignores_entry_custom + } val isCustom = item.type == MessageIgnoreItem.Type.Custom ElevatedCard(modifier) { Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), ) { Text( text = stringResource(titleText), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier.align(Alignment.Center), ) if (isCustom) { IconButton( @@ -389,54 +413,57 @@ private fun MessageIgnoreItem( } if (isCustom) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.pattern, - onValueChange = { onChanged(item.copy(pattern = it)) }, + onValueChange = { onChange(item.copy(pattern = it)) }, label = { Text(stringResource(R.string.pattern)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, ) } FlowRow( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) if (isCustom) { CheckboxWithText( text = stringResource(R.string.multi_entry_header_regex), checked = item.isRegex, - onCheckedChange = { onChanged(item.copy(isRegex = it)) }, + onCheckedChange = { onChange(item.copy(isRegex = it)) }, enabled = item.enabled, ) IconButton( onClick = { launcher.openUri(HighlightsViewModel.REGEX_INFO_URL) }, content = { Icon(Icons.Outlined.Info, contentDescription = "regex info") }, enabled = item.enabled, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(end = 8.dp), + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.case_sensitive), checked = item.isCaseSensitive, - onCheckedChange = { onChanged(item.copy(isCaseSensitive = it)) }, + onCheckedChange = { onChange(item.copy(isCaseSensitive = it)) }, enabled = item.enabled, modifier = Modifier.padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.block), checked = item.isBlockMessage, - onCheckedChange = { onChanged(item.copy(isBlockMessage = it)) }, + onCheckedChange = { onChange(item.copy(isBlockMessage = it)) }, enabled = item.enabled, modifier = Modifier.padding(end = 8.dp), ) @@ -444,11 +471,12 @@ private fun MessageIgnoreItem( } AnimatedVisibility(visible = isCustom && !item.isBlockMessage) { OutlinedTextField( - modifier = Modifier - .padding(8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(), value = item.replacement, - onValueChange = { onChanged(item.copy(replacement = it)) }, + onValueChange = { onChange(item.copy(replacement = it)) }, label = { Text(stringResource(R.string.replacement)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -460,7 +488,7 @@ private fun MessageIgnoreItem( @Composable private fun UserIgnoreItem( item: UserIgnoreItem, - onChanged: (UserIgnoreItem) -> Unit, + onChange: (UserIgnoreItem) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -468,15 +496,16 @@ private fun UserIgnoreItem( ElevatedCard(modifier) { Row { Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) + modifier = + Modifier + .weight(1f) + .padding(8.dp), ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), enabled = true, value = item.username, - onValueChange = { onChanged(item.copy(username = it)) }, + onValueChange = { onChange(item.copy(username = it)) }, label = { Text(stringResource(R.string.username)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 1, @@ -488,27 +517,28 @@ private fun UserIgnoreItem( CheckboxWithText( text = stringResource(R.string.enabled), checked = item.enabled, - onCheckedChange = { onChanged(item.copy(enabled = it)) }, - modifier = modifier.padding(end = 8.dp), + onCheckedChange = { onChange(item.copy(enabled = it)) }, + modifier = Modifier.padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.multi_entry_header_regex), checked = item.isRegex, - onCheckedChange = { onChanged(item.copy(isRegex = it)) }, + onCheckedChange = { onChange(item.copy(isRegex = it)) }, enabled = item.enabled, ) IconButton( onClick = { launcher.openUri(HighlightsViewModel.REGEX_INFO_URL) }, content = { Icon(Icons.Outlined.Info, contentDescription = "regex info") }, enabled = item.enabled, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(end = 8.dp), + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 8.dp), ) CheckboxWithText( text = stringResource(R.string.case_sensitive), checked = item.isCaseSensitive, - onCheckedChange = { onChanged(item.copy(isCaseSensitive = it)) }, + onCheckedChange = { onChange(item.copy(isCaseSensitive = it)) }, enabled = item.enabled, modifier = Modifier.padding(end = 8.dp), ) @@ -533,15 +563,17 @@ private fun TwitchBlockItem( val colors = OutlinedTextFieldDefaults.colors() OutlinedTextField( value = item.username.value, - modifier = Modifier - .weight(1f) - .padding(8.dp), + modifier = + Modifier + .weight(1f) + .padding(8.dp), onValueChange = {}, - colors = OutlinedTextFieldDefaults.colors( - disabledTextColor = colors.unfocusedTextColor, - disabledBorderColor = colors.unfocusedIndicatorColor, - disabledContainerColor = colors.unfocusedContainerColor, - ), + colors = + OutlinedTextFieldDefaults.colors( + disabledTextColor = colors.unfocusedTextColor, + disabledBorderColor = colors.unfocusedIndicatorColor, + disabledContainerColor = colors.unfocusedContainerColor, + ), enabled = false, maxLines = 1, ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresTab.kt index 9436769ec..06bf1330f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresTab.kt @@ -3,5 +3,5 @@ package com.flxrs.dankchat.preferences.notifications.ignores enum class IgnoresTab { Messages, Users, - Twitch + Twitch, } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresViewModel.kt index bb2329598..f00a2a54c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/notifications/ignores/IgnoresViewModel.kt @@ -5,21 +5,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.database.entity.MessageIgnoreEntityType import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.utils.extensions.replaceAll -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel @KoinViewModel class IgnoresViewModel( - private val ignoresRepository: IgnoresRepository + private val ignoresRepository: IgnoresRepository, + private val dispatchersProvider: DispatchersProvider, ) : ViewModel() { - private val _currentTab = MutableStateFlow(IgnoresTab.Messages) private val eventChannel = Channel(Channel.BUFFERED) @@ -53,18 +53,23 @@ class IgnoresViewModel( position = messageIgnores.lastIndex } - IgnoresTab.Users -> { + IgnoresTab.Users -> { val entity = ignoresRepository.addUserIgnore() userIgnores += entity.toItem() position = userIgnores.lastIndex } - IgnoresTab.Twitch -> return@launch + IgnoresTab.Twitch -> { + return@launch + } } sendEvent(IgnoreEvent.ItemAdded(position, isLast = true)) } - fun addIgnoreItem(item: IgnoreItem, position: Int) = viewModelScope.launch { + fun addIgnoreItem( + item: IgnoreItem, + position: Int, + ) = viewModelScope.launch { val isLast: Boolean when (item) { is MessageIgnoreItem -> { @@ -73,13 +78,13 @@ class IgnoresViewModel( isLast = position == messageIgnores.lastIndex } - is UserIgnoreItem -> { + is UserIgnoreItem -> { ignoresRepository.updateUserIgnore(item.toEntity()) userIgnores.add(position, item) isLast = position == userIgnores.lastIndex } - is TwitchBlockItem -> { + is TwitchBlockItem -> { runCatching { ignoresRepository.addUserBlock(item.userId, item.username) twitchBlocks.add(position, item) @@ -102,18 +107,17 @@ class IgnoresViewModel( messageIgnores.removeAt(position) } - is UserIgnoreItem -> { + is UserIgnoreItem -> { position = userIgnores.indexOfFirst { it.id == item.id } ignoresRepository.removeUserIgnore(item.toEntity()) userIgnores.removeAt(position) } - is TwitchBlockItem -> { + is TwitchBlockItem -> { position = twitchBlocks.indexOfFirst { it.id == item.id } runCatching { ignoresRepository.removeUserBlock(item.userId, item.username) twitchBlocks.removeAt(position) - }.getOrElse { eventChannel.trySend(IgnoreEvent.UnblockError(item)) return@launch @@ -146,7 +150,7 @@ class IgnoresViewModel( .map { it.toEntity() } .partition { it.username.isBlank() } - private suspend fun sendEvent(event: IgnoreEvent) = withContext(Dispatchers.Main.immediate) { + private suspend fun sendEvent(event: IgnoreEvent) = withContext(dispatchersProvider.immediate) { eventChannel.send(event) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt similarity index 50% rename from app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt rename to app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt index 18a68a920..ee89557c7 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/OverviewSettingsScreen.kt @@ -1,9 +1,5 @@ package com.flxrs.dankchat.preferences.overview -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -34,144 +30,110 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.navigation.NavController -import androidx.navigation.fragment.findNavController import com.flxrs.dankchat.BuildConfig import com.flxrs.dankchat.R -import com.flxrs.dankchat.changelog.DankChatVersion -import com.flxrs.dankchat.main.MainFragment -import com.flxrs.dankchat.preferences.DankChatPreferenceStore import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.PreferenceCategoryTitle import com.flxrs.dankchat.preferences.components.PreferenceCategoryWithSummary import com.flxrs.dankchat.preferences.components.PreferenceItem import com.flxrs.dankchat.preferences.components.PreferenceSummary -import com.flxrs.dankchat.theme.DankChatTheme +import com.flxrs.dankchat.ui.theme.DankChatTheme import com.flxrs.dankchat.utils.compose.buildClickableAnnotation import com.flxrs.dankchat.utils.compose.buildLinkAnnotation -import com.flxrs.dankchat.utils.extensions.navigateSafe -import com.google.android.material.transition.MaterialFadeThrough -import com.google.android.material.transition.MaterialSharedAxis -import org.koin.android.ext.android.inject -class OverviewSettingsFragment : Fragment() { +private const val GITHUB_URL = "https://github.com/flex3r/dankchat" +private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" - private val navController: NavController by lazy { findNavController() } +sealed interface SettingsNavigation { + data object Appearance : SettingsNavigation - private val dankChatPreferences: DankChatPreferenceStore by inject() + data object Notifications : SettingsNavigation - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - exitTransition = MaterialFadeThrough() - reenterTransition = MaterialFadeThrough() - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } + data object Chat : SettingsNavigation - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - DankChatTheme { - OverviewSettings( - isLoggedIn = dankChatPreferences.isLoggedIn, - hasChangelog = DankChatVersion.HAS_CHANGELOG, - onBackPressed = { navController.popBackStack() }, - onNavigateRequested = { navigateSafe(it) }, - onLogoutRequested = { - with(navController) { - previousBackStackEntry?.savedStateHandle?.set(MainFragment.LOGOUT_REQUEST_KEY, true) - popBackStack() - } - }, - ) - } - } - } - } + data object Streams : SettingsNavigation - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } + data object Tools : SettingsNavigation + + data object Developer : SettingsNavigation + + data object Changelog : SettingsNavigation + + data object About : SettingsNavigation } @Composable -private fun OverviewSettings( +fun OverviewSettingsScreen( isLoggedIn: Boolean, hasChangelog: Boolean, - onBackPressed: () -> Unit, - onLogoutRequested: () -> Unit, - onNavigateRequested: (id: Int) -> Unit, + onBack: () -> Unit, + onLogout: () -> Unit, + onNavigate: (SettingsNavigation) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), topBar = { TopAppBar( scrollBehavior = scrollBehavior, title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }, ) - } + }, ) }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(top = 16.dp, start = 16.dp, end = 16.dp) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + .verticalScroll(rememberScrollState()), ) { PreferenceItem( title = stringResource(R.string.preference_appearance_header), icon = Icons.Default.Palette, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_appearanceSettingsFragment) }, + onClick = { onNavigate(SettingsNavigation.Appearance) }, ) PreferenceItem( title = stringResource(R.string.preference_highlights_ignores_header), icon = Icons.Default.NotificationsActive, - onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_notificationsSettingsFragment) }, + onClick = { onNavigate(SettingsNavigation.Notifications) }, ) PreferenceItem(stringResource(R.string.preference_chat_header), Icons.Default.Forum, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_chatSettingsFragment) + onNavigate(SettingsNavigation.Chat) }) PreferenceItem(stringResource(R.string.preference_streams_header), Icons.Default.PlayArrow, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_streamsSettingsFragment) + onNavigate(SettingsNavigation.Streams) }) PreferenceItem(stringResource(R.string.preference_tools_header), Icons.Default.Construction, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_toolsSettingsFragment) + onNavigate(SettingsNavigation.Tools) }) PreferenceItem(stringResource(R.string.preference_developer_header), Icons.Default.DeveloperMode, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_developerSettingsFragment) + onNavigate(SettingsNavigation.Developer) }) AnimatedVisibility(hasChangelog) { PreferenceItem(stringResource(R.string.preference_whats_new_header), Icons.Default.FiberNew, onClick = { - onNavigateRequested(R.id.action_overviewSettingsFragment_to_changelogSheetFragment) + onNavigate(SettingsNavigation.Changelog) }) } - PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogoutRequested) + PreferenceItem(stringResource(R.string.logout), Icons.AutoMirrored.Default.ExitToApp, isEnabled = isLoggedIn, onClick = onLogout) SecretDankerModeTrigger { PreferenceCategoryWithSummary( title = { @@ -181,27 +143,29 @@ private fun OverviewSettings( ) }, ) { - val context = LocalContext.current - val annotated = buildAnnotatedString { - append(context.getString(R.string.preference_about_summary, BuildConfig.VERSION_NAME)) - appendLine() - withLink(link = buildLinkAnnotation(GITHUB_URL)) { - append(GITHUB_URL) - } - appendLine() - appendLine() - append(context.getString(R.string.preference_about_tos)) - appendLine() - withLink(link = buildLinkAnnotation(TWITCH_TOS_URL)) { - append(TWITCH_TOS_URL) - } - appendLine() - appendLine() - val licenseText = stringResource(R.string.open_source_licenses) - withLink(link = buildClickableAnnotation(text = licenseText, onClick = { onNavigateRequested(R.id.action_overviewSettingsFragment_to_aboutFragment) })) { - append(licenseText) + val aboutSummary = stringResource(R.string.preference_about_summary, BuildConfig.VERSION_NAME) + val aboutTos = stringResource(R.string.preference_about_tos) + val annotated = + buildAnnotatedString { + append(aboutSummary) + appendLine() + withLink(link = buildLinkAnnotation(GITHUB_URL)) { + append(GITHUB_URL) + } + appendLine() + appendLine() + append(aboutTos) + appendLine() + withLink(link = buildLinkAnnotation(TWITCH_TOS_URL)) { + append(TWITCH_TOS_URL) + } + appendLine() + appendLine() + val licenseText = stringResource(R.string.open_source_licenses) + withLink(link = buildClickableAnnotation(text = licenseText, onClick = { onNavigate(SettingsNavigation.About) })) { + append(licenseText) + } } - } PreferenceSummary(annotated, Modifier.padding(top = 16.dp)) } } @@ -210,21 +174,18 @@ private fun OverviewSettings( } } +@Suppress("UnusedPrivateFunction") @Composable @PreviewDynamicColors @PreviewLightDark private fun OverviewSettingsPreview() { DankChatTheme { - OverviewSettings( + OverviewSettingsScreen( isLoggedIn = false, hasChangelog = true, - onBackPressed = { }, - onLogoutRequested = { }, - onNavigateRequested = { }, + onBack = { }, + onLogout = { }, + onNavigate = { }, ) } } - -private const val GITHUB_URL = "https://github.com/flex3r/dankchat" -private const val TWITCH_TOS_URL = "https://www.twitch.tv/p/terms-of-service" - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/SecretDankerMode.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/SecretDankerMode.kt index db29c80c2..7a4bfa779 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/SecretDankerMode.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/overview/SecretDankerMode.kt @@ -21,12 +21,14 @@ interface SecretDankerScope { fun Modifier.dankClickable(): Modifier } +@Suppress("ContentSlotReused") @Composable fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { if (LocalInspectionMode.current) { - val scope = object : SecretDankerScope { - override fun Modifier.dankClickable(): Modifier = this - } + val scope = + object : SecretDankerScope { + override fun Modifier.dankClickable(): Modifier = this + } content(scope) return } @@ -35,16 +37,17 @@ fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { var secretDankerMode by remember { mutableStateOf(preferences.isSecretDankerModeEnabled) } var lastToast by remember { mutableStateOf(null) } var currentClicks by remember { mutableIntStateOf(0) } - val scope = remember { - object : SecretDankerScope { - override fun Modifier.dankClickable() = clickable( - enabled = !secretDankerMode, - onClick = { currentClicks++ }, - interactionSource = null, - indication = null, - ) + val scope = + remember { + object : SecretDankerScope { + override fun Modifier.dankClickable() = clickable( + enabled = !secretDankerMode, + onClick = { currentClicks++ }, + interactionSource = null, + indication = null, + ) + } } - } val context = LocalContext.current if (!secretDankerMode) { val clicksNeeded = preferences.secretDankerModeClicks @@ -53,12 +56,13 @@ fun SecretDankerModeTrigger(content: @Composable SecretDankerScope.() -> Unit) { when (currentClicks) { in 2.. { val remaining = clicksNeeded - currentClicks - lastToast = Toast - .makeText(context, "$remaining click(s) left to enable secret danker mode", Toast.LENGTH_SHORT) - .apply { show() } + lastToast = + Toast + .makeText(context, "$remaining click(s) left to enable secret danker mode", Toast.LENGTH_SHORT) + .apply { show() } } - clicksNeeded -> { + clicksNeeded -> { Toast .makeText(context, "Secret danker mode enabled", Toast.LENGTH_SHORT) .show() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsDataStore.kt index 4dd7edb50..1d6e479e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsDataStore.kt @@ -23,48 +23,55 @@ class StreamsSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class StreamsPreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class StreamsPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { FetchStreams(R.string.preference_fetch_streams_key), ShowStreamInfo(R.string.preference_streaminfo_key), PreventStreamReloads(R.string.preference_retain_webview_new_key), EnablePiP(R.string.preference_pip_key), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - StreamsPreferenceKeys.FetchStreams -> acc.copy(fetchStreams = value.booleanOrDefault(acc.fetchStreams)) - StreamsPreferenceKeys.ShowStreamInfo -> acc.copy(showStreamInfo = value.booleanOrDefault(acc.showStreamInfo)) - StreamsPreferenceKeys.PreventStreamReloads -> acc.copy(preventStreamReloads = value.booleanOrDefault(acc.preventStreamReloads)) - StreamsPreferenceKeys.EnablePiP -> acc.copy(enablePiP = value.booleanOrDefault(acc.enablePiP)) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + StreamsPreferenceKeys.FetchStreams -> acc.copy(fetchStreams = value.booleanOrDefault(acc.fetchStreams)) + StreamsPreferenceKeys.ShowStreamInfo -> acc.copy(showStreamInfo = value.booleanOrDefault(acc.showStreamInfo)) + StreamsPreferenceKeys.PreventStreamReloads -> acc.copy(preventStreamReloads = value.booleanOrDefault(acc.preventStreamReloads)) + StreamsPreferenceKeys.EnablePiP -> acc.copy(enablePiP = value.booleanOrDefault(acc.enablePiP)) + } } - } - private val dataStore = createDataStore( - fileName = "streams", - context = context, - defaultValue = StreamsSettings(), - serializer = StreamsSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration), - ) + private val dataStore = + createDataStore( + fileName = "streams", + context = context, + defaultValue = StreamsSettings(), + serializer = StreamsSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration), + ) val settings = dataStore.safeData(StreamsSettings()) - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) - val fetchStreams = settings - .map { it.fetchStreams } - .distinctUntilChanged() - val showStreamsInfo = settings - .map { it.showStreamInfo } - .distinctUntilChanged() - val pipEnabled = settings - .map { it.fetchStreams && it.preventStreamReloads && it.enablePiP } - .distinctUntilChanged() + val fetchStreams = + settings + .map { it.fetchStreams } + .distinctUntilChanged() + val showStreamsInfo = + settings + .map { it.showStreamInfo } + .distinctUntilChanged() + val pipEnabled = + settings + .map { it.fetchStreams && it.preventStreamReloads && it.enablePiP } + .distinctUntilChanged() fun current() = currentSettings.value diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt similarity index 68% rename from app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt rename to app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt index b0c4eb8a7..aaa21d804 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsFragment.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsScreen.kt @@ -2,10 +2,6 @@ package com.flxrs.dankchat.preferences.stream import android.content.pm.PackageManager import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -28,59 +24,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.findNavController import com.flxrs.dankchat.R import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem -import com.flxrs.dankchat.theme.DankChatTheme -import com.google.android.material.transition.MaterialFadeThrough import org.koin.compose.viewmodel.koinViewModel -class StreamsSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val viewModel = koinViewModel() - val settings = viewModel.settings.collectAsStateWithLifecycle().value +@Composable +fun StreamsSettingsScreen(onBack: () -> Unit) { + val viewModel = koinViewModel() + val settings = viewModel.settings.collectAsStateWithLifecycle().value - DankChatTheme { - StreamsSettings( - settings = settings, - onInteraction = { viewModel.onInteraction(it) }, - onBackPressed = { findNavController().popBackStack() }, - ) - } - } - } - } + StreamsSettingsContent( + settings = settings, + onInteraction = { viewModel.onInteraction(it) }, + onBack = onBack, + ) } @Composable -private fun StreamsSettings( +private fun StreamsSettingsContent( settings: StreamsSettings, onInteraction: (StreamsSettingsInteraction) -> Unit, - onBackPressed: () -> Unit, + onBack: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( @@ -92,18 +59,19 @@ private fun StreamsSettings( title = { Text(stringResource(R.string.preference_streams_header)) }, navigationIcon = { IconButton( - onClick = onBackPressed, + onClick = onBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { SwitchPreferenceItem( title = stringResource(R.string.preference_fetch_streams_title), @@ -134,10 +102,11 @@ private fun StreamsSettings( ) val activity = LocalActivity.current - val pipAvailable = remember { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + val pipAvailable = + remember { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && activity != null && activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) - } + } if (pipAvailable) { SwitchPreferenceItem( title = stringResource(R.string.preference_pip_title), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsViewModel.kt index af3ac24fd..ef0ba035b 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/stream/StreamsSettingsViewModel.kt @@ -6,37 +6,51 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class StreamsSettingsViewModel( private val dataStore: StreamsSettingsDataStore, ) : ViewModel() { - - val settings = dataStore.settings.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = dataStore.current(), - ) + val settings = + dataStore.settings.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = dataStore.current(), + ) fun onInteraction(interaction: StreamsSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is StreamsSettingsInteraction.FetchStreams -> dataStore.update { it.copy(fetchStreams = interaction.value) } - is StreamsSettingsInteraction.ShowStreamInfo -> dataStore.update { it.copy(showStreamInfo = interaction.value) } - is StreamsSettingsInteraction.ShowStreamCategory -> dataStore.update { it.copy(showStreamCategory = interaction.value) } + is StreamsSettingsInteraction.FetchStreams -> dataStore.update { it.copy(fetchStreams = interaction.value) } + is StreamsSettingsInteraction.ShowStreamInfo -> dataStore.update { it.copy(showStreamInfo = interaction.value) } + is StreamsSettingsInteraction.ShowStreamCategory -> dataStore.update { it.copy(showStreamCategory = interaction.value) } is StreamsSettingsInteraction.PreventStreamReloads -> dataStore.update { it.copy(preventStreamReloads = interaction.value) } - is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } + is StreamsSettingsInteraction.EnablePiP -> dataStore.update { it.copy(enablePiP = interaction.value) } } } } } sealed interface StreamsSettingsInteraction { - data class FetchStreams(val value: Boolean) : StreamsSettingsInteraction - data class ShowStreamInfo(val value: Boolean) : StreamsSettingsInteraction - data class ShowStreamCategory(val value: Boolean) : StreamsSettingsInteraction - data class PreventStreamReloads(val value: Boolean) : StreamsSettingsInteraction - data class EnablePiP(val value: Boolean) : StreamsSettingsInteraction + data class FetchStreams( + val value: Boolean, + ) : StreamsSettingsInteraction + + data class ShowStreamInfo( + val value: Boolean, + ) : StreamsSettingsInteraction + + data class ShowStreamCategory( + val value: Boolean, + ) : StreamsSettingsInteraction + + data class PreventStreamReloads( + val value: Boolean, + ) : StreamsSettingsInteraction + + data class EnablePiP( + val value: Boolean, + ) : StreamsSettingsInteraction } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettings.kt index 4366331d5..56129ae13 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettings.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettings.kt @@ -13,9 +13,10 @@ data class ToolsSettings( val ttsForceEnglish: Boolean = false, val ttsIgnoreUrls: Boolean = false, val ttsIgnoreEmotes: Boolean = false, + val ttsVolume: Float = 1.0f, + val ttsAudioDucking: Boolean = false, val ttsUserIgnoreList: Set = emptySet(), ) { - @Transient val ttsUserNameIgnores = ttsUserIgnoreList.toUserNames() } @@ -28,29 +29,31 @@ data class ImageUploaderConfig( val imageLinkPattern: String?, val deletionLinkPattern: String?, ) { - @Transient - val parsedHeaders: List> = headers - ?.split(";") - ?.mapNotNull { - val splits = runCatching { - it.split(":", limit = 2) - }.getOrElse { return@mapNotNull null } + val parsedHeaders: List> = + headers + ?.split(";") + ?.mapNotNull { + val splits = + runCatching { + it.split(":", limit = 2) + }.getOrElse { return@mapNotNull null } - when { - splits.size != 2 -> null - else -> Pair(splits[0].trim(), splits[1].trim()) - } - }.orEmpty() + when { + splits.size != 2 -> null + else -> Pair(splits[0].trim(), splits[1].trim()) + } + }.orEmpty() companion object { - val DEFAULT = ImageUploaderConfig( - uploadUrl = "https://kappa.lol/api/upload", - formField = "file", - headers = null, - imageLinkPattern = "{link}", - deletionLinkPattern = "{delete}", - ) + val DEFAULT = + ImageUploaderConfig( + uploadUrl = "https://kappa.lol/api/upload", + formField = "file", + headers = null, + imageLinkPattern = "{link}", + deletionLinkPattern = "{delete}", + ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsDataStore.kt index 31458b859..6e8d54e6d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsDataStore.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsDataStore.kt @@ -28,8 +28,9 @@ class ToolsSettingsDataStore( context: Context, dispatchersProvider: DispatchersProvider, ) { - - private enum class ToolsPreferenceKeys(override val id: Int) : PreferenceKeys { + private enum class ToolsPreferenceKeys( + override val id: Int, + ) : PreferenceKeys { TTS(R.string.preference_tts_key), TTSQueue(R.string.preference_tts_queue_key), TTSMessageFormat(R.string.preference_tts_message_format_key), @@ -39,7 +40,9 @@ class ToolsSettingsDataStore( TTSUserIgnoreList(R.string.preference_tts_user_ignore_list_key), } - private enum class UploaderKeys(val key: String) { + private enum class UploaderKeys( + val key: String, + ) { UploadUrl("uploaderUrl"), FormField("uploaderFormField"), Headers("uploaderHeaders"), @@ -47,82 +50,111 @@ class ToolsSettingsDataStore( DeletionLinkPattern("uploaderDeletionLink"), } - private val initialMigration = dankChatPreferencesMigration(context) { acc, key, value -> - when (key) { - ToolsPreferenceKeys.TTS -> acc.copy(ttsEnabled = value.booleanOrDefault(acc.ttsEnabled)) - ToolsPreferenceKeys.TTSQueue -> acc.copy( - ttsPlayMode = value.booleanOrNull()?.let { - if (it) TTSPlayMode.Queue else TTSPlayMode.Newest - } ?: acc.ttsPlayMode - ) - - ToolsPreferenceKeys.TTSMessageFormat -> acc.copy( - ttsMessageFormat = value.booleanOrNull()?.let { - if (it) TTSMessageFormat.UserAndMessage else TTSMessageFormat.Message - } ?: acc.ttsMessageFormat - ) - - ToolsPreferenceKeys.TTSForceEnglish -> acc.copy(ttsForceEnglish = value.booleanOrDefault(acc.ttsForceEnglish)) - ToolsPreferenceKeys.TTSMessageIgnoreUrl -> acc.copy(ttsIgnoreUrls = value.booleanOrDefault(acc.ttsIgnoreUrls)) - ToolsPreferenceKeys.TTSMessageIgnoreEmote -> acc.copy(ttsIgnoreEmotes = value.booleanOrDefault(acc.ttsIgnoreEmotes)) - ToolsPreferenceKeys.TTSUserIgnoreList -> acc.copy(ttsUserIgnoreList = value.stringSetOrDefault(acc.ttsUserIgnoreList)) + private val initialMigration = + dankChatPreferencesMigration(context) { acc, key, value -> + when (key) { + ToolsPreferenceKeys.TTS -> { + acc.copy(ttsEnabled = value.booleanOrDefault(acc.ttsEnabled)) + } + + ToolsPreferenceKeys.TTSQueue -> { + acc.copy( + ttsPlayMode = + value.booleanOrNull()?.let { + if (it) TTSPlayMode.Queue else TTSPlayMode.Newest + } ?: acc.ttsPlayMode, + ) + } + + ToolsPreferenceKeys.TTSMessageFormat -> { + acc.copy( + ttsMessageFormat = + value.booleanOrNull()?.let { + if (it) TTSMessageFormat.UserAndMessage else TTSMessageFormat.Message + } ?: acc.ttsMessageFormat, + ) + } + + ToolsPreferenceKeys.TTSForceEnglish -> { + acc.copy(ttsForceEnglish = value.booleanOrDefault(acc.ttsForceEnglish)) + } + + ToolsPreferenceKeys.TTSMessageIgnoreUrl -> { + acc.copy(ttsIgnoreUrls = value.booleanOrDefault(acc.ttsIgnoreUrls)) + } + + ToolsPreferenceKeys.TTSMessageIgnoreEmote -> { + acc.copy(ttsIgnoreEmotes = value.booleanOrDefault(acc.ttsIgnoreEmotes)) + } + + ToolsPreferenceKeys.TTSUserIgnoreList -> { + acc.copy(ttsUserIgnoreList = value.stringSetOrDefault(acc.ttsUserIgnoreList)) + } + } } - } private val dankchatPreferences = context.getSharedPreferences(context.getString(R.string.shared_preference_key), Context.MODE_PRIVATE) - private val uploaderMigration = object : DataMigration { - override suspend fun migrate(currentData: ToolsSettings): ToolsSettings { - val current = currentData.uploaderConfig - val url = dankchatPreferences.getString(UploaderKeys.UploadUrl.key, current.uploadUrl) ?: current.uploadUrl - val field = dankchatPreferences.getString(UploaderKeys.FormField.key, current.formField) ?: current.formField - val isDefault = url == ImageUploaderConfig.DEFAULT.uploadUrl && field == ImageUploaderConfig.DEFAULT.formField - val headers = dankchatPreferences.getString(UploaderKeys.Headers.key, null) - val link = dankchatPreferences.getString(UploaderKeys.ImageLinkPattern.key, null) - val delete = dankchatPreferences.getString(UploaderKeys.DeletionLinkPattern.key, null) - return currentData.copy( - uploaderConfig = current.copy( - uploadUrl = url, - formField = field, - headers = headers, - imageLinkPattern = if (isDefault) ImageUploaderConfig.DEFAULT.imageLinkPattern else link.orEmpty(), - deletionLinkPattern = if (isDefault) ImageUploaderConfig.DEFAULT.deletionLinkPattern else delete.orEmpty(), + private val uploaderMigration = + object : DataMigration { + override suspend fun migrate(currentData: ToolsSettings): ToolsSettings { + val current = currentData.uploaderConfig + val url = dankchatPreferences.getString(UploaderKeys.UploadUrl.key, current.uploadUrl) ?: current.uploadUrl + val field = dankchatPreferences.getString(UploaderKeys.FormField.key, current.formField) ?: current.formField + val isDefault = url == ImageUploaderConfig.DEFAULT.uploadUrl && field == ImageUploaderConfig.DEFAULT.formField + val headers = dankchatPreferences.getString(UploaderKeys.Headers.key, null) + val link = dankchatPreferences.getString(UploaderKeys.ImageLinkPattern.key, null) + val delete = dankchatPreferences.getString(UploaderKeys.DeletionLinkPattern.key, null) + return currentData.copy( + uploaderConfig = + current.copy( + uploadUrl = url, + formField = field, + headers = headers, + imageLinkPattern = if (isDefault) ImageUploaderConfig.DEFAULT.imageLinkPattern else link.orEmpty(), + deletionLinkPattern = if (isDefault) ImageUploaderConfig.DEFAULT.deletionLinkPattern else delete.orEmpty(), + ), ) - ) - } + } - override suspend fun shouldMigrate(currentData: ToolsSettings): Boolean = UploaderKeys.entries.any { dankchatPreferences.contains(it.key) } - override suspend fun cleanUp() = dankchatPreferences.edit { UploaderKeys.entries.forEach { remove(it.key) } } - } + override suspend fun shouldMigrate(currentData: ToolsSettings): Boolean = UploaderKeys.entries.any { dankchatPreferences.contains(it.key) } + + override suspend fun cleanUp() = dankchatPreferences.edit { UploaderKeys.entries.forEach { remove(it.key) } } + } - private val dataStore = createDataStore( - fileName = "tools", - context = context, - defaultValue = ToolsSettings(), - serializer = ToolsSettings.serializer(), - scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), - migrations = listOf(initialMigration, uploaderMigration), - ) + private val dataStore = + createDataStore( + fileName = "tools", + context = context, + defaultValue = ToolsSettings(), + serializer = ToolsSettings.serializer(), + scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()), + migrations = listOf(initialMigration, uploaderMigration), + ) val settings = dataStore.safeData(ToolsSettings()) - val currentSettings = settings.stateIn( - scope = CoroutineScope(dispatchersProvider.io), - started = SharingStarted.Eagerly, - initialValue = runBlocking { settings.first() } - ) + val currentSettings = + settings.stateIn( + scope = CoroutineScope(dispatchersProvider.io), + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) - val uploadConfig = settings - .map { it.uploaderConfig } - .distinctUntilChanged() + val uploadConfig = + settings + .map { it.uploaderConfig } + .distinctUntilChanged() fun current() = currentSettings.value - val ttsEnabled = settings - .map { it.ttsEnabled } - .distinctUntilChanged() - val ttsForceEnglishChanged = settings - .map { it.ttsForceEnglish } - .distinctUntilChanged() - .drop(1) + val ttsEnabled = + settings + .map { it.ttsEnabled } + .distinctUntilChanged() + val ttsForceEnglishChanged = + settings + .map { it.ttsForceEnglish } + .distinctUntilChanged() + .drop(1) suspend fun update(transform: suspend (ToolsSettings) -> ToolsSettings) { runCatching { dataStore.updateData(transform) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsFragment.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsFragment.kt deleted file mode 100644 index 9cf103ee6..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsFragment.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.flxrs.dankchat.preferences.tools - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.view.doOnPreDraw -import androidx.fragment.app.Fragment -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.findNavController -import com.flxrs.dankchat.preferences.tools.tts.TTSUserIgnoreListScreen -import com.flxrs.dankchat.preferences.tools.upload.ImageUploaderScreen -import com.flxrs.dankchat.theme.DankChatTheme -import com.google.android.material.transition.MaterialFadeThrough - -class ToolsSettingsFragment : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialFadeThrough() - returnTransition = MaterialFadeThrough() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - postponeEnterTransition() - (view.parent as? View)?.doOnPreDraw { startPostponedEnterTransition() } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - DankChatTheme { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = ToolsSettingsRoute.ToolsSettings, - enterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - exitTransition = { fadeOut() }, - popEnterTransition = { fadeIn() + scaleIn(initialScale = 0.92f) }, - popExitTransition = { fadeOut() }, - ) { - composable { - ToolsSettingsScreen( - onNavToImageUploader = { navController.navigate(ToolsSettingsRoute.ImageUploader) }, - onNavToTTSUserIgnoreList = { navController.navigate(ToolsSettingsRoute.TTSUserIgnoreList) }, - onNavBack = { findNavController().popBackStack() }, - ) - } - composable { - ImageUploaderScreen( - onNavBack = { navController.popBackStack() }, - ) - } - composable { - TTSUserIgnoreListScreen( - onNavBack = { navController.popBackStack() }, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsScreen.kt index f3dfa598a..96d9e7553 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.History -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -48,6 +47,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -72,15 +72,18 @@ import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.components.PreferenceCategory import com.flxrs.dankchat.preferences.components.PreferenceItem import com.flxrs.dankchat.preferences.components.PreferenceListDialog +import com.flxrs.dankchat.preferences.components.SliderPreferenceItem import com.flxrs.dankchat.preferences.components.SwitchPreferenceItem import com.flxrs.dankchat.preferences.tools.upload.RecentUpload import com.flxrs.dankchat.preferences.tools.upload.RecentUploadsViewModel +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import com.flxrs.dankchat.utils.compose.buildLinkAnnotation import com.flxrs.dankchat.utils.compose.textLinkStyles import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import sh.calvin.autolinktext.rememberAutoLinkText +import kotlin.math.roundToInt @Composable fun ToolsSettingsScreen( @@ -121,15 +124,16 @@ private fun ToolsSettingsScreen( onClick = onNavBack, content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, ) - } + }, ) }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { ImageUploaderCategory(hasRecentUploads = settings.hasRecentUploads, onNavToImageUploader = onNavToImageUploader) HorizontalDivider(thickness = Dp.Hairline) @@ -167,6 +171,7 @@ fun ImageUploaderCategory( ModalBottomSheet( onDismissRequest = { recentUploadSheetOpen = false }, modifier = Modifier.statusBarsPadding(), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { Text( text = stringResource(R.string.preference_uploader_recent_uploads_title), @@ -177,7 +182,7 @@ fun ImageUploaderCategory( modifier = Modifier.align(Alignment.End), onClick = { confirmClearDialog = true }, enabled = uploads.isNotEmpty(), - content = { Text(stringResource(R.string.recent_uploads_clear)) } + content = { Text(stringResource(R.string.recent_uploads_clear)) }, ) LazyColumn { items(uploads) { upload -> @@ -187,25 +192,14 @@ fun ImageUploaderCategory( } if (confirmClearDialog) { - AlertDialog( - onDismissRequest = { confirmClearDialog = false }, - confirmButton = { - TextButton( - onClick = { - viewModel.clearUploads() - recentUploadSheetOpen = false - }, - content = { Text(stringResource(R.string.clear)) }, - ) + ConfirmationBottomSheet( + title = stringResource(R.string.clear_recent_uploads_dialog_message), + confirmText = stringResource(R.string.clear), + onConfirm = { + viewModel.clearUploads() + recentUploadSheetOpen = false }, - dismissButton = { - TextButton( - onClick = { confirmClearDialog = false }, - content = { Text(stringResource(R.string.dialog_cancel)) }, - ) - }, - title = { Text(stringResource(R.string.clear_recent_uploads_dialog_title)) }, - text = { Text(stringResource(R.string.clear_recent_uploads_dialog_message)) }, + onDismiss = { confirmClearDialog = false }, ) } } @@ -214,23 +208,26 @@ fun ImageUploaderCategory( @Composable fun RecentUploadItem(upload: RecentUpload) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), ) { val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(8.dp) - .height(IntrinsicSize.Min), + modifier = + Modifier + .padding(8.dp) + .height(IntrinsicSize.Min), ) { OutlinedCard { AsyncImage( - modifier = Modifier - .background(CardDefaults.cardColors().containerColor) - .size(96.dp), + modifier = + Modifier + .background(CardDefaults.cardColors().containerColor) + .size(96.dp), model = upload.imageUrl, contentDescription = upload.imageUrl, contentScale = ContentScale.Inside, @@ -239,16 +236,18 @@ fun RecentUploadItem(upload: RecentUpload) { Spacer(Modifier.width(8.dp)) Column( verticalArrangement = Arrangement.Center, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), + modifier = + Modifier + .weight(1f) + .fillMaxHeight(), ) { Row(verticalAlignment = Alignment.CenterVertically) { - val link = buildAnnotatedString { - withLink(link = buildLinkAnnotation(upload.imageUrl)) { - append(upload.imageUrl) + val link = + buildAnnotatedString { + withLink(link = buildLinkAnnotation(upload.imageUrl)) { + append(upload.imageUrl) + } } - } Text( text = link, modifier = Modifier.weight(1f), @@ -267,10 +266,11 @@ fun RecentUploadItem(upload: RecentUpload) { } if (upload.deleteUrl != null) { val deletionText = stringResource(R.string.recent_upload_deletion_link, upload.deleteUrl) - val annotatedDeletionText = AnnotatedString.rememberAutoLinkText( - text = deletionText, - defaultLinkStyles = textLinkStyles(), - ) + val annotatedDeletionText = + AnnotatedString.rememberAutoLinkText( + text = deletionText, + defaultLinkStyles = textLinkStyles(), + ) Text(annotatedDeletionText, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(8.dp)) } @@ -293,12 +293,13 @@ fun TextToSpeechCategory( ) { PreferenceCategory(title = stringResource(R.string.preference_tts_header)) { val context = LocalContext.current - val checkTTSDataLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - when { - it.resultCode != TextToSpeech.Engine.CHECK_VOICE_DATA_PASS -> context.startActivity(Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)) - else -> onInteraction(ToolsSettingsInteraction.TTSEnabled(true)) + val checkTTSDataLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + when { + it.resultCode != TextToSpeech.Engine.CHECK_VOICE_DATA_PASS -> context.startActivity(Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)) + else -> onInteraction(ToolsSettingsInteraction.TTSEnabled(true)) + } } - } SwitchPreferenceItem( title = stringResource(R.string.preference_tts_title), summary = stringResource(R.string.preference_tts_summary), @@ -321,7 +322,7 @@ fun TextToSpeechCategory( entries = modeEntries, selected = settings.ttsPlayMode, isEnabled = settings.ttsEnabled, - onChanged = { onInteraction(ToolsSettingsInteraction.TTSMode(it)) }, + onChange = { onInteraction(ToolsSettingsInteraction.TTSMode(it)) }, ) val formatMessage = stringResource(R.string.preference_tts_message_format_message) @@ -334,7 +335,7 @@ fun TextToSpeechCategory( entries = formatEntries, selected = settings.ttsMessageFormat, isEnabled = settings.ttsEnabled, - onChanged = { onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, + onChange = { onInteraction(ToolsSettingsInteraction.TTSFormat(it)) }, ) SwitchPreferenceItem( @@ -358,6 +359,28 @@ fun TextToSpeechCategory( isEnabled = settings.ttsEnabled, onClick = { onInteraction(ToolsSettingsInteraction.TTSIgnoreEmotes(it)) }, ) + + var volume by remember(settings.ttsVolume) { mutableFloatStateOf(settings.ttsVolume) } + val volumePercent = remember(volume) { "${(volume * 100).roundToInt()}%" } + SliderPreferenceItem( + title = stringResource(R.string.preference_tts_volume_title), + value = volume, + range = 0f..1f, + steps = 19, + onDrag = { volume = it }, + onDragFinish = { onInteraction(ToolsSettingsInteraction.TTSVolume(volume)) }, + summary = volumePercent, + isEnabled = settings.ttsEnabled, + displayValue = false, + ) + SwitchPreferenceItem( + title = stringResource(R.string.preference_tts_audio_ducking_title), + summary = stringResource(R.string.preference_tts_audio_ducking_summary), + isChecked = settings.ttsAudioDucking, + isEnabled = settings.ttsEnabled, + onClick = { onInteraction(ToolsSettingsInteraction.TTSAudioDucking(it)) }, + ) + PreferenceItem( title = stringResource(R.string.preference_tts_user_ignore_list_title), summary = stringResource(R.string.preference_tts_user_ignore_list_summary), diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt new file mode 100644 index 000000000..6e86dc220 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsState.kt @@ -0,0 +1,55 @@ +package com.flxrs.dankchat.preferences.tools + +import kotlinx.collections.immutable.ImmutableSet + +sealed interface ToolsSettingsInteraction { + data class TTSEnabled( + val value: Boolean, + ) : ToolsSettingsInteraction + + data class TTSMode( + val value: TTSPlayMode, + ) : ToolsSettingsInteraction + + data class TTSFormat( + val value: TTSMessageFormat, + ) : ToolsSettingsInteraction + + data class TTSForceEnglish( + val value: Boolean, + ) : ToolsSettingsInteraction + + data class TTSIgnoreUrls( + val value: Boolean, + ) : ToolsSettingsInteraction + + data class TTSIgnoreEmotes( + val value: Boolean, + ) : ToolsSettingsInteraction + + data class TTSVolume( + val value: Float, + ) : ToolsSettingsInteraction + + data class TTSAudioDucking( + val value: Boolean, + ) : ToolsSettingsInteraction + + data class TTSUserIgnoreList( + val value: Set, + ) : ToolsSettingsInteraction +} + +data class ToolsSettingsState( + val imageUploader: ImageUploaderConfig, + val hasRecentUploads: Boolean, + val ttsEnabled: Boolean, + val ttsPlayMode: TTSPlayMode, + val ttsMessageFormat: TTSMessageFormat, + val ttsForceEnglish: Boolean, + val ttsIgnoreUrls: Boolean, + val ttsIgnoreEmotes: Boolean, + val ttsVolume: Float, + val ttsAudioDucking: Boolean, + val ttsUserIgnoreList: ImmutableSet, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsViewModel.kt index 9ee1f3bc0..262d36108 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/ToolsSettingsViewModel.kt @@ -3,14 +3,13 @@ package com.flxrs.dankchat.preferences.tools import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flxrs.dankchat.data.repo.RecentUploadsRepository -import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel @@ -18,55 +17,35 @@ class ToolsSettingsViewModel( private val toolsSettingsDataStore: ToolsSettingsDataStore, private val recentUploadsRepository: RecentUploadsRepository, ) : ViewModel() { - - val settings = combine( - toolsSettingsDataStore.settings, - recentUploadsRepository.getRecentUploads(), - ) { toolsSettings, recentUploads -> - toolsSettings.toState(hasRecentUploads = recentUploads.isNotEmpty()) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = toolsSettingsDataStore.current().toState(hasRecentUploads = false), - ) + val settings = + combine( + toolsSettingsDataStore.settings, + recentUploadsRepository.getRecentUploads(), + ) { toolsSettings, recentUploads -> + toolsSettings.toState(hasRecentUploads = recentUploads.isNotEmpty()) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = toolsSettingsDataStore.current().toState(hasRecentUploads = false), + ) fun onInteraction(interaction: ToolsSettingsInteraction) = viewModelScope.launch { runCatching { when (interaction) { - is ToolsSettingsInteraction.TTSEnabled -> toolsSettingsDataStore.update { it.copy(ttsEnabled = interaction.value) } - is ToolsSettingsInteraction.TTSMode -> toolsSettingsDataStore.update { it.copy(ttsPlayMode = interaction.value) } - is ToolsSettingsInteraction.TTSFormat -> toolsSettingsDataStore.update { it.copy(ttsMessageFormat = interaction.value) } - is ToolsSettingsInteraction.TTSForceEnglish -> toolsSettingsDataStore.update { it.copy(ttsForceEnglish = interaction.value) } - is ToolsSettingsInteraction.TTSIgnoreUrls -> toolsSettingsDataStore.update { it.copy(ttsIgnoreUrls = interaction.value) } - is ToolsSettingsInteraction.TTSIgnoreEmotes -> toolsSettingsDataStore.update { it.copy(ttsIgnoreEmotes = interaction.value) } + is ToolsSettingsInteraction.TTSEnabled -> toolsSettingsDataStore.update { it.copy(ttsEnabled = interaction.value) } + is ToolsSettingsInteraction.TTSMode -> toolsSettingsDataStore.update { it.copy(ttsPlayMode = interaction.value) } + is ToolsSettingsInteraction.TTSFormat -> toolsSettingsDataStore.update { it.copy(ttsMessageFormat = interaction.value) } + is ToolsSettingsInteraction.TTSForceEnglish -> toolsSettingsDataStore.update { it.copy(ttsForceEnglish = interaction.value) } + is ToolsSettingsInteraction.TTSIgnoreUrls -> toolsSettingsDataStore.update { it.copy(ttsIgnoreUrls = interaction.value) } + is ToolsSettingsInteraction.TTSIgnoreEmotes -> toolsSettingsDataStore.update { it.copy(ttsIgnoreEmotes = interaction.value) } + is ToolsSettingsInteraction.TTSVolume -> toolsSettingsDataStore.update { it.copy(ttsVolume = interaction.value) } + is ToolsSettingsInteraction.TTSAudioDucking -> toolsSettingsDataStore.update { it.copy(ttsAudioDucking = interaction.value) } is ToolsSettingsInteraction.TTSUserIgnoreList -> toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = interaction.value) } } } } } -sealed interface ToolsSettingsInteraction { - data class TTSEnabled(val value: Boolean) : ToolsSettingsInteraction - data class TTSMode(val value: TTSPlayMode) : ToolsSettingsInteraction - data class TTSFormat(val value: TTSMessageFormat) : ToolsSettingsInteraction - data class TTSForceEnglish(val value: Boolean) : ToolsSettingsInteraction - data class TTSIgnoreUrls(val value: Boolean) : ToolsSettingsInteraction - data class TTSIgnoreEmotes(val value: Boolean) : ToolsSettingsInteraction - data class TTSUserIgnoreList(val value: Set) : ToolsSettingsInteraction -} - -data class ToolsSettingsState( - val imageUploader: ImageUploaderConfig, - val hasRecentUploads: Boolean, - val ttsEnabled: Boolean, - val ttsPlayMode: TTSPlayMode, - val ttsMessageFormat: TTSMessageFormat, - val ttsForceEnglish: Boolean, - val ttsIgnoreUrls: Boolean, - val ttsIgnoreEmotes: Boolean, - val ttsUserIgnoreList: ImmutableSet, -) - private fun ToolsSettings.toState(hasRecentUploads: Boolean) = ToolsSettingsState( imageUploader = uploaderConfig, hasRecentUploads = hasRecentUploads, @@ -76,5 +55,7 @@ private fun ToolsSettings.toState(hasRecentUploads: Boolean) = ToolsSettingsStat ttsForceEnglish = ttsForceEnglish, ttsIgnoreUrls = ttsIgnoreUrls, ttsIgnoreEmotes = ttsIgnoreEmotes, + ttsVolume = ttsVolume, + ttsAudioDucking = ttsAudioDucking, ttsUserIgnoreList = ttsUserIgnoreList.toImmutableSet(), ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListScreen.kt index 2dc505f49..e67483b3a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -75,13 +74,14 @@ private fun UserIgnoreListScreen( onSaveAndNavBack: (List) -> Unit, onSave: (List) -> Unit, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val ignores = remember { initialIgnores.toMutableStateList() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val listState = rememberLazyListState() val snackbarHost = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val itemRemovedMsg = stringResource(R.string.item_removed) + val undoMsg = stringResource(R.string.undo) LifecycleStartEffect(Unit) { onStopOrDispose { @@ -91,9 +91,10 @@ private fun UserIgnoreListScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), snackbarHost = { SnackbarHost(snackbarHost) }, topBar = { TopAppBar( @@ -112,19 +113,20 @@ private fun UserIgnoreListScreen( ExtendedFloatingActionButton( text = { Text(stringResource(R.string.tts_ignore_list_add_user)) }, icon = { Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.tts_ignore_list_add_user)) }, - modifier = Modifier - .navigationBarsPadding() - .padding(8.dp), + modifier = + Modifier + .navigationBarsPadding() + .padding(8.dp), onClick = { focusManager.clearFocus() ignores += UserIgnore(user = "") scope.launch { when { listState.canScrollForward -> listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1) - else -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) + else -> listState.requestScrollToItem(listState.layoutInfo.totalItemsCount - 1) } } - } + }, ) } }, @@ -133,25 +135,27 @@ private fun UserIgnoreListScreen( DankBackground(visible = ignores.isEmpty()) LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { - itemsIndexed(ignores, key = { _, it -> it.id }) { idx, ignore -> + itemsIndexed(ignores, key = { _, item -> item.id }) { idx, ignore -> UserIgnoreItem( user = ignore.user, - onUserChanged = { ignores[idx] = ignore.copy(user = it) }, + onUserChange = { ignores[idx] = ignore.copy(user = it) }, onRemove = { focusManager.clearFocus() val removed = ignores.removeAt(idx) scope.launch { snackbarHost.currentSnackbarData?.dismiss() - val result = snackbarHost.showSnackbar( - message = context.getString(R.string.item_removed), - actionLabel = context.getString(R.string.undo), - duration = SnackbarDuration.Short - ) + val result = + snackbarHost.showSnackbar( + message = itemRemovedMsg, + actionLabel = undoMsg, + duration = SnackbarDuration.Short, + ) if (result == SnackbarResult.ActionPerformed) { focusManager.clearFocus() ignores.add(idx, removed) @@ -159,12 +163,13 @@ private fun UserIgnoreListScreen( } } }, - modifier = Modifier - .padding(bottom = 16.dp) - .animateItem( - fadeInSpec = null, - fadeOutSpec = null, - ), + modifier = + Modifier + .padding(bottom = 16.dp) + .animateItem( + fadeInSpec = null, + fadeOutSpec = null, + ), ) } item(key = "spacer") { @@ -177,7 +182,7 @@ private fun UserIgnoreListScreen( @Composable private fun UserIgnoreItem( user: String, - onUserChanged: (String) -> Unit, + onUserChange: (String) -> Unit, onRemove: () -> Unit, modifier: Modifier = Modifier, ) { @@ -185,11 +190,12 @@ private fun UserIgnoreItem( ElevatedCard { Row { OutlinedTextField( - modifier = Modifier - .weight(1f) - .padding(16.dp), + modifier = + Modifier + .weight(1f) + .padding(16.dp), value = user, - onValueChange = onUserChanged, + onValueChange = onUserChange, label = { Text(stringResource(R.string.tts_ignore_list_user_hint)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListViewModel.kt index f8278737e..38bf56b8d 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/tts/TTSUserIgnoreListViewModel.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -17,21 +17,24 @@ import kotlin.uuid.Uuid class TTSUserIgnoreListViewModel( private val toolsSettingsDataStore: ToolsSettingsDataStore, ) : ViewModel() { - - val userIgnores = toolsSettingsDataStore.settings - .map { it.ttsUserIgnoreList.mapToUserIgnores() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = toolsSettingsDataStore.current().ttsUserIgnoreList.mapToUserIgnores() - ) + val userIgnores = + toolsSettingsDataStore.settings + .map { it.ttsUserIgnoreList.mapToUserIgnores() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = toolsSettingsDataStore.current().ttsUserIgnoreList.mapToUserIgnores(), + ) fun save(ignores: List) = viewModelScope.launch { - val filtered = ignores.mapNotNullTo(mutableSetOf()) { it.user.takeIf { it.isNotBlank() } } + val filtered = ignores.mapNotNullTo(mutableSetOf()) { ignore -> ignore.user.takeIf { it.isNotBlank() } } toolsSettingsDataStore.update { it.copy(ttsUserIgnoreList = filtered) } } private fun Set.mapToUserIgnores() = map { UserIgnore(user = it) }.toImmutableList() } -data class UserIgnore(val id: String = Uuid.random().toString(), val user: String) +data class UserIgnore( + val id: String = Uuid.random().toString(), + val user: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderScreen.kt index 5fe17f191..e5202fd42 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderScreen.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderScreen.kt @@ -20,8 +20,8 @@ import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -51,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flxrs.dankchat.R import com.flxrs.dankchat.preferences.components.NavigationBarSpacer import com.flxrs.dankchat.preferences.tools.ImageUploaderConfig +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet import com.flxrs.dankchat.utils.compose.textLinkStyles import org.koin.compose.viewmodel.koinViewModel import sh.calvin.autolinktext.rememberAutoLinkText @@ -75,7 +76,7 @@ private fun ImageUploaderScreen( uploaderConfig: ImageUploaderConfig, onReset: () -> Unit, onSave: (ImageUploaderConfig) -> Unit, - onSaveAndNavBack: (ImageUploaderConfig) -> Unit + onSaveAndNavBack: (ImageUploaderConfig) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uploadUrl = rememberTextFieldState(uploaderConfig.uploadUrl) @@ -83,15 +84,16 @@ private fun ImageUploaderScreen( val headers = rememberTextFieldState(uploaderConfig.headers.orEmpty()) val linkPattern = rememberTextFieldState(uploaderConfig.imageLinkPattern.orEmpty()) val deleteLinkPattern = rememberTextFieldState(uploaderConfig.deletionLinkPattern.orEmpty()) - val hasChanged = remember(uploaderConfig) { - derivedStateOf { - uploaderConfig.uploadUrl != uploadUrl.text || + val hasChanged = + remember(uploaderConfig) { + derivedStateOf { + uploaderConfig.uploadUrl != uploadUrl.text || uploaderConfig.formField != formField.text || uploaderConfig.headers.orEmpty() != headers.text || uploaderConfig.imageLinkPattern.orEmpty() != linkPattern.text || uploaderConfig.deletionLinkPattern.orEmpty() != deleteLinkPattern.text + } } - } var resetDialog by remember { mutableStateOf(false) } val currentConfig = { @@ -112,9 +114,10 @@ private fun ImageUploaderScreen( Scaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection) - .imePadding(), + modifier = + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .imePadding(), topBar = { TopAppBar( scrollBehavior = scrollBehavior, @@ -129,17 +132,19 @@ private fun ImageUploaderScreen( }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .verticalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - val description = AnnotatedString.rememberAutoLinkText( - text = stringResource(R.string.uploader_description), - defaultLinkStyles = textLinkStyles(), - ) + val description = + AnnotatedString.rememberAutoLinkText( + text = stringResource(R.string.uploader_description), + defaultLinkStyles = textLinkStyles(), + ) Text(description, style = MaterialTheme.typography.bodyMedium) TextButton( @@ -152,11 +157,12 @@ private fun ImageUploaderScreen( modifier = Modifier.fillMaxWidth(), state = uploadUrl, label = { Text(stringResource(R.string.uploader_url)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - autoCorrectEnabled = false, - imeAction = ImeAction.Next, - ), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), lineLimits = TextFieldLineLimits.SingleLine, ) Spacer(Modifier.height(8.dp)) @@ -164,10 +170,11 @@ private fun ImageUploaderScreen( modifier = Modifier.fillMaxWidth(), state = formField, label = { Text(stringResource(R.string.uploader_field)) }, - keyboardOptions = KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Next, - ), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), lineLimits = TextFieldLineLimits.SingleLine, ) Spacer(Modifier.height(8.dp)) @@ -175,10 +182,11 @@ private fun ImageUploaderScreen( modifier = Modifier.fillMaxWidth(), state = headers, label = { Text(stringResource(R.string.uploader_headers)) }, - keyboardOptions = KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Next, - ), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), lineLimits = TextFieldLineLimits.SingleLine, ) Spacer(Modifier.height(8.dp)) @@ -186,10 +194,11 @@ private fun ImageUploaderScreen( modifier = Modifier.fillMaxWidth(), state = linkPattern, label = { Text(stringResource(R.string.uploader_image_link)) }, - keyboardOptions = KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Next, - ), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), lineLimits = TextFieldLineLimits.SingleLine, ) Spacer(Modifier.height(8.dp)) @@ -197,18 +206,20 @@ private fun ImageUploaderScreen( modifier = Modifier.fillMaxWidth(), state = deleteLinkPattern, label = { Text(stringResource(R.string.uploader_deletion_link)) }, - keyboardOptions = KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Done, - ), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), lineLimits = TextFieldLineLimits.SingleLine, ) AnimatedVisibility(visible = hasChanged.value) { Button( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), onClick = { onSaveAndNavBack(currentConfig()) }, content = { Text(stringResource(R.string.save)) }, ) @@ -218,31 +229,21 @@ private fun ImageUploaderScreen( } if (resetDialog) { - AlertDialog( - onDismissRequest = { resetDialog = false }, - title = { Text(stringResource(R.string.reset_media_uploader_dialog_title)) }, - text = { Text(stringResource(R.string.reset_media_uploader_dialog_message)) }, - confirmButton = { - TextButton( - onClick = { - resetDialog = false - onReset() - val default = ImageUploaderConfig.DEFAULT - uploadUrl.setTextAndPlaceCursorAtEnd(default.uploadUrl) - formField.setTextAndPlaceCursorAtEnd(default.formField) - headers.setTextAndPlaceCursorAtEnd(default.headers.orEmpty()) - linkPattern.setTextAndPlaceCursorAtEnd(default.imageLinkPattern.orEmpty()) - deleteLinkPattern.setTextAndPlaceCursorAtEnd(default.deletionLinkPattern.orEmpty()) - }, - content = { Text(stringResource(R.string.reset_media_uploader_dialog_positive)) }, - ) - }, - dismissButton = { - TextButton( - onClick = { resetDialog = false }, - content = { Text(stringResource(R.string.dialog_cancel)) }, - ) + ConfirmationBottomSheet( + title = stringResource(R.string.reset_media_uploader_dialog_message), + confirmText = stringResource(R.string.reset_media_uploader_dialog_positive), + confirmColors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onConfirm = { + resetDialog = false + onReset() + val default = ImageUploaderConfig.DEFAULT + uploadUrl.setTextAndPlaceCursorAtEnd(default.uploadUrl) + formField.setTextAndPlaceCursorAtEnd(default.formField) + headers.setTextAndPlaceCursorAtEnd(default.headers.orEmpty()) + linkPattern.setTextAndPlaceCursorAtEnd(default.imageLinkPattern.orEmpty()) + deleteLinkPattern.setTextAndPlaceCursorAtEnd(default.deletionLinkPattern.orEmpty()) }, + onDismiss = { resetDialog = false }, ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderViewModel.kt index 118f74900..10fae8cf8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/ImageUploaderViewModel.kt @@ -8,27 +8,28 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import kotlin.time.Duration.Companion.seconds @KoinViewModel class ImageUploaderViewModel( private val toolsSettingsDataStore: ToolsSettingsDataStore, ) : ViewModel() { - - val uploader = toolsSettingsDataStore.uploadConfig - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = toolsSettingsDataStore.current().uploaderConfig, - ) + val uploader = + toolsSettingsDataStore.uploadConfig + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = toolsSettingsDataStore.current().uploaderConfig, + ) fun save(uploader: ImageUploaderConfig) = viewModelScope.launch { - val validated = uploader.copy( - headers = uploader.headers?.takeIf { it.isNotBlank() }, - imageLinkPattern = uploader.imageLinkPattern?.takeIf { it.isNotBlank() }, - deletionLinkPattern = uploader.deletionLinkPattern?.takeIf { it.isNotBlank() }, - ) + val validated = + uploader.copy( + headers = uploader.headers?.takeIf { it.isNotBlank() }, + imageLinkPattern = uploader.imageLinkPattern?.takeIf { it.isNotBlank() }, + deletionLinkPattern = uploader.deletionLinkPattern?.takeIf { it.isNotBlank() }, + ) toolsSettingsDataStore.update { it.copy(uploaderConfig = validated) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUpload.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUpload.kt index 92ef8bc7c..730a5526c 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUpload.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUpload.kt @@ -1,3 +1,8 @@ package com.flxrs.dankchat.preferences.tools.upload -data class RecentUpload(val id: Long, val imageUrl: String, val deleteUrl: String?, val formattedUploadTime: String) +data class RecentUpload( + val id: Long, + val imageUrl: String, + val deleteUrl: String?, + val formattedUploadTime: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUploadsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUploadsViewModel.kt index f8b6e4e68..e2ed4dcd0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUploadsViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/preferences/tools/upload/RecentUploadsViewModel.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -18,35 +18,35 @@ import kotlin.time.Duration.Companion.seconds @KoinViewModel class RecentUploadsViewModel( - private val recentUploadsRepository: RecentUploadsRepository + private val recentUploadsRepository: RecentUploadsRepository, ) : ViewModel() { - - val recentUploads = recentUploadsRepository - .getRecentUploads() - .map { uploads -> - uploads.map { - RecentUpload( - id = it.id, - imageUrl = it.imageLink, - deleteUrl = it.deleteLink, - formattedUploadTime = it.timestamp.formatWithLocale(Locale.getDefault()) - ) - } - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5.seconds), - initialValue = emptyList(), - ) + val recentUploads = + recentUploadsRepository + .getRecentUploads() + .map { uploads -> + uploads.map { + RecentUpload( + id = it.id, + imageUrl = it.imageLink, + deleteUrl = it.deleteLink, + formattedUploadTime = it.timestamp.formatWithLocale(Locale.getDefault()), + ) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5.seconds), + initialValue = emptyList(), + ) fun clearUploads() = viewModelScope.launch { recentUploadsRepository.clearUploads() } companion object { - private val formatter = DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.SHORT) - .withZone(ZoneId.systemDefault()) + private val formatter = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.SHORT) + .withZone(ZoneId.systemDefault()) private fun Instant.formatWithLocale(locale: Locale) = formatter .withLocale(locale) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt b/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt deleted file mode 100644 index 4511b0261..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/theme/DankChatTheme.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.flxrs.dankchat.theme - -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialExpressiveTheme -import androidx.compose.material3.MotionScheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.expressiveLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore -import org.koin.compose.koinInject - -private val TrueDarkColorScheme = darkColorScheme( - surface = Color.Black, - background = Color.Black, - onSurface = Color.White, - onBackground = Color.White, -) - -@Composable -fun DankChatTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val inspectionMode = LocalInspectionMode.current - val appearanceSettings = if (!inspectionMode) koinInject() else null - val trueDarkTheme = remember { appearanceSettings?.current()?.trueDarkTheme == true } - - // Dynamic color is available on Android 12+ - val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - val colors = when { - dynamicColor && darkTheme && trueDarkTheme -> dynamicDarkColorScheme(LocalContext.current).copy( - surface = TrueDarkColorScheme.surface, - background = TrueDarkColorScheme.background, - ) - - dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) - dynamicColor -> dynamicLightColorScheme(LocalContext.current) - darkTheme && trueDarkTheme -> TrueDarkColorScheme - darkTheme -> darkColorScheme() - else -> expressiveLightColorScheme() - } - - MaterialExpressiveTheme( - motionScheme = MotionScheme.expressive(), - colorScheme = colors, - content = content, - ) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt new file mode 100644 index 000000000..57d3dc6fc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogScreen.kt @@ -0,0 +1,77 @@ +package com.flxrs.dankchat.ui.changelog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun ChangelogScreen(onBack: () -> Unit) { + val viewModel: ChangelogSheetViewModel = koinViewModel() + val state = viewModel.state ?: return + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + scrollBehavior = scrollBehavior, + title = { + Column { + Text(stringResource(R.string.preference_whats_new_header)) + Text( + text = stringResource(R.string.changelog_sheet_subtitle, state.version), + style = MaterialTheme.typography.labelMedium, + ) + } + }, + navigationIcon = { + IconButton( + onClick = onBack, + content = { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) }, + ) + }, + ) + }, + ) { padding -> + val entries = state.changelog.split("\n").filter { it.isNotBlank() } + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(padding), + ) { + items(entries) { entry -> + Text( + text = entry, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt similarity index 53% rename from app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt index b5d54859e..b3be07325 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/changelog/ChangelogSheetViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogSheetViewModel.kt @@ -1,19 +1,19 @@ -package com.flxrs.dankchat.changelog +package com.flxrs.dankchat.ui.changelog import androidx.lifecycle.ViewModel import com.flxrs.dankchat.preferences.DankChatPreferenceStore -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.KoinViewModel @KoinViewModel class ChangelogSheetViewModel( dankChatPreferenceStore: DankChatPreferenceStore, ) : ViewModel() { - init { dankChatPreferenceStore.setCurrentInstalledVersionCode() } - val state: ChangelogState? = DankChatVersion.LATEST_CHANGELOG?.let { - ChangelogState(it.version.copy(patch = 0).formattedString(), it.string) - } + val state: ChangelogState? = + DankChatVersion.LATEST_CHANGELOG?.let { + ChangelogState(it.version.copy(patch = 0).formattedString(), it.string) + } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt new file mode 100644 index 000000000..932d093dd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/ChangelogState.kt @@ -0,0 +1,9 @@ +package com.flxrs.dankchat.ui.changelog + +import androidx.compose.runtime.Immutable + +@Immutable +data class ChangelogState( + val version: String, + val changelog: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt new file mode 100644 index 000000000..e5e2da497 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatChangelog.kt @@ -0,0 +1,7 @@ +package com.flxrs.dankchat.ui.changelog + +@Suppress("unused") +enum class DankChatChangelog( + val version: DankChatVersion, + val string: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt new file mode 100644 index 000000000..565c4595f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/changelog/DankChatVersion.kt @@ -0,0 +1,31 @@ +package com.flxrs.dankchat.ui.changelog + +import com.flxrs.dankchat.BuildConfig + +data class DankChatVersion( + val major: Int, + val minor: Int, + val patch: Int, +) : Comparable { + override fun compareTo(other: DankChatVersion): Int = COMPARATOR.compare(this, other) + + fun formattedString(): String = "$major.$minor.$patch" + + companion object { + private val CURRENT = checkNotNull(fromString(BuildConfig.VERSION_NAME)) { "Invalid VERSION_NAME: ${BuildConfig.VERSION_NAME}" } + private val COMPARATOR = + Comparator + .comparingInt(DankChatVersion::major) + .thenComparingInt(DankChatVersion::minor) + .thenComparingInt(DankChatVersion::patch) + + fun fromString(version: String): DankChatVersion? = version + .split(".") + .mapNotNull(String::toIntOrNull) + .takeIf { it.size == 3 } + ?.let { (major, minor, patch) -> DankChatVersion(major, minor, patch) } + + val LATEST_CHANGELOG = DankChatChangelog.entries.findLast { CURRENT >= it.version } + val HAS_CHANGELOG = LATEST_CHANGELOG != null + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt new file mode 100644 index 000000000..161a05afd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatComposable.kt @@ -0,0 +1,80 @@ +package com.flxrs.dankchat.ui.chat + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import kotlinx.collections.immutable.persistentListOf +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatComposable( + channel: UserName, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, + onReplyClick: (String, UserName) -> Unit, + modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, + showInput: Boolean = true, + isFullscreen: Boolean = false, + showFabs: Boolean = true, + onRecover: () -> Unit = {}, + fabMenuCallbacks: FabMenuCallbacks? = null, + contentPadding: PaddingValues = PaddingValues(), + onScrollToBottom: () -> Unit = {}, + onScrollDirectionChange: (Boolean) -> Unit = {}, + scrollToMessageId: String? = null, + onScrollToMessageHandle: () -> Unit = {}, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, +) { + // Create ChatViewModel with channel-specific key for proper scoping + val viewModel: ChatViewModel = + koinViewModel( + key = channel.value, + parameters = { parametersOf(channel) }, + ) + + val messages by viewModel.chatUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) + val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() + + ChatScreen( + messages = messages, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onReplyClick = onReplyClick, + onAutomodAllow = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = true) }, + onAutomodDeny = { heldMessageId, ch -> viewModel.manageAutomodMessage(heldMessageId, ch, allow = false) }, + ), + animateGifs = displaySettings.animateGifs, + modifier = modifier.fillMaxSize(), + showInput = showInput, + isFullscreen = isFullscreen, + showFabs = showFabs, + onRecover = onRecover, + fabMenuCallbacks = fabMenuCallbacks, + contentPadding = contentPadding, + scrollModifier = scrollModifier, + onScrollToBottom = onScrollToBottom, + onScrollDirectionChange = onScrollDirectionChange, + scrollToMessageId = scrollToMessageId, + onScrollToMessageHandle = onScrollToMessageHandle, + recoveryFabTooltipState = recoveryFabTooltipState, + onTourAdvance = onTourAdvance, + onTourSkip = onTourSkip, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt new file mode 100644 index 000000000..42420db9b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageMapper.kt @@ -0,0 +1,973 @@ +package com.flxrs.dankchat.ui.chat + +import androidx.compose.ui.graphics.Color +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatImportance +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.toUserId +import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +import com.flxrs.dankchat.data.twitch.message.AutomodMessage +import com.flxrs.dankchat.data.twitch.message.Highlight +import com.flxrs.dankchat.data.twitch.message.HighlightType +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.ModerationMessage.Action +import com.flxrs.dankchat.data.twitch.message.NoticeMessage +import com.flxrs.dankchat.data.twitch.message.PointRedemptionMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.data.twitch.message.aliasOrFormattedName +import com.flxrs.dankchat.data.twitch.message.highestPriorityHighlight +import com.flxrs.dankchat.data.twitch.message.hypeChatInfo +import com.flxrs.dankchat.data.twitch.message.isAnimatedMessage +import com.flxrs.dankchat.data.twitch.message.isElevatedMessage +import com.flxrs.dankchat.data.twitch.message.isGigantifiedEmote +import com.flxrs.dankchat.data.twitch.message.recipientAliasOrFormattedName +import com.flxrs.dankchat.data.twitch.message.senderAliasOrFormattedName +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.chat.ChatSettings +import com.flxrs.dankchat.utils.DateTimeUtils +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.koin.core.annotation.Single + +/** + * Maps domain Message objects to Compose UI state objects. + * Pre-computed all rendering decisions to minimize work during composition. + */ +@Single +class ChatMessageMapper( + private val usersRepository: UsersRepository, +) { + fun mapToUiState( + item: ChatItem, + chatSettings: ChatSettings, + preferenceStore: DankChatPreferenceStore, + isAlternateBackground: Boolean, + ): ChatMessageUiState { + val textAlpha = + when (item.importance) { + ChatImportance.SYSTEM -> 1f + ChatImportance.DELETED -> 0.5f + ChatImportance.REGULAR -> 1f + } + + return when (val msg = item.message) { + is SystemMessage -> { + msg.toSystemMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + ) + } + + is NoticeMessage -> { + msg.toNoticeMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + ) + } + + is UserNoticeMessage -> { + msg.toUserNoticeMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + ) + } + + is PrivMessage -> { + msg.toPrivMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + isMentionTab = item.isMentionTab, + isInReplies = item.isInReplies, + textAlpha = textAlpha, + ) + } + + is AutomodMessage -> { + msg.toAutomodMessageUi( + tag = item.tag, + chatSettings = chatSettings, + textAlpha = textAlpha, + ) + } + + is ModerationMessage -> { + msg.toModerationMessageUi( + tag = item.tag, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + ) + } + + is PointRedemptionMessage -> { + msg.toPointRedemptionMessageUi( + tag = item.tag, + chatSettings = chatSettings, + textAlpha = textAlpha, + ) + } + + is WhisperMessage -> { + msg.toWhisperMessageUi( + tag = item.tag, + chatSettings = chatSettings, + isAlternateBackground = isAlternateBackground, + textAlpha = textAlpha, + currentUserName = preferenceStore.userName, + ) + } + } + } + + fun List.withHighlightLayout(showLineSeparator: Boolean): List = mapIndexed { index, message -> + val above = getOrNull(index - 1) + val below = getOrNull(index + 1) + val hasSameAbove = message.hasSameHighlightBackground(above) + val hasSameBelow = message.hasSameHighlightBackground(below) + + val roundedTop = message.isHighlighted && !hasSameAbove + val roundedBottom = message.isHighlighted && !hasSameBelow + + val isHighlightBoundary = (message.isHighlighted && !hasSameBelow) || + (below != null && below.isHighlighted && !below.hasSameHighlightBackground(message)) + val showDivider = showLineSeparator && below != null && !isHighlightBoundary + + message.withLayout(roundedTop, roundedBottom, showDivider) + } + + private fun SystemMessage.toSystemMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.SystemMessageUi { + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val message = + when (type) { + is SystemMessageType.Disconnected -> { + TextResource.Res(R.string.system_message_disconnected) + } + + is SystemMessageType.Connected -> { + TextResource.Res(R.string.system_message_connected) + } + + is SystemMessageType.Reconnected -> { + TextResource.Res(R.string.system_message_reconnected) + } + + is SystemMessageType.LoginExpired -> { + TextResource.Res(R.string.login_expired) + } + + is SystemMessageType.ChannelNonExistent -> { + TextResource.Res(R.string.system_message_channel_non_existent) + } + + is SystemMessageType.MessageHistoryIgnored -> { + TextResource.Res(R.string.system_message_history_ignored) + } + + is SystemMessageType.MessageHistoryIncomplete -> { + TextResource.Res(R.string.system_message_history_recovering) + } + + is SystemMessageType.ChannelBTTVEmotesFailed -> { + TextResource.Res(R.string.system_message_bttv_emotes_failed, persistentListOf(type.status)) + } + + is SystemMessageType.ChannelFFZEmotesFailed -> { + TextResource.Res(R.string.system_message_ffz_emotes_failed, persistentListOf(type.status)) + } + + is SystemMessageType.ChannelSevenTVEmotesFailed -> { + TextResource.Res(R.string.system_message_7tv_emotes_failed, persistentListOf(type.status)) + } + + is SystemMessageType.Custom -> { + type.message + } + + is SystemMessageType.Debug -> { + TextResource.Plain(type.message) + } + + is SystemMessageType.SendNotLoggedIn -> { + TextResource.Res(R.string.system_message_send_not_logged_in) + } + + is SystemMessageType.SendChannelNotResolved -> { + TextResource.Res(R.string.system_message_send_channel_not_resolved, persistentListOf(type.channel)) + } + + is SystemMessageType.SendNotDelivered -> { + TextResource.Res(R.string.system_message_send_not_delivered) + } + + is SystemMessageType.SendDropped -> { + TextResource.Res(R.string.system_message_send_dropped, persistentListOf(type.reason, type.code)) + } + + is SystemMessageType.SendMissingScopes -> { + TextResource.Res(R.string.system_message_send_missing_scopes) + } + + is SystemMessageType.SendNotAuthorized -> { + TextResource.Res(R.string.system_message_send_not_authorized) + } + + is SystemMessageType.SendMessageTooLarge -> { + TextResource.Res(R.string.system_message_send_message_too_large) + } + + is SystemMessageType.SendRateLimited -> { + TextResource.Res(R.string.system_message_send_rate_limited) + } + + is SystemMessageType.SendFailed -> { + TextResource.Res(R.string.system_message_send_failed, persistentListOf(type.message.orEmpty())) + } + + is SystemMessageType.MessageHistoryUnavailable -> { + when (type.status) { + null -> TextResource.Res(R.string.system_message_history_unavailable) + else -> TextResource.Res(R.string.system_message_history_unavailable_detailed, persistentListOf(type.status)) + } + } + + is SystemMessageType.ChannelSevenTVEmoteAdded -> { + TextResource.Res(R.string.system_message_7tv_emote_added, persistentListOf(type.actorName, type.emoteName)) + } + + is SystemMessageType.ChannelSevenTVEmoteRemoved -> { + TextResource.Res(R.string.system_message_7tv_emote_removed, persistentListOf(type.actorName, type.emoteName)) + } + + is SystemMessageType.ChannelSevenTVEmoteRenamed -> { + TextResource.Res( + R.string.system_message_7tv_emote_renamed, + persistentListOf(type.actorName, type.oldEmoteName, type.emoteName), + ) + } + + is SystemMessageType.ChannelSevenTVEmoteSetChanged -> { + TextResource.Res(R.string.system_message_7tv_emote_set_changed, persistentListOf(type.actorName, type.newEmoteSetName)) + } + + is SystemMessageType.AutomodActionFailed -> { + val actionRes = TextResource.Res(if (type.allow) R.string.automod_allow else R.string.automod_deny) + val errorResId = + when (type.statusCode) { + 400 -> R.string.automod_error_already_processed + 401 -> R.string.automod_error_not_authenticated + 403 -> R.string.automod_error_not_authorized + 404 -> R.string.automod_error_not_found + else -> R.string.automod_error_unknown + } + TextResource.Res(errorResId, persistentListOf(actionRes)) + } + } + + return ChatMessageUiState.SystemMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + message = message, + ) + } + + private fun NoticeMessage.toNoticeMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.NoticeMessageUi { + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + return ChatMessageUiState.NoticeMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + message = message, + ) + } + + private fun UserNoticeMessage.toUserNoticeMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.UserNoticeMessageUi { + val highlightType = + highlights.firstOrNull { + it.type == HighlightType.Subscription || + it.type == HighlightType.Announcement || + it.type == HighlightType.WatchStreak + } + val backgroundColors = + when { + highlightType != null -> highlights.toBackgroundColors() + else -> calculateCheckeredBackgroundColors(isAlternateBackground, false) + } + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val displayName = tags["display-name"].orEmpty() + val login = tags["login"]?.toUserName() + val ircColor = + tags["color"]?.ifBlank { null }?.let(android.graphics.Color::parseColor) + ?: login?.let { usersRepository.getCachedUserColor(it) } + val rawNameColor = resolveNameColor(null, ircColor, tags["user-id"]?.toUserId(), chatSettings) + + return ChatMessageUiState.UserNoticeMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + isHighlighted = highlightType != null, + message = message, + displayName = displayName, + rawNameColor = rawNameColor, + shouldHighlight = highlightType != null, + ) + } + + private fun ModerationMessage.toModerationMessageUi( + tag: Int, + chatSettings: ChatSettings, + preferenceStore: DankChatPreferenceStore, + isAlternateBackground: Boolean, + textAlpha: Float, + ): ChatMessageUiState.ModerationMessageUi { + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, false) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val arguments = + buildList { + when (action) { + is Action.Timeout -> add(action.duration) + is Action.SharedTimeout -> add(action.duration) + else -> Unit + } + reason?.takeIf { it.isNotBlank() }?.let(::add) + sourceBroadcasterDisplay?.toString()?.let(::add) + }.toImmutableList() + + return ChatMessageUiState.ModerationMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + channel = channel, + message = getSystemMessage(preferenceStore.userName, chatSettings.showTimedOutMessages), + creatorName = creatorUserDisplay?.toString(), + targetName = targetUserDisplay?.toString(), + creatorColor = creatorUserDisplay?.let { usersRepository.getCachedUserColor(UserName(it.toString())) } ?: Message.DEFAULT_COLOR, + targetColor = targetUser?.let { usersRepository.getCachedUserColor(it) } ?: Message.DEFAULT_COLOR, + arguments = arguments, + ) + } + + private fun AutomodMessage.toAutomodMessageUi( + tag: Int, + chatSettings: ChatSettings, + textAlpha: Float, + ): ChatMessageUiState.AutomodMessageUi { + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val uiStatus = + when (status) { + AutomodMessage.Status.Pending -> ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus.Pending + AutomodMessage.Status.Approved -> ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus.Approved + AutomodMessage.Status.Denied -> ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus.Denied + AutomodMessage.Status.Expired -> ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus.Expired + } + + return ChatMessageUiState.AutomodMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = Color.Unspecified, + darkBackgroundColor = Color.Unspecified, + textAlpha = textAlpha, + heldMessageId = heldMessageId, + channel = channel, + badges = + badges + .mapIndexed { index, badge -> + BadgeUi( + url = badge.url, + badge = badge, + position = index, + drawableResId = + when (badge.badgeTag) { + "automod/1" -> R.drawable.ic_automod_badge + else -> null + }, + ) + }.toImmutableList(), + userDisplayName = userName.formatWithDisplayName(userDisplayName), + rawNameColor = color ?: Message.DEFAULT_COLOR, + messageText = messageText?.takeIf { it.isNotEmpty() }, + reason = reason, + status = uiStatus, + isUserSide = isUserSide, + ) + } + + private fun PrivMessage.toPrivMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + isMentionTab: Boolean, + isInReplies: Boolean, + textAlpha: Float, + ): ChatMessageUiState.PrivMessageUi { + val backgroundColors = + when { + timedOut && !chatSettings.showTimedOutMessages -> BackgroundColors(Color.Transparent, Color.Transparent) + highlights.isNotEmpty() -> highlights.toBackgroundColors() + else -> calculateCheckeredBackgroundColors(isAlternateBackground, true) + } + + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val nameText = + when { + !chatSettings.showUsernames -> "" + isAction -> "$aliasOrFormattedName " + aliasOrFormattedName.isBlank() -> "" + else -> "$aliasOrFormattedName: " + } + + val allowedBadges = badges.filter { it.type in chatSettings.visibleBadgeTypes } + val badgeUis = + allowedBadges + .mapIndexed { index, badge -> + BadgeUi( + url = badge.url, + badge = badge, + position = index, + ) + }.toImmutableList() + + val emoteUis = + emotes + .groupBy { it.position } + .map { (position, emoteGroup) -> + // Check if any emote in the group is animated - we need to check the type + val hasAnimated = + emoteGroup.any { emote -> + when (emote.type) { + is ChatMessageEmoteType.TwitchEmote -> false + + // Twitch emotes can be animated but we don't have that info here + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + is ChatMessageEmoteType.ChannelBTTVEmote, + is ChatMessageEmoteType.GlobalBTTVEmote, + -> true + + // Assume third-party can be animated + is ChatMessageEmoteType.ChannelSevenTVEmote, + is ChatMessageEmoteType.GlobalSevenTVEmote, + -> true + + is ChatMessageEmoteType.Cheermote -> true + } + } + + val firstEmote = emoteGroup.first() + EmoteUi( + code = firstEmote.code, + urls = emoteGroup.map { it.url }.toImmutableList(), + position = position, + isAnimated = hasAnimated, + isTwitch = emoteGroup.any { it.isTwitch }, + scale = firstEmote.scale, + emotes = emoteGroup.toImmutableList(), + cheerAmount = firstEmote.cheerAmount, + cheerColor = firstEmote.cheerColor?.let { Color(it) }, + ) + }.toImmutableList() + + val threadUi = + if (thread != null && !isInReplies) { + ThreadUi( + rootId = thread.rootId, + userName = thread.name.value, + message = thread.message, + rawNameColor = usersRepository.getCachedUserColor(thread.name) ?: Message.DEFAULT_COLOR, + ) + } else { + null + } + + val highlightHeader = + when { + isGigantifiedEmote -> { + TextResource.Res(R.string.highlight_header_gigantified_emote) + } + + isAnimatedMessage -> { + TextResource.Res(R.string.highlight_header_animated_message) + } + + isElevatedMessage -> { + hypeChatInfo?.let { TextResource.Plain(it) } + ?: TextResource.Res(R.string.highlight_header_elevated_chat) + } + + rewardTitle != null -> { + TextResource.Res(R.string.highlight_header_reward_no_cost, persistentListOf(rewardTitle)) + } + + highlights.highestPriorityHighlight()?.type == HighlightType.FirstMessage -> { + TextResource.Res(R.string.highlight_header_first_time_chat) + } + + else -> { + null + } + } + + val fullMessage = + buildString { + if (isMentionTab && highlights.any { it.isMention }) { + append("#$channel ") + } + if (timestamp.isNotEmpty()) { + append("$timestamp ") + } + append(nameText) + append(message) + } + + val rawNameColor = resolveNameColor(userDisplay?.color, color, userId, chatSettings) + + return ChatMessageUiState.PrivMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + enableRipple = true, + isHighlighted = highlights.isNotEmpty(), + channel = channel, + userId = userId, + userName = name, + displayName = displayName, + badges = badgeUis, + rawNameColor = rawNameColor, + nameText = nameText, + message = message, + emotes = emoteUis, + isAction = isAction, + thread = threadUi, + highlightHeader = highlightHeader, + highlightHeaderImageUrl = rewardImageUrl, + highlightHeaderCost = rewardCost, + highlightHeaderCostSuffix = when { + isGigantifiedEmote || isAnimatedMessage -> "Bits" + else -> null + }, + fullMessage = fullMessage, + ) + } + + private fun PointRedemptionMessage.toPointRedemptionMessageUi( + tag: Int, + chatSettings: ChatSettings, + textAlpha: Float, + ): ChatMessageUiState.PointRedemptionMessageUi { + val backgroundColors = getHighlightColors(HighlightType.ChannelPointRedemption) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val nameText = if (!requiresUserInput) aliasOrFormattedName else null + val nameColor = usersRepository.getCachedUserColor(name) ?: Message.DEFAULT_COLOR + + return ChatMessageUiState.PointRedemptionMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + nameText = nameText, + rawNameColor = nameColor, + title = title, + cost = cost, + rewardImageUrl = rewardImageUrl, + requiresUserInput = requiresUserInput, + ) + } + + private fun WhisperMessage.toWhisperMessageUi( + tag: Int, + chatSettings: ChatSettings, + isAlternateBackground: Boolean, + textAlpha: Float, + currentUserName: UserName?, + ): ChatMessageUiState.WhisperMessageUi { + val backgroundColors = calculateCheckeredBackgroundColors(isAlternateBackground, true) + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime(timestamp, chatSettings.formatter) + } else { + "" + } + + val allowedBadges = badges.filter { it.type in chatSettings.visibleBadgeTypes } + val badgeUis = + allowedBadges + .mapIndexed { index, badge -> + BadgeUi( + url = badge.url, + badge = badge, + position = index, + ) + }.toImmutableList() + + val emoteUis = + emotes + .groupBy { it.position } + .map { (position, emoteGroup) -> + // Check if any emote in the group is animated + val hasAnimated = + emoteGroup.any { emote -> + when (emote.type) { + is ChatMessageEmoteType.TwitchEmote -> false + + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + is ChatMessageEmoteType.ChannelBTTVEmote, + is ChatMessageEmoteType.GlobalBTTVEmote, + -> true + + is ChatMessageEmoteType.ChannelSevenTVEmote, + is ChatMessageEmoteType.GlobalSevenTVEmote, + -> true + + is ChatMessageEmoteType.Cheermote -> true + } + } + + val firstEmote = emoteGroup.first() + EmoteUi( + code = firstEmote.code, + urls = emoteGroup.map { it.url }.toImmutableList(), + position = position, + isAnimated = hasAnimated, + isTwitch = emoteGroup.any { it.isTwitch }, + scale = firstEmote.scale, + emotes = emoteGroup.toImmutableList(), + cheerAmount = firstEmote.cheerAmount, + cheerColor = firstEmote.cheerColor?.let { Color(it) }, + ) + }.toImmutableList() + + val fullMessage = + buildString { + if (timestamp.isNotEmpty()) { + append("$timestamp ") + } + append("$senderAliasOrFormattedName -> $recipientAliasOrFormattedName: ") + append(message) + } + + val rawSenderColor = resolveNameColor(userDisplay?.color, color, userId, chatSettings) + val rawRecipientColor = resolveNameColor(recipientDisplay?.color, recipientColor, recipientId, chatSettings) + + return ChatMessageUiState.WhisperMessageUi( + id = id, + tag = tag, + timestamp = timestamp, + lightBackgroundColor = backgroundColors.light, + darkBackgroundColor = backgroundColors.dark, + textAlpha = textAlpha, + enableRipple = true, + userId = userId ?: error("Whisper must have userId"), + userName = name, + displayName = displayName, + badges = badgeUis, + rawSenderColor = rawSenderColor, + rawRecipientColor = rawRecipientColor, + senderName = senderAliasOrFormattedName, + recipientName = recipientAliasOrFormattedName, + message = message, + emotes = emoteUis, + fullMessage = fullMessage, + replyTargetName = if (currentUserName != null && name.value.equals(currentUserName.value, ignoreCase = true)) recipientName else name, + ) + } + + private fun resolveNameColor( + customColor: Int?, + ircColor: Int?, + userId: UserId?, + chatSettings: ChatSettings, + ): Int = when { + customColor != null -> customColor + ircColor != null -> ircColor + chatSettings.colorizeNicknames && userId != null -> getStableColor(userId) + else -> Message.DEFAULT_COLOR + } + + data class BackgroundColors( + val light: Color, + val dark: Color, + ) + + private fun calculateCheckeredBackgroundColors( + isAlternateBackground: Boolean, + enableCheckered: Boolean, + ): BackgroundColors = if (enableCheckered && isAlternateBackground) { + BackgroundColors(CHECKERED_LIGHT, CHECKERED_DARK) + } else { + BackgroundColors(Color.Transparent, Color.Transparent) + } + + private fun getHighlightColors(type: HighlightType): BackgroundColors = when (type) { + HighlightType.Subscription, + HighlightType.Announcement, + -> { + BackgroundColors( + light = COLOR_SUB_HIGHLIGHT_LIGHT, + dark = COLOR_SUB_HIGHLIGHT_DARK, + ) + } + + HighlightType.WatchStreak -> { + BackgroundColors( + light = COLOR_WATCH_STREAK_HIGHLIGHT_LIGHT, + dark = COLOR_WATCH_STREAK_HIGHLIGHT_DARK, + ) + } + + HighlightType.ChannelPointRedemption -> { + BackgroundColors( + light = COLOR_REDEMPTION_HIGHLIGHT_LIGHT, + dark = COLOR_REDEMPTION_HIGHLIGHT_DARK, + ) + } + + HighlightType.ElevatedMessage -> { + BackgroundColors( + light = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT, + dark = COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK, + ) + } + + HighlightType.FirstMessage -> { + BackgroundColors( + light = COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT, + dark = COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK, + ) + } + + HighlightType.Username, + HighlightType.Custom, + HighlightType.Reply, + HighlightType.Badge, + HighlightType.Notification, + -> { + BackgroundColors( + light = COLOR_MENTION_HIGHLIGHT_LIGHT, + dark = COLOR_MENTION_HIGHLIGHT_DARK, + ) + } + } + + private fun Set.toBackgroundColors(): BackgroundColors { + val highlight = + this.maxByOrNull { it.type.priority.value } + ?: return BackgroundColors(Color.Transparent, Color.Transparent) + + val customColor = highlight.customColor + if (customColor != null && customColor !in DEFAULT_HIGHLIGHT_COLOR_INTS) { + val color = Color(customColor) + return BackgroundColors(color, color) + } + + return getHighlightColors(highlight.type) + } + + companion object { + // Highlight colors - Light theme (all dark enough for white text) + private val COLOR_SUB_HIGHLIGHT_LIGHT = Color(0xFF7E57C2) + private val COLOR_MENTION_HIGHLIGHT_LIGHT = Color(0xFFCF5050) + private val COLOR_REDEMPTION_HIGHLIGHT_LIGHT = Color(0xFF458B93) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFF558B2F) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_LIGHT = Color(0xFFB08D2A) + private val COLOR_WATCH_STREAK_HIGHLIGHT_LIGHT = Color(0xFF2979B7) + + // Highlight colors - Dark theme + private val COLOR_SUB_HIGHLIGHT_DARK = Color(0xFF6A45A0) + private val COLOR_MENTION_HIGHLIGHT_DARK = Color(0xFF8C3A3B) + private val COLOR_REDEMPTION_HIGHLIGHT_DARK = Color(0xFF00606B) + private val COLOR_FIRST_MESSAGE_HIGHLIGHT_DARK = Color(0xFF3A6600) + private val COLOR_ELEVATED_MESSAGE_HIGHLIGHT_DARK = Color(0xFF6B5800) + private val COLOR_WATCH_STREAK_HIGHLIGHT_DARK = Color(0xFF1A5C8A) + + fun defaultHighlightColorInt( + type: HighlightType, + isDark: Boolean, + ): Int = when (type) { + HighlightType.Subscription, HighlightType.Announcement -> if (isDark) 0xFF6A45A0 else 0xFF7E57C2 + HighlightType.WatchStreak -> if (isDark) 0xFF1A5C8A else 0xFF2979B7 + HighlightType.Username, HighlightType.Custom, HighlightType.Reply, HighlightType.Notification, HighlightType.Badge -> if (isDark) 0xFF8C3A3B else 0xFFCF5050 + HighlightType.ChannelPointRedemption -> if (isDark) 0xFF00606B else 0xFF458B93 + HighlightType.FirstMessage -> if (isDark) 0xFF3A6600 else 0xFF558B2F + HighlightType.ElevatedMessage -> if (isDark) 0xFF6B5800 else 0xFFB08D2A + }.toInt() + + private val DEFAULT_HIGHLIGHT_COLOR_INTS = + setOf( + // Current defaults + 0xFF7E57C2.toInt(), // sub light + 0xFF6A45A0.toInt(), // sub dark + 0xFFCF5050.toInt(), // mention light + 0xFF8C3A3B.toInt(), // mention dark + 0xFF458B93.toInt(), // redemption light + 0xFF00606B.toInt(), // redemption dark + 0xFF558B2F.toInt(), // first message light + 0xFF3A6600.toInt(), // first message dark + 0xFFB08D2A.toInt(), // elevated light + 0xFF6B5800.toInt(), // elevated dark + 0xFF2979B7.toInt(), // watch streak light + 0xFF1A5C8A.toInt(), // watch streak dark + // Legacy defaults + 0xFFD1C4E9.toInt(), + 0xFF543589.toInt(), // sub (v1) + 0xFFEF9A9A.toInt(), + 0xFF773031.toInt(), // mention (v1) + 0xFF93F1FF.toInt(), + 0xFF004F57.toInt(), // redemption (v1) + 0xFFC2F18D.toInt(), + 0xFF2D5000.toInt(), // first message (v1) + 0xFFFFE087.toInt(), + 0xFF574500.toInt(), // elevated (v1) + 0xFFB5A0D4.toInt(), + 0xFFE57373.toInt(), // sub/mention (v2 light) + 0xFFA8D8DF.toInt(), + 0xFFAED581.toInt(), + 0xFFEDD59A.toInt(), // redemption/first/elevated (v2 light) + ) + + // Twitch's 15 default username colors + private val TWITCH_USERNAME_COLORS = intArrayOf( + 0xFFFF0000.toInt(), // Red + 0xFF0000FF.toInt(), // Blue + 0xFF00FF00.toInt(), // Green + 0xFFB22222.toInt(), // FireBrick + 0xFFFF7F50.toInt(), // Coral + 0xFF9ACD32.toInt(), // YellowGreen + 0xFFFF4500.toInt(), // OrangeRed + 0xFF2E8B57.toInt(), // SeaGreen + 0xFFDAA520.toInt(), // GoldenRod + 0xFFD2691E.toInt(), // Chocolate + 0xFF5F9EA0.toInt(), // CadetBlue + 0xFF1E90FF.toInt(), // DodgerBlue + 0xFFFF69B4.toInt(), // HotPink + 0xFF8A2BE2.toInt(), // BlueViolet + 0xFF00FF7F.toInt(), // SpringGreen + ) + + private fun getStableColor(userId: UserId): Int { + val colorSeed = userId.value.toIntOrNull() + ?: userId.value.sumOf { it.code } + return TWITCH_USERNAME_COLORS[colorSeed % TWITCH_USERNAME_COLORS.size] + } + + // Checkered background colors — 12% opacity overlay + private const val CHECKERED_ALPHA = (255 * 0.12f).toInt() + private val CHECKERED_LIGHT = Color(android.graphics.Color.argb(CHECKERED_ALPHA, 0, 0, 0)) + private val CHECKERED_DARK = Color(android.graphics.Color.argb(CHECKERED_ALPHA, 255, 255, 255)) + } +} + +private fun ChatMessageUiState.hasSameHighlightBackground(other: ChatMessageUiState?): Boolean = other != null && + other.lightBackgroundColor == lightBackgroundColor && + other.darkBackgroundColor == darkBackgroundColor + +private fun ChatMessageUiState.withLayout( + roundedTopCorners: Boolean, + roundedBottomCorners: Boolean, + showDividerBelow: Boolean, +): ChatMessageUiState = when (this) { + is ChatMessageUiState.PrivMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.SystemMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.NoticeMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.UserNoticeMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.ModerationMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.PointRedemptionMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.DateSeparatorUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.AutomodMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) + is ChatMessageUiState.WhisperMessageUi -> copy(roundedTopCorners = roundedTopCorners, roundedBottomCorners = roundedBottomCorners, showDividerBelow = showDividerBelow) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt new file mode 100644 index 000000000..05500dbcd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatMessageUiState.kt @@ -0,0 +1,251 @@ +package com.flxrs.dankchat.ui.chat + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.badge.Badge +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.data.twitch.message.Message +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +sealed interface ChatMessageUiState { + val id: String + val tag: Int // Used for invalidating/updating messages when emotes/badges change + val timestamp: String + val lightBackgroundColor: Color + val darkBackgroundColor: Color + val textAlpha: Float + val enableRipple: Boolean + val isHighlighted: Boolean + val roundedTopCorners: Boolean + val roundedBottomCorners: Boolean + val showDividerBelow: Boolean + + @Immutable + data class PrivMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean, + override val isHighlighted: Boolean, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val channel: UserName, + val userId: UserId?, + val userName: UserName, + val displayName: DisplayName, + val badges: ImmutableList, + val rawNameColor: Int, + val nameText: String, + val message: String, + val emotes: ImmutableList, + val isAction: Boolean, + val thread: ThreadUi?, + val highlightHeader: TextResource? = null, + val highlightHeaderImageUrl: String? = null, + val highlightHeaderCost: Int? = null, + val highlightHeaderCostSuffix: String? = null, + val fullMessage: String, // For copying + ) : ChatMessageUiState + + @Immutable + data class SystemMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val message: TextResource, + ) : ChatMessageUiState + + @Immutable + data class NoticeMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val message: String, + ) : ChatMessageUiState + + @Immutable + data class UserNoticeMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val message: String, + val displayName: String = "", + val rawNameColor: Int = Message.DEFAULT_COLOR, + val shouldHighlight: Boolean, + ) : ChatMessageUiState + + @Immutable + data class ModerationMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val channel: UserName, + val message: TextResource, + val creatorName: String? = null, + val targetName: String? = null, + val creatorColor: Int = Message.DEFAULT_COLOR, + val targetColor: Int = Message.DEFAULT_COLOR, + val arguments: ImmutableList = persistentListOf(), + ) : ChatMessageUiState + + @Immutable + data class PointRedemptionMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = true, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val nameText: String?, + val rawNameColor: Int, + val title: String, + val cost: Int, + val rewardImageUrl: String, + val requiresUserInput: Boolean, + ) : ChatMessageUiState + + @Immutable + data class DateSeparatorUi( + override val id: String, + override val tag: Int = 0, + override val timestamp: String, + override val lightBackgroundColor: Color = Color.Transparent, + override val darkBackgroundColor: Color = Color.Transparent, + override val textAlpha: Float = 0.5f, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val dateText: String, + ) : ChatMessageUiState + + @Immutable + data class AutomodMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean = false, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val heldMessageId: String, + val channel: UserName, + val badges: ImmutableList, + val userDisplayName: String, + val rawNameColor: Int, + val messageText: String?, + val reason: TextResource, + val status: AutomodMessageStatus, + val isUserSide: Boolean = false, + ) : ChatMessageUiState { + enum class AutomodMessageStatus { Pending, Approved, Denied, Expired } + } + + @Immutable + data class WhisperMessageUi( + override val id: String, + override val tag: Int, + override val timestamp: String, + override val lightBackgroundColor: Color, + override val darkBackgroundColor: Color, + override val textAlpha: Float, + override val enableRipple: Boolean, + override val isHighlighted: Boolean = false, + override val roundedTopCorners: Boolean = false, + override val roundedBottomCorners: Boolean = false, + override val showDividerBelow: Boolean = false, + val userId: UserId, + val userName: UserName, + val displayName: DisplayName, + val badges: ImmutableList, + val rawSenderColor: Int, + val rawRecipientColor: Int, + val senderName: String, + val recipientName: String, + val message: String, + val emotes: ImmutableList, + val fullMessage: String, + val replyTargetName: UserName, + ) : ChatMessageUiState +} + +@Immutable +data class BadgeUi( + val url: String, + val badge: Badge, + val position: Int, // Position in message + val drawableResId: Int? = null, +) + +@Immutable +data class EmoteUi( + val code: String, + val urls: ImmutableList, + val position: IntRange, + val isAnimated: Boolean, + val isTwitch: Boolean, + val scale: Int, + val emotes: ImmutableList, // For click handling + val cheerAmount: Int? = null, + val cheerColor: Color? = null, +) + +@Immutable +data class ThreadUi( + val rootId: String, + val userName: String, + val message: String, + val rawNameColor: Int = Message.DEFAULT_COLOR, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt new file mode 100644 index 000000000..4b69ddfe3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScreen.kt @@ -0,0 +1,799 @@ +package com.flxrs.dankchat.ui.chat + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.composables.core.ScrollArea +import com.composables.core.Thumb +import com.composables.core.VerticalScrollbar +import com.composables.core.rememberScrollAreaState +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.ui.chat.messages.AutomodMessageComposable +import com.flxrs.dankchat.ui.chat.messages.DateSeparatorComposable +import com.flxrs.dankchat.ui.chat.messages.ModerationMessageComposable +import com.flxrs.dankchat.ui.chat.messages.NoticeMessageComposable +import com.flxrs.dankchat.ui.chat.messages.PointRedemptionMessageComposable +import com.flxrs.dankchat.ui.chat.messages.PrivMessageComposable +import com.flxrs.dankchat.ui.chat.messages.SystemMessageComposable +import com.flxrs.dankchat.ui.chat.messages.UserNoticeMessageComposable +import com.flxrs.dankchat.ui.chat.messages.WhisperMessageComposable +import com.flxrs.dankchat.ui.main.input.TourTooltip +import com.flxrs.dankchat.utils.compose.predictiveBackScale +import kotlinx.collections.immutable.ImmutableList + +data class ChatScreenCallbacks( + val onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + val onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + val onEmoteClick: (emotes: List) -> Unit = {}, + val onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit = { _, _ -> }, + val onWhisperReply: ((userName: UserName) -> Unit)? = null, + val onAutomodAllow: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, + val onAutomodDeny: (heldMessageId: String, channel: UserName) -> Unit = { _, _ -> }, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + messages: ImmutableList, + fontSize: Float, + callbacks: ChatScreenCallbacks, + modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, + showChannelPrefix: Boolean = false, + animateGifs: Boolean = true, + showInput: Boolean = true, + isFullscreen: Boolean = false, + onRecover: () -> Unit = {}, + fabMenuCallbacks: FabMenuCallbacks? = null, + contentPadding: PaddingValues = PaddingValues(), + onScrollToBottom: () -> Unit = {}, + onScrollDirectionChange: (isScrollingUp: Boolean) -> Unit = {}, + scrollToMessageId: String? = null, + onScrollToMessageHandle: () -> Unit = {}, + containerColor: Color = MaterialTheme.colorScheme.background, + showFabs: Boolean = true, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, +) { + val listState = rememberLazyListState() + + // Track if we should auto-scroll to bottom (sticky state) + var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } + + // Detect if we're showing the newest messages (with reverseLayout, index 0 = newest). + // Require zero scroll offset so items scrolled into the bottom content padding + // (behind the input bar) don't count as "at bottom". + val isAtBottom by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + } + } + + // Disable auto-scroll when user scrolls up, re-enable when they return to bottom + LaunchedEffect(listState.isScrollInProgress) { + if (listState.lastScrolledForward && shouldAutoScroll) { + shouldAutoScroll = false + } + if (!listState.isScrollInProgress && isAtBottom && !shouldAutoScroll) { + shouldAutoScroll = true + } + onScrollDirectionChange(listState.lastScrolledForward) + } + + // Auto-scroll when new messages arrive or when re-enabled + LaunchedEffect(shouldAutoScroll, messages) { + if (shouldAutoScroll) { + listState.scrollToItem(0) + } + } + + val reversedMessages = remember(messages) { messages.asReversed() } + + // Handle scroll-to-message requests — keyed on both scrollToMessageId and whether messages + // are available, so the scroll retries after ViewModel recreation (which briefly empties messages). + val hasMessages = reversedMessages.isNotEmpty() + val density = LocalDensity.current + LaunchedEffect(scrollToMessageId, hasMessages) { + val targetId = scrollToMessageId ?: return@LaunchedEffect + if (!hasMessages) return@LaunchedEffect + val index = reversedMessages.indexOfFirst { it.id == targetId } + if (index >= 0) { + shouldAutoScroll = false + val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() } + val bottomPaddingPx = with(density) { contentPadding.calculateBottomPadding().roundToPx() } + listState.scrollToCentered(index, topPaddingPx, bottomPaddingPx) + } + onScrollToMessageHandle() + } + + Surface( + modifier = modifier.fillMaxSize(), + color = containerColor, + ) { + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding() + MESSAGE_GAP, + bottom = contentPadding.calculateBottomPadding() + MESSAGE_GAP, + ), + modifier = + Modifier + .fillMaxSize() + .then(scrollModifier), + ) { + itemsIndexed( + items = reversedMessages, + key = { _, message -> message.id }, + contentType = { _, message -> + when (message) { + is ChatMessageUiState.SystemMessageUi -> "system" + is ChatMessageUiState.NoticeMessageUi -> "notice" + is ChatMessageUiState.UserNoticeMessageUi -> "usernotice" + is ChatMessageUiState.ModerationMessageUi -> "moderation" + is ChatMessageUiState.AutomodMessageUi -> "automod" + is ChatMessageUiState.PrivMessageUi -> "privmsg" + is ChatMessageUiState.WhisperMessageUi -> "whisper" + is ChatMessageUiState.PointRedemptionMessageUi -> "redemption" + is ChatMessageUiState.DateSeparatorUi -> "datesep" + } + }, + ) { _, message -> + Box { + ChatMessageItem( + message = message, + highlightShape = message.toHighlightShape(), + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + callbacks = callbacks, + ) + + if (message.showDividerBelow) { + val dividerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + HorizontalDivider( + modifier = Modifier.align(Alignment.BottomCenter), + color = dividerColor, + ) + } + } + } + } + + // FABs at bottom-end with coordinated position animation + if (showFabs) { + val showScrollFab = !shouldAutoScroll && messages.isNotEmpty() + val bottomContentPadding = contentPadding.calculateBottomPadding() + val fabBottomPadding by animateDpAsState( + targetValue = bottomContentPadding, + animationSpec = if (showInput) snap() else spring(), + label = "fabBottomPadding", + ) + val recoveryBottomPadding by animateDpAsState( + targetValue = if (showScrollFab) 56.dp + 12.dp else 0.dp, + label = "recoveryBottomPadding", + ) + var fabMenuExpanded by remember { mutableStateOf(false) } + + // Dismiss scrim + back handler when fab menu is open + if (fabMenuExpanded) { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + fabMenuExpanded = false + }, + ) + } + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp + fabBottomPadding), + contentAlignment = Alignment.BottomEnd, + ) { + RecoveryFabs( + isFullscreen = isFullscreen, + showInput = showInput, + onRecover = onRecover, + fabMenuCallbacks = fabMenuCallbacks, + menuExpanded = fabMenuExpanded, + onMenuExpandedChange = { fabMenuExpanded = it }, + recoveryFabTooltipState = recoveryFabTooltipState, + onTourAdvance = { + onTourAdvance?.invoke() + onRecover() + }, + onTourSkip = { + onTourSkip?.invoke() + onRecover() + }, + modifier = Modifier.padding(bottom = recoveryBottomPadding), + ) + AnimatedVisibility( + visible = showScrollFab, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + FloatingActionButton( + onClick = { + shouldAutoScroll = true + onScrollDirectionChange(false) + onScrollToBottom() + }, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Scroll to bottom", + ) + } + } + } + } + } + } +} + +@Stable +class FabMenuCallbacks( + val onAction: (InputAction) -> Unit, + val onAudioOnly: () -> Unit, + val isStreamActive: Boolean, + val isAudioOnly: Boolean, + val hasStreamData: Boolean, + val isFullscreen: Boolean, + val isModerator: Boolean, + val debugMode: Boolean, + val enabled: Boolean, + val hasLastMessage: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecoveryFabs( + isFullscreen: Boolean, + showInput: Boolean, + onRecover: () -> Unit, + fabMenuCallbacks: FabMenuCallbacks?, + menuExpanded: Boolean, + onMenuExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + recoveryFabTooltipState: TooltipState? = null, + onTourAdvance: (() -> Unit)? = null, + onTourSkip: (() -> Unit)? = null, +) { + val visible = isFullscreen || !showInput + val isTourHighlighted = recoveryFabTooltipState != null + val fabShape = FloatingActionButtonDefaults.smallShape + + val escapeFab: @Composable () -> Unit = { + SmallFloatingActionButton( + onClick = { + onMenuExpandedChange(false) + onTourAdvance?.invoke() + onRecover() + }, + containerColor = when { + isTourHighlighted -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f) + }, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = when { + isTourHighlighted -> Modifier.border(2.dp, MaterialTheme.colorScheme.primary, fabShape) + else -> Modifier + }, + ) { + Icon( + imageVector = Icons.Default.FullscreenExit, + contentDescription = stringResource(R.string.menu_exit_fullscreen), + ) + } + } + + if (recoveryFabTooltipState != null) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + TourTooltip( + text = stringResource(R.string.tour_recovery_fab), + onAction = { onTourAdvance?.invoke() }, + onSkip = { onTourSkip?.invoke() }, + isLast = true, + showCaret = false, + ) + }, + state = recoveryFabTooltipState, + onDismissRequest = {}, + hasAction = true, + modifier = modifier, + ) { + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + ) { + escapeFab() + } + } + } else { + AnimatedVisibility( + visible = visible, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = modifier, + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!showInput && fabMenuCallbacks != null) { + FabMenuToggle( + fabMenuCallbacks = fabMenuCallbacks, + menuExpanded = menuExpanded, + onMenuExpandedChange = onMenuExpandedChange, + ) + } + escapeFab() + } + } + } +} + +@Composable +private fun FabMenuToggle( + fabMenuCallbacks: FabMenuCallbacks, + menuExpanded: Boolean, + onMenuExpandedChange: (Boolean) -> Unit, +) { + AnimatedContent( + targetState = menuExpanded, + transitionSpec = { + (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) + }, + label = "FabMenuToggle", + ) { expanded -> + when { + expanded -> { + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onMenuExpandedChange(false) + } catch (_: Exception) { + backProgress = 0f + } + } + FabActionsMenu( + callbacks = fabMenuCallbacks, + onDismiss = { onMenuExpandedChange(false) }, + modifier = Modifier.predictiveBackScale(backProgress), + ) + } + + else -> { + SmallFloatingActionButton( + onClick = { onMenuExpandedChange(true) }, + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f), + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } + } +} + +@Composable +private fun FabActionsMenu( + callbacks: FabMenuCallbacks, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val windowHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } + val menuMaxHeight = windowHeight * 0.35f + val scrollState = rememberScrollState() + var itemHeightPx by remember { mutableIntStateOf(0) } + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = 4.dp, + modifier = modifier.heightIn(max = menuMaxHeight), + ) { + ScrollArea(state = rememberScrollAreaState(scrollState)) { + Column( + modifier = + Modifier + .width(IntrinsicSize.Max) + .verticalScroll(scrollState), + ) { + var measured = false + for (action in InputAction.entries) { + val item = + getFabMenuItem( + action = action, + isStreamActive = callbacks.isStreamActive, + hasStreamData = callbacks.hasStreamData, + isFullscreen = callbacks.isFullscreen, + isModerator = callbacks.isModerator, + debugMode = callbacks.debugMode, + ) ?: continue + + val actionEnabled = + when (action) { + InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true + InputAction.LastMessage -> callbacks.enabled && callbacks.hasLastMessage + InputAction.Stream, InputAction.ModActions -> callbacks.enabled + } + + val measureModifier = if (!measured) { + measured = true + Modifier.onSizeChanged { itemHeightPx = it.height } + } else { + Modifier + } + + DropdownMenuItem( + text = { Text(stringResource(item.labelRes)) }, + onClick = { + callbacks.onAction(action) + onDismiss() + }, + enabled = actionEnabled, + modifier = measureModifier, + leadingIcon = { + Icon( + imageVector = item.icon, + contentDescription = null, + ) + }, + ) + } + + if (callbacks.isStreamActive) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (callbacks.isAudioOnly) R.string.menu_exit_audio_only else R.string.menu_audio_only, + ), + ) + }, + onClick = { + callbacks.onAudioOnly() + onDismiss() + }, + enabled = callbacks.enabled, + leadingIcon = { + Icon( + imageVector = if (callbacks.isAudioOnly) Icons.Default.Videocam else Icons.Default.Headphones, + contentDescription = null, + ) + }, + ) + } + } + if (scrollState.maxValue > itemHeightPx) { + VerticalScrollbar( + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(3.dp) + .padding(vertical = 2.dp), + ) { + Thumb( + Modifier.background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(100), + ), + ) + } + } + } + } +} + +@Immutable +private data class FabMenuItem( + val labelRes: Int, + val icon: ImageVector, +) + +private fun getFabMenuItem( + action: InputAction, + isStreamActive: Boolean, + hasStreamData: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + debugMode: Boolean, +): FabMenuItem? = when (action) { + InputAction.Search -> { + FabMenuItem(R.string.input_action_search, Icons.Default.Search) + } + + InputAction.LastMessage -> { + null + } + + InputAction.Stream -> { + when { + hasStreamData || isStreamActive -> { + FabMenuItem( + if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, + if (isStreamActive) Icons.Default.VideocamOff else Icons.Default.Videocam, + ) + } + + else -> { + null + } + } + } + + InputAction.ModActions -> { + when { + isModerator -> FabMenuItem(R.string.menu_mod_actions, Icons.Default.Shield) + else -> null + } + } + + InputAction.Fullscreen -> { + FabMenuItem( + if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen, + if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + ) + } + + InputAction.HideInput -> { + FabMenuItem(R.string.menu_show_input, Icons.Default.Visibility) + } + + InputAction.Debug -> { + when { + debugMode -> FabMenuItem(R.string.input_action_debug, Icons.Default.BugReport) + else -> null + } + } +} + +private val MESSAGE_GAP = 4.dp +private val HIGHLIGHT_CORNER_RADIUS = 6.dp + +private fun ChatMessageUiState.toHighlightShape(): Shape { + if (!isHighlighted) return RectangleShape + val top = if (roundedTopCorners) HIGHLIGHT_CORNER_RADIUS else 0.dp + val bottom = if (roundedBottomCorners) HIGHLIGHT_CORNER_RADIUS else 0.dp + return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) +} + +/** + * Renders a single chat message based on its type + */ + +@Composable +private fun ChatMessageItem( + message: ChatMessageUiState, + highlightShape: Shape, + fontSize: Float, + showChannelPrefix: Boolean, + animateGifs: Boolean, + callbacks: ChatScreenCallbacks, +) { + when (message) { + is ChatMessageUiState.SystemMessageUi -> { + SystemMessageComposable( + message = message, + fontSize = fontSize, + ) + } + + is ChatMessageUiState.NoticeMessageUi -> { + NoticeMessageComposable( + message = message, + fontSize = fontSize, + ) + } + + is ChatMessageUiState.UserNoticeMessageUi -> { + UserNoticeMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + ) + } + + is ChatMessageUiState.ModerationMessageUi -> { + ModerationMessageComposable( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + ) + } + + is ChatMessageUiState.AutomodMessageUi -> { + AutomodMessageComposable( + message = message, + fontSize = fontSize, + onAllow = callbacks.onAutomodAllow, + onDeny = callbacks.onAutomodDeny, + ) + } + + is ChatMessageUiState.PrivMessageUi -> { + PrivMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + onUserClick = callbacks.onUserClick, + onMessageLongClick = callbacks.onMessageLongClick, + onEmoteClick = callbacks.onEmoteClick, + onReplyClick = callbacks.onReplyClick, + ) + } + + is ChatMessageUiState.PointRedemptionMessageUi -> { + PointRedemptionMessageComposable( + message = message, + highlightShape = highlightShape, + fontSize = fontSize, + ) + } + + is ChatMessageUiState.DateSeparatorUi -> { + DateSeparatorComposable( + message = message, + fontSize = fontSize, + ) + } + + is ChatMessageUiState.WhisperMessageUi -> { + WhisperMessageComposable( + message = message, + fontSize = fontSize, + animateGifs = animateGifs, + onUserClick = { userId, userName, displayName, badges, isLongPress -> + callbacks.onUserClick(userId, userName, displayName, null, badges, isLongPress) + }, + onMessageLongClick = { messageId, fullMessage -> + callbacks.onMessageLongClick(messageId, null, fullMessage) + }, + onEmoteClick = callbacks.onEmoteClick, + onWhisperReply = callbacks.onWhisperReply, + ) + } + } +} + +/** + * Scrolls so that [index] is vertically centered in the usable viewport area + * (the region between [topPaddingPx] and [bottomPaddingPx]). + * + * Works in two instant steps that coalesce into a single visual frame: + * 1. [scrollToItem] ensures the target item is laid out and measurable. + * 2. Reads the item's actual position, computes the delta needed to center it, + * and applies the correction via [scroll]. + */ +private suspend fun LazyListState.scrollToCentered( + index: Int, + topPaddingPx: Int, + bottomPaddingPx: Int, +) { + scrollToItem(index) + + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return + val viewportHeight = layoutInfo.viewportSize.height + val usableBottom = viewportHeight - bottomPaddingPx + val usableCenter = (topPaddingPx + usableBottom) / 2 + val itemCenter = itemInfo.offset + itemInfo.size / 2 + val delta = (itemCenter - usableCenter).toFloat() + + scroll { scrollBy(delta) } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt new file mode 100644 index 000000000..c8c671cdc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatScrollBehavior.kt @@ -0,0 +1,89 @@ +package com.flxrs.dankchat.ui.chat + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange + +/** + * Observes scroll direction and fires [onHide]/[onShow] when the accumulated + * scroll delta exceeds [hideThresholdPx]/[showThresholdPx]. + * + * With `reverseLayout = true` the nested scroll deltas are inverted: + * `available.y > 0` = finger up = reading old messages = hide toolbar; + * `available.y < 0` = finger down = toward new messages = show toolbar. + * + * Returns [Offset.Zero] — scroll is observed, never consumed. + */ +class ScrollDirectionTracker( + private val hideThresholdPx: Float, + private val showThresholdPx: Float, + private val onHide: () -> Unit, + private val onShow: () -> Unit, +) : NestedScrollConnection { + private var accumulated = 0f + + @Suppress("SameReturnValue") + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + val delta = consumed.y + if (delta == 0f) return Offset.Zero + // Reset accumulator on direction change to avoid stale buildup + when { + accumulated > 0f && delta < 0f -> accumulated = 0f + accumulated < 0f && delta > 0f -> accumulated = 0f + } + accumulated += delta + when { + accumulated > hideThresholdPx -> { + onHide() + accumulated = 0f + } + + accumulated < -showThresholdPx -> { + onShow() + accumulated = 0f + } + } + return Offset.Zero + } +} + +/** + * Detects a cumulative downward drag exceeding [thresholdPx] and calls [onHide]. + * Uses [PointerEventPass.Initial] to observe events before children (text fields, + * buttons) consume them. Events are never consumed so children still work normally. + */ +fun Modifier.swipeDownToHide( + enabled: Boolean, + thresholdPx: Float, + onHide: () -> Unit, +): Modifier { + if (!enabled) return this + return this.pointerInput(true) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial, requireUnconsumed = false) + var totalDragY = 0f + var fired = false + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull() ?: break + if (!change.pressed) break + totalDragY += change.positionChange().y + if (totalDragY > thresholdPx && !fired) { + fired = true + onHide() + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt new file mode 100644 index 000000000..cb6d249c7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/ChatViewModel.kt @@ -0,0 +1,170 @@ +package com.flxrs.dankchat.ui.chat + +import android.util.LruCache +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.helix.HelixApiClient +import com.flxrs.dankchat.data.api.helix.HelixApiException +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettings +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettings +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.utils.DateTimeUtils +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +private val logger = KotlinLogging.logger("ChatViewModel") + +@KoinViewModel +class ChatViewModel( + @InjectedParam private val channel: UserName, + private val chatMessageRepository: ChatMessageRepository, + private val chatMessageMapper: ChatMessageMapper, + private val helixApiClient: HelixApiClient, + private val authDataStore: AuthDataStore, + private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) : ViewModel() { + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + + private val chat: StateFlow> = + chatMessageRepository + .getChat(channel) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), emptyList()) + + // Mapping cache: keyed on "${message.id}-${tag}-${altBg}" to avoid re-mapping unchanged messages + private val mappingCache = LruCache(512) + private val checkeredTracker = CheckeredMessageTracker() + private var lastAppearanceSettings: AppearanceSettings? = null + private var lastChatSettings: ChatSettings? = null + + private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.getDefault()) + + val chatUiStates: StateFlow> = + combine( + chat, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + // Clear cache when settings change (affects all mapped results) + if (appearanceSettings != lastAppearanceSettings || chatSettings != lastChatSettings) { + mappingCache.evictAll() + lastAppearanceSettings = appearanceSettings + lastChatSettings = chatSettings + } + + val zone = ZoneId.systemDefault() + val result = ArrayList(messages.size + 8) + for (index in messages.indices) { + val item = messages[index] + val altBg = checkeredTracker.isAlternate(item.message.id) && appearanceSettings.checkeredMessages + val cacheKey = "${item.message.id}-${item.tag}-$altBg" + + val mapped = + mappingCache[cacheKey] ?: chatMessageMapper + .mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ).also { mappingCache.put(cacheKey, it) } + result += mapped + + // Insert date separator between messages on different days + if (index < messages.lastIndex) { + val currentDay = Instant.ofEpochMilli(item.message.timestamp).atZone(zone).toLocalDate() + val nextDay = Instant.ofEpochMilli(messages[index + 1].message.timestamp).atZone(zone).toLocalDate() + if (currentDay != nextDay) { + val timestamp = + if (chatSettings.showTimestamps) { + DateTimeUtils.timestampToLocalTime( + nextDay + .atTime(LocalTime.MIDNIGHT) + .atZone(zone) + .toInstant() + .toEpochMilli(), + chatSettings.formatter, + ) + } else { + "" + } + result += + ChatMessageUiState.DateSeparatorUi( + id = "date-sep-$nextDay", + timestamp = timestamp, + dateText = nextDay.format(dateFormatter), + ) + } + } + } + + chatMessageMapper + .run { + result.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() + }.flowOn(dispatchersProvider.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), persistentListOf()) + + fun manageAutomodMessage( + heldMessageId: String, + channel: UserName, + allow: Boolean, + ) { + viewModelScope.launch { + val userId = authDataStore.userIdString ?: return@launch + val action = if (allow) "ALLOW" else "DENY" + + helixApiClient + .manageAutomodMessage(userId, heldMessageId, action) + .onFailure { error -> + logger.error(error) { "Failed to $action automod message $heldMessageId" } + val statusCode = (error as? HelixApiException)?.status?.value + chatMessageRepository.addSystemMessage( + channel, + SystemMessageType.AutomodActionFailed(statusCode = statusCode, allow = allow), + ) + } + } + } +} + +@Immutable +data class ChatDisplaySettings( + val fontSize: Float = 14f, + val animateGifs: Boolean = true, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/CheckeredMessageTracker.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/CheckeredMessageTracker.kt new file mode 100644 index 000000000..327856fe1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/CheckeredMessageTracker.kt @@ -0,0 +1,17 @@ +package com.flxrs.dankchat.ui.chat + +/** + * Assigns stable ordinals to messages by ID, so that a message's even/odd + * status never changes when other messages are added or evicted from the list. + * + * Each display context (ViewModel) should use its own tracker instance. + */ +class CheckeredMessageTracker { + private val ordinals = HashMap() + private var nextOrdinal = 0L + + fun isAlternate(id: String): Boolean { + val ordinal = ordinals.getOrPut(id) { nextOrdinal++ } + return ordinal % 2 == 0L + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt new file mode 100644 index 000000000..a270bbebb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteAnimationCoordinator.kt @@ -0,0 +1,48 @@ +package com.flxrs.dankchat.ui.chat.emote + +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.util.LruCache +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf + +@Stable +class EmoteAnimationCoordinator { + private val emoteCache = LruCache(512) + private val layerCache = LruCache(256) + val dimensionCache = LruCache>(1024) + + fun getCached(url: String): Drawable? = emoteCache.get(url) + + fun putInCache( + url: String, + drawable: Drawable, + ) { + emoteCache.put(url, drawable) + } + + fun getLayerCached(cacheKey: String): LayerDrawable? = layerCache.get(cacheKey) + + fun putLayerInCache( + cacheKey: String, + layerDrawable: LayerDrawable, + ) { + layerCache.put(cacheKey, layerDrawable) + } + + fun clear() { + emoteCache.evictAll() + layerCache.evictAll() + dimensionCache.evictAll() + } +} + +val LocalEmoteAnimationCoordinator = + staticCompositionLocalOf { + error("No EmoteAnimationCoordinator provided. Wrap your chat composables with CompositionLocalProvider.") + } + +@Composable +fun rememberEmoteAnimationCoordinator(): EmoteAnimationCoordinator = remember { EmoteAnimationCoordinator() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt new file mode 100644 index 000000000..f88b09786 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteDrawablePainter.kt @@ -0,0 +1,81 @@ +package com.flxrs.dankchat.ui.chat.emote + +import android.graphics.drawable.Drawable +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.LayoutDirection + +@Stable +class EmoteDrawablePainter( + val drawable: Drawable, +) : Painter(), + androidx.compose.runtime.RememberObserver { + private var invalidateTick by mutableIntStateOf(0) + + private val mainHandler = Handler(Looper.getMainLooper()) + + private val callback = + object : Drawable.Callback { + override fun invalidateDrawable(d: Drawable) { + invalidateTick++ + } + + override fun scheduleDrawable( + d: Drawable, + what: Runnable, + time: Long, + ) { + mainHandler.postAtTime(what, time) + } + + override fun unscheduleDrawable( + d: Drawable, + what: Runnable, + ) { + mainHandler.removeCallbacks(what) + } + } + + override val intrinsicSize: Size + get() { + val bounds = drawable.bounds + return if (bounds.width() > 0 && bounds.height() > 0) { + Size(bounds.width().toFloat(), bounds.height().toFloat()) + } else { + Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat()) + } + } + + override fun applyLayoutDirection(layoutDirection: LayoutDirection): Boolean = false + + override fun DrawScope.onDraw() { + // Read invalidateTick to trigger recomposition on animation frames + invalidateTick + drawIntoCanvas { canvas -> + drawable.draw(canvas.nativeCanvas) + } + } + + override fun onRemembered() { + drawable.callback = callback + drawable.setVisible(true, true) + } + + override fun onForgotten() { + drawable.setVisible(false, false) + drawable.callback = null + } + + override fun onAbandoned() { + onForgotten() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt similarity index 82% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetItem.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt index 8d327411f..3b18771e4 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emote/EmoteSheetItem.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoItem.kt @@ -1,9 +1,9 @@ -package com.flxrs.dankchat.chat.emote +package com.flxrs.dankchat.ui.chat.emote import androidx.annotation.StringRes import com.flxrs.dankchat.data.DisplayName -data class EmoteSheetItem( +data class EmoteInfoItem( val id: String, val name: String, val baseName: String?, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt new file mode 100644 index 000000000..9fb2281c9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/EmoteInfoViewModel.kt @@ -0,0 +1,81 @@ +package com.flxrs.dankchat.ui.chat.emote + +import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +import kotlinx.collections.immutable.toImmutableList +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class EmoteInfoViewModel( + @InjectedParam private val emotes: List, +) : ViewModel() { + val items = + emotes + .map { emote -> + EmoteInfoItem( + id = emote.id, + name = emote.code, + imageUrl = emote.url, + baseName = emote.baseNameOrNull(), + creatorName = emote.creatorNameOrNull(), + providerUrl = emote.providerUrlOrNull(), + isZeroWidth = emote.isOverlayEmote, + emoteType = emote.emoteTypeOrNull(), + ) + }.toImmutableList() + + private fun ChatMessageEmote.baseNameOrNull(): String? = when (type) { + is ChatMessageEmoteType.GlobalSevenTVEmote -> type.baseName + is ChatMessageEmoteType.ChannelSevenTVEmote -> type.baseName + else -> null + } + + private fun ChatMessageEmote.creatorNameOrNull(): DisplayName? = when (type) { + is ChatMessageEmoteType.GlobalSevenTVEmote -> type.creator + is ChatMessageEmoteType.ChannelSevenTVEmote -> type.creator + is ChatMessageEmoteType.ChannelBTTVEmote -> type.creator + is ChatMessageEmoteType.ChannelFFZEmote -> type.creator + is ChatMessageEmoteType.GlobalFFZEmote -> type.creator + else -> null + } + + private fun ChatMessageEmote.providerUrlOrNull(): String = when (type) { + is ChatMessageEmoteType.GlobalSevenTVEmote, + is ChatMessageEmoteType.ChannelSevenTVEmote, + -> "$SEVEN_TV_BASE_LINK$id" + + is ChatMessageEmoteType.ChannelBTTVEmote, + is ChatMessageEmoteType.GlobalBTTVEmote, + -> "$BTTV_BASE_LINK$id" + + is ChatMessageEmoteType.ChannelFFZEmote, + is ChatMessageEmoteType.GlobalFFZEmote, + -> "$FFZ_BASE_LINK$id-$code" + + is ChatMessageEmoteType.TwitchEmote -> "$TWITCH_BASE_LINK$id" + + is ChatMessageEmoteType.Cheermote -> "$TWITCH_BASE_LINK$id" + } + + private fun ChatMessageEmote.emoteTypeOrNull(): Int = when (type) { + is ChatMessageEmoteType.ChannelBTTVEmote -> if (type.isShared) R.string.emote_sheet_bttv_shared_emote else R.string.emote_sheet_bttv_channel_emote + is ChatMessageEmoteType.ChannelFFZEmote -> R.string.emote_sheet_ffz_channel_emote + is ChatMessageEmoteType.ChannelSevenTVEmote -> R.string.emote_sheet_seventv_channel_emote + ChatMessageEmoteType.GlobalBTTVEmote -> R.string.emote_sheet_bttv_global_emote + is ChatMessageEmoteType.GlobalFFZEmote -> R.string.emote_sheet_ffz_global_emote + is ChatMessageEmoteType.GlobalSevenTVEmote -> R.string.emote_sheet_seventv_global_emote + ChatMessageEmoteType.TwitchEmote -> R.string.emote_sheet_twitch_emote + ChatMessageEmoteType.Cheermote -> R.string.emote_sheet_twitch_emote + } + + companion object { + private const val SEVEN_TV_BASE_LINK = "https://7tv.app/emotes/" + private const val FFZ_BASE_LINK = "https://www.frankerfacez.com/emoticon/" + private const val BTTV_BASE_LINK = "https://betterttv.com/emotes/" + private const val TWITCH_BASE_LINK = "https://chatvau.lt/emote/twitch/" + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt new file mode 100644 index 000000000..5e6a3e532 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emote/StackedEmote.kt @@ -0,0 +1,280 @@ +package com.flxrs.dankchat.ui.chat.emote + +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.asDrawable +import coil3.compose.LocalPlatformContext +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.size.Size +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.EmoteUi +import com.flxrs.dankchat.utils.extensions.forEachLayer +import com.flxrs.dankchat.utils.extensions.setRunning +import kotlin.math.roundToInt + +private const val BASE_HEIGHT_CONSTANT = 1.173 +private const val SCALE_FACTOR_CONSTANT = 1.5 / 112 + +fun emoteBaseHeight(fontSizeSp: Float): Dp = (fontSizeSp * BASE_HEIGHT_CONSTANT).dp + +internal fun emoteScaleFactor(baseHeightPx: Int): Double = baseHeightPx * SCALE_FACTOR_CONSTANT + +@Composable +fun StackedEmote( + emote: EmoteUi, + fontSize: Float, + emoteCoordinator: EmoteAnimationCoordinator, + modifier: Modifier = Modifier, + animateGifs: Boolean = true, + alpha: Float = 1f, + onClick: () -> Unit = {}, +) { + val context = LocalPlatformContext.current + val density = LocalDensity.current + val baseHeight = emoteBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + val scaleFactor = emoteScaleFactor(baseHeightPx) + + // For single emote, render directly without LayerDrawable + if (emote.urls.size == 1 && emote.emotes.isNotEmpty()) { + SingleEmoteDrawable( + url = emote.urls.first(), + chatEmote = emote.emotes.first(), + scaleFactor = scaleFactor, + emoteCoordinator = emoteCoordinator, + animateGifs = animateGifs, + alpha = alpha, + modifier = modifier, + onClick = onClick, + ) + return + } + + // For stacked emotes, create cache key matching old implementation + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + + // Estimate placeholder size from dimension cache or from base height + val cachedDims = emoteCoordinator.dimensionCache.get(cacheKey) + val estimatedHeightPx = cachedDims?.second ?: (baseHeightPx * (emote.emotes.firstOrNull()?.scale ?: 1)) + val estimatedWidthPx = cachedDims?.first ?: estimatedHeightPx + + // Load or create LayerDrawable asynchronously + val layerDrawableState = + produceState(initialValue = null, key1 = cacheKey) { + // Check cache first + val cached = emoteCoordinator.getLayerCached(cacheKey) + if (cached != null) { + value = cached + // Control animation + cached.forEachLayer { it.setRunning(animateGifs) } + } else { + // Load all drawables + val drawables = + emote.urls + .mapIndexedNotNull { idx, url -> + val emoteData = emote.emotes.getOrNull(idx) ?: emote.emotes.first() + try { + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .build() + val result = context.imageLoader.execute(request) + result.image?.asDrawable(context.resources)?.let { drawable -> + transformEmoteDrawable(drawable, scaleFactor, emoteData) + } + } catch (_: Exception) { + null + } + }.toTypedArray() + + if (drawables.isNotEmpty()) { + val layerDrawable = drawables.toLayerDrawable(scaleFactor, emote.emotes) + emoteCoordinator.putLayerInCache(cacheKey, layerDrawable) + // Store dimensions for future placeholder sizing + emoteCoordinator.dimensionCache.put( + cacheKey, + layerDrawable.bounds.width() to layerDrawable.bounds.height(), + ) + value = layerDrawable + // Control animation + layerDrawable.forEachLayer { it.setRunning(animateGifs) } + } + } + } + + // Update animation state when setting changes + LaunchedEffect(animateGifs, layerDrawableState.value) { + layerDrawableState.value?.forEachLayer { it.setRunning(animateGifs) } + } + + val layerDrawable = layerDrawableState.value + if (layerDrawable != null) { + // Render with actual dimensions + val widthDp = with(density) { layerDrawable.bounds.width().toDp() } + val heightDp = with(density) { layerDrawable.bounds.height().toDp() } + val painter = remember(layerDrawable) { EmoteDrawablePainter(layerDrawable) } + + Image( + painter = painter, + contentDescription = null, + alpha = alpha, + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, + ) + } else { + // Placeholder with estimated size to prevent layout shift + val widthDp = with(density) { estimatedWidthPx.toDp() } + val heightDp = with(density) { estimatedHeightPx.toDp() } + Box( + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, + ) + } +} + +@Composable +private fun SingleEmoteDrawable( + url: String, + chatEmote: ChatMessageEmote, + scaleFactor: Double, + emoteCoordinator: EmoteAnimationCoordinator, + animateGifs: Boolean, + modifier: Modifier = Modifier, + alpha: Float = 1f, + onClick: () -> Unit = {}, +) { + val context = LocalPlatformContext.current + val density = LocalDensity.current + + // Use dimension cache for instant placeholder sizing on repeat views + val cachedDims = emoteCoordinator.dimensionCache.get(url) + + // Load drawable asynchronously + val drawableState = + produceState(initialValue = null, key1 = url) { + // Fast path: check cache first + val cached = emoteCoordinator.getCached(url) + if (cached != null) { + value = cached + } else { + try { + val request = + ImageRequest + .Builder(context) + .data(url) + .size(Size.ORIGINAL) + .build() + val result = context.imageLoader.execute(request) + result.image?.asDrawable(context.resources)?.let { drawable -> + // Transform and cache + val transformed = transformEmoteDrawable(drawable, scaleFactor, chatEmote) + emoteCoordinator.putInCache(url, transformed) + // Store dimensions for future placeholder sizing + emoteCoordinator.dimensionCache.put( + url, + transformed.bounds.width() to transformed.bounds.height(), + ) + value = transformed + } + } catch (_: Exception) { + // Ignore errors + } + } + } + + // Update animation state when setting changes + LaunchedEffect(animateGifs, drawableState.value) { + if (drawableState.value is Animatable) { + (drawableState.value as Animatable).setRunning(animateGifs) + } + } + + val drawable = drawableState.value + if (drawable != null) { + // Render with actual dimensions + val widthDp = with(density) { drawable.bounds.width().toDp() } + val heightDp = with(density) { drawable.bounds.height().toDp() } + val painter = remember(drawable) { EmoteDrawablePainter(drawable) } + + Image( + painter = painter, + contentDescription = null, + alpha = alpha, + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, + ) + } else if (cachedDims != null) { + // Placeholder with cached size to prevent layout shift + val widthDp = with(density) { cachedDims.first.toDp() } + val heightDp = with(density) { cachedDims.second.toDp() } + Box( + modifier = + modifier + .size(width = widthDp, height = heightDp) + .clickable { onClick() }, + ) + } +} + +private fun transformEmoteDrawable( + drawable: Drawable, + scale: Double, + emote: ChatMessageEmote, + maxWidth: Int = 0, + maxHeight: Int = 0, +): Drawable { + val ratio = drawable.intrinsicWidth / drawable.intrinsicHeight.toFloat() + val height = + when { + drawable.intrinsicHeight < 55 && emote.isTwitch -> (70 * scale).roundToInt() + drawable.intrinsicHeight in 55..111 && emote.isTwitch -> (112 * scale).roundToInt() + else -> (drawable.intrinsicHeight * scale).roundToInt() + } + val width = (height * ratio).roundToInt() + + val scaledWidth = width * emote.scale + val scaledHeight = height * emote.scale + + val left = if (maxWidth > 0) (maxWidth - scaledWidth).div(2).coerceAtLeast(0) else 0 + val top = (maxHeight - scaledHeight).coerceAtLeast(0) + + drawable.setBounds(left, top, scaledWidth + left, scaledHeight + top) + return drawable +} + +private fun Array.toLayerDrawable( + scaleFactor: Double, + emotes: List, +): LayerDrawable = LayerDrawable(this).apply { + val bounds = this@toLayerDrawable.map { it.bounds } + val maxWidth = bounds.maxOf { it.width() } + val maxHeight = bounds.maxOf { it.height() } + setBounds(0, 0, maxWidth, maxHeight) + + // Phase 2: Re-adjust bounds with maxWidth/maxHeight + forEachIndexed { idx, dr -> + transformEmoteDrawable(dr, scaleFactor, emotes[idx], maxWidth, maxHeight) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt new file mode 100644 index 000000000..f1e6578df --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteItem.kt @@ -0,0 +1,30 @@ +package com.flxrs.dankchat.ui.chat.emotemenu + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.twitch.emote.GenericEmote + +@Immutable +sealed class EmoteItem { + data class Emote( + val emote: GenericEmote, + ) : EmoteItem(), + Comparable { + override fun compareTo(other: Emote): Int = when (val byType = emote.emoteType.compareTo(other.emote.emoteType)) { + 0 -> other.emote.code.compareTo(other.emote.code) + else -> byType + } + } + + data class Header( + val title: String, + ) : EmoteItem() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return javaClass == other?.javaClass + } + + override fun hashCode(): Int = javaClass.hashCode() + + operator fun plus(list: List): List = listOf(this) + list +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt new file mode 100644 index 000000000..744c1380c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenu.kt @@ -0,0 +1,235 @@ +package com.flxrs.dankchat.ui.chat.emotemenu + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.ui.main.sheet.EmoteMenuViewModel +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteMenu( + onEmoteClick: (String, String) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EmoteMenuViewModel = koinViewModel(), +) { + val tabItems by viewModel.emoteTabItems.collectAsStateWithLifecycle() + val selectedTabIndex by viewModel.selectedTabIndex.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val pagerState = + rememberPagerState( + initialPage = selectedTabIndex, + pageCount = { tabItems.size }, + ) + + LaunchedEffect(pagerState.currentPage) { + viewModel.selectTab(pagerState.currentPage) + } + val subsGridState = rememberLazyGridState() + val subsFirstHeader = + tabItems + .getOrNull(EmoteMenuTab.SUBS.ordinal) + ?.items + ?.firstOrNull() + ?.let { (it as? EmoteItem.Header)?.title } + + LaunchedEffect(subsFirstHeader) { + subsGridState.scrollToItem(0) + } + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + tabItems.forEachIndexed { index, tabItem -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { + Text( + text = + when (tabItem.type) { + EmoteMenuTab.RECENT -> stringResource(R.string.emote_menu_tab_recent) + EmoteMenuTab.SUBS -> stringResource(R.string.emote_menu_tab_subs) + EmoteMenuTab.CHANNEL -> stringResource(R.string.emote_menu_tab_channel) + EmoteMenuTab.GLOBAL -> stringResource(R.string.emote_menu_tab_global) + }, + ) + }, + ) + } + } + + val navBarBottom = WindowInsets.navigationBars.getBottom(LocalDensity.current) + val navBarBottomDp = with(LocalDensity.current) { navBarBottom.toDp() } + + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1, + ) { page -> + val tab = tabItems[page] + EmoteGridPage( + tab = tab, + subsGridState = subsGridState, + navBarBottomDp = navBarBottomDp, + onEmoteClick = onEmoteClick, + ) + } + + // Floating backspace button at bottom-end, matching keyboard position + IconButton( + onClick = onBackspace, + colors = + IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 8.dp, bottom = 8.dp + navBarBottomDp) + .size(48.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Backspace, + contentDescription = stringResource(R.string.backspace), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@Composable +private fun EmoteGridPage( + tab: EmoteMenuTabItem, + subsGridState: LazyGridState, + navBarBottomDp: Dp, + onEmoteClick: (code: String, id: String) -> Unit, +) { + val items = tab.items + + if (tab.type == EmoteMenuTab.RECENT && items.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + DankBackground(visible = true) + Text( + text = stringResource(R.string.no_recent_emotes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 160.dp), + ) + } + } else { + val gridState = + when (tab.type) { + EmoteMenuTab.SUBS -> subsGridState + else -> rememberLazyGridState() + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 40.dp), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 56.dp + navBarBottomDp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = items, + key = { item -> + when (item) { + is EmoteItem.Emote -> "emote-${item.emote.id}-${item.emote.code}" + is EmoteItem.Header -> "header-${item.title}" + } + }, + span = { item -> + when (item) { + is EmoteItem.Header -> GridItemSpan(maxLineSpan) + is EmoteItem.Emote -> GridItemSpan(1) + } + }, + contentType = { item -> + when (item) { + is EmoteItem.Header -> "header" + is EmoteItem.Emote -> "emote" + } + }, + ) { item -> + when (item) { + is EmoteItem.Header -> { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + + is EmoteItem.Emote -> { + AsyncImage( + model = item.emote.url, + contentDescription = item.emote.code, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable { onEmoteClick(item.emote.code, item.emote.id) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTab.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt similarity index 52% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTab.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt index a52ab56ee..d25ade537 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/emotemenu/EmoteMenuTab.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTab.kt @@ -1,8 +1,8 @@ -package com.flxrs.dankchat.chat.emotemenu +package com.flxrs.dankchat.ui.chat.emotemenu enum class EmoteMenuTab { RECENT, SUBS, CHANNEL, - GLOBAL + GLOBAL, } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt new file mode 100644 index 000000000..6c16e68ca --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/emotemenu/EmoteMenuTabItem.kt @@ -0,0 +1,9 @@ +package com.flxrs.dankchat.ui.chat.emotemenu + +import androidx.compose.runtime.Immutable + +@Immutable +data class EmoteMenuTabItem( + val type: EmoteMenuTab, + val items: List, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/HistoryChannel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/HistoryChannel.kt new file mode 100644 index 000000000..407afff0b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/HistoryChannel.kt @@ -0,0 +1,13 @@ +package com.flxrs.dankchat.ui.chat.history + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.UserName + +@Immutable +sealed interface HistoryChannel { + data object Global : HistoryChannel + + data class Channel( + val name: UserName, + ) : HistoryChannel +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt new file mode 100644 index 000000000..f7790bb2b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/history/MessageHistoryViewModel.kt @@ -0,0 +1,225 @@ +package com.flxrs.dankchat.ui.chat.history + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.ui.chat.ChatDisplaySettings +import com.flxrs.dankchat.ui.chat.ChatMessageMapper +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.CheckeredMessageTracker +import com.flxrs.dankchat.ui.chat.search.ChatItemFilter +import com.flxrs.dankchat.ui.chat.search.ChatSearchFilter +import com.flxrs.dankchat.ui.chat.search.ChatSearchFilterParser +import com.flxrs.dankchat.ui.chat.search.SearchFilterSuggestions +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@OptIn(ExperimentalCoroutinesApi::class) +@KoinViewModel +class MessageHistoryViewModel( + @InjectedParam private val initialChannel: HistoryChannel, + private val chatMessageRepository: ChatMessageRepository, + usersRepository: UsersRepository, + private val chatMessageMapper: ChatMessageMapper, + private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) : ViewModel() { + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + + private val checkeredTracker = CheckeredMessageTracker() + private val _selectedChannel = MutableStateFlow(initialChannel) + val selectedChannel: StateFlow = _selectedChannel + + val availableChannels: StateFlow> = + chatMessageRepository.channels + .map { channels -> + buildList { + add(HistoryChannel.Global) + channels.forEach { add(HistoryChannel.Channel(it)) } + }.toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf(HistoryChannel.Global)) + + val isGlobal: StateFlow = + _selectedChannel + .map { it is HistoryChannel.Global } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialChannel is HistoryChannel.Global) + + fun selectChannel(channel: HistoryChannel) { + _selectedChannel.value = channel + } + + val searchFieldState = TextFieldState() + + private val searchQuery = + snapshotFlow { searchFieldState.text.toString() } + .distinctUntilChanged() + + private val filters: Flow> = + merge( + searchQuery.take(1), + searchQuery.drop(1).debounce(300), + ).map { ChatSearchFilterParser.parse(it) } + .distinctUntilChanged() + + private val messagesFlow: Flow> = + _selectedChannel.flatMapLatest { channel -> + when (channel) { + is HistoryChannel.Global -> chatMessageRepository.getAllChat() + is HistoryChannel.Channel -> chatMessageRepository.getChat(channel.name) + } + } + + val historyUiStates: Flow> = + combine( + messagesFlow, + filters, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, activeFilters, appearanceSettings, chatSettings -> + chatMessageMapper + .run { + messages + .filter { it.message !is SystemMessage } + .filter { ChatItemFilter.matches(it, activeFilters) } + .map { item -> + val altBg = checkeredTracker.isAlternate(item.message.id) && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() + }.flowOn(dispatchersProvider.default) + + private val users: StateFlow> = + _selectedChannel + .flatMapLatest { channel -> + when (channel) { + is HistoryChannel.Channel -> usersRepository.getUsersFlow(channel.name).map { it.toSet() } + + is HistoryChannel.Global -> chatMessageRepository.channels.flatMapLatest { channels -> + when { + channels.isEmpty() -> flowOf(emptySet()) + + else -> combine(channels.map { usersRepository.getUsersFlow(it) }) { arrays -> + arrays.flatMap { it }.toSet() + } + } + } + } + }.map { it.toImmutableSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) + + private val badgeNames: StateFlow> = + messagesFlow + .map { items -> + items + .asSequence() + .map { it.message } + .filterIsInstance() + .flatMap { it.badges } + .mapNotNull { it.badgeTag?.substringBefore('/') } + .toImmutableSet() + }.flowOn(dispatchersProvider.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentSetOf()) + + val filterSuggestions: StateFlow> = + combine( + searchQuery, + users, + badgeNames, + ) { query, userSet, badges -> + SearchFilterSuggestions.filter(query, users = userSet, badgeNames = badges).toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + fun setInitialQuery(query: String) { + if (query.isNotEmpty()) { + val normalizedQuery = if (query.endsWith(' ')) query else "$query " + searchFieldState.edit { + replace(0, length, normalizedQuery) + placeCursorAtEnd() + } + } else { + searchFieldState.clearText() + } + } + + fun insertText(text: String) { + searchFieldState.edit { + append(text) + placeCursorAtEnd() + } + } + + fun applySuggestion(suggestion: Suggestion) { + val currentText = searchFieldState.text.toString() + val lastSpaceIndex = currentText.trimEnd().lastIndexOf(' ') + val prefix = + when { + lastSpaceIndex >= 0 -> currentText.substring(0, lastSpaceIndex + 1) + else -> "" + } + val keyword = suggestion.toString() + val suffix = + when { + keyword.endsWith(':') -> "" + else -> " " + } + val newText = prefix + keyword + suffix + searchFieldState.edit { + replace(0, length, newText) + selection = TextRange(newText.length) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt new file mode 100644 index 000000000..71b000949 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionComposable.kt @@ -0,0 +1,63 @@ +package com.flxrs.dankchat.ui.chat.mention + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatScreen +import com.flxrs.dankchat.ui.chat.ChatScreenCallbacks +import kotlinx.collections.immutable.persistentListOf + +/** + * Standalone composable for mentions/whispers display. + * Extracted from MentionChatFragment to enable pure Compose integration. + * + * This composable: + * - Collects mentions or whispers from MentionViewModel based on isWhisperTab + * - Collects appearance settings + * - Renders ChatScreen with channel prefix for mentions only + */ +@Composable +fun MentionComposable( + mentionViewModel: MentionViewModel, + isWhisperTab: Boolean, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, + containerColor: Color, + modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, + onWhisperReply: ((userName: UserName) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(), + onScrollToBottom: () -> Unit = {}, +) { + val displaySettings by mentionViewModel.chatDisplaySettings.collectAsStateWithLifecycle() + val messages by when { + isWhisperTab -> mentionViewModel.whispersUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) + else -> mentionViewModel.mentionsUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) + } + + ChatScreen( + messages = messages, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (isWhisperTab) onWhisperReply else null, + ), + animateGifs = displaySettings.animateGifs, + showChannelPrefix = !isWhisperTab, + modifier = modifier, + contentPadding = contentPadding, + scrollModifier = scrollModifier, + containerColor = containerColor, + onScrollToBottom = onScrollToBottom, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt new file mode 100644 index 000000000..35e10eec9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/mention/MentionViewModel.kt @@ -0,0 +1,128 @@ +package com.flxrs.dankchat.ui.chat.mention + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.ui.chat.ChatDisplaySettings +import com.flxrs.dankchat.ui.chat.ChatMessageMapper +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.CheckeredMessageTracker +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class MentionViewModel( + private val chatNotificationRepository: ChatNotificationRepository, + private val chatMessageMapper: ChatMessageMapper, + private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) : ViewModel() { + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + + private val _currentTab = MutableStateFlow(0) + val currentTab: StateFlow = _currentTab + + val whisperMentionCount: StateFlow = + chatNotificationRepository.channelMentionCount + .map { it[WhisperMessage.WHISPER_CHANNEL] ?: 0 } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + fun setCurrentTab(index: Int) { + _currentTab.value = index + } + + fun clearWhisperMentionCount() { + chatNotificationRepository.clearMentionCount(WhisperMessage.WHISPER_CHANNEL) + } + + val mentions: StateFlow> = + chatNotificationRepository.mentions + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), persistentListOf()) + val whispers: StateFlow> = + chatNotificationRepository.whispers + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), persistentListOf()) + + private val mentionCheckered = CheckeredMessageTracker() + private val whisperCheckered = CheckeredMessageTracker() + + init { + combine(whispers, currentTab) { _, tab -> tab } + .filter { it == 1 } + .onEach { clearWhisperMentionCount() } + .launchIn(viewModelScope) + } + + val mentionsUiStates: Flow> = + combine( + mentions, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + chatMessageMapper + .run { + messages + .map { item -> + val altBg = mentionCheckered.isAlternate(item.message.id) && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() + }.flowOn(dispatchersProvider.default) + + val whispersUiStates: Flow> = + combine( + whispers, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { messages, appearanceSettings, chatSettings -> + chatMessageMapper + .run { + messages + .map { item -> + val altBg = whisperCheckered.isAlternate(item.message.id) && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() + }.flowOn(dispatchersProvider.default) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt new file mode 100644 index 000000000..a486c8669 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsParams.kt @@ -0,0 +1,13 @@ +package com.flxrs.dankchat.ui.chat.message + +import com.flxrs.dankchat.data.UserName + +data class MessageOptionsParams( + val messageId: String, + val channel: UserName?, + val fullMessage: String, + val canModerate: Boolean, + val canReply: Boolean, + val canCopy: Boolean = true, + val canJump: Boolean = false, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsState.kt new file mode 100644 index 000000000..17164a12a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsState.kt @@ -0,0 +1,24 @@ +package com.flxrs.dankchat.ui.chat.message + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.UserName + +@Immutable +sealed interface MessageOptionsState { + data object Loading : MessageOptionsState + + data object NotFound : MessageOptionsState + + data class Found( + val messageId: String, + val rootThreadId: String, + val rootThreadName: UserName?, + val rootThreadMessage: String?, + val replyName: UserName, + val name: UserName, + val originalMessage: String, + val canModerate: Boolean, + val hasReplyThread: Boolean, + val canReply: Boolean, + ) : MessageOptionsState +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt new file mode 100644 index 000000000..de2494de6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/message/MessageOptionsViewModel.kt @@ -0,0 +1,129 @@ +package com.flxrs.dankchat.ui.chat.message + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.RepliesRepository +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatConnector +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.command.CommandRepository +import com.flxrs.dankchat.data.repo.command.CommandResult +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class MessageOptionsViewModel( + @InjectedParam private val messageId: String, + @InjectedParam private val channel: UserName?, + @InjectedParam private val canModerateParam: Boolean, + @InjectedParam private val canReplyParam: Boolean, + private val chatRepository: ChatRepository, + private val channelRepository: ChannelRepository, + private val userStateRepository: UserStateRepository, + private val commandRepository: CommandRepository, + private val repliesRepository: RepliesRepository, + chatMessageRepository: ChatMessageRepository, + chatConnector: ChatConnector, + chatNotificationRepository: ChatNotificationRepository, +) : ViewModel() { + private val messageFlow = flowOf(chatMessageRepository.findMessage(messageId, channel, chatNotificationRepository.whispers)) + private val connectionStateFlow = chatConnector.getConnectionState(channel ?: WhisperMessage.WHISPER_CHANNEL) + + val state: StateFlow = + combine( + userStateRepository.userState, + connectionStateFlow, + messageFlow, + ) { userState, connectionState, message -> + when (message) { + null -> { + MessageOptionsState.NotFound + } + + else -> { + val asPrivMessage = message as? PrivMessage + val asWhisperMessage = message as? WhisperMessage + val thread = asPrivMessage?.thread + val rootId = thread?.rootId + val name = asPrivMessage?.name ?: asWhisperMessage?.name ?: return@combine MessageOptionsState.NotFound + val originalMessage = asPrivMessage?.originalMessage ?: asWhisperMessage?.originalMessage + MessageOptionsState.Found( + messageId = message.id, + rootThreadId = rootId ?: message.id, + rootThreadName = thread?.name, + rootThreadMessage = thread?.message, + replyName = name, + name = name, + originalMessage = originalMessage.orEmpty(), + canModerate = canModerateParam && channel != null && channel in userState.moderationChannels, + hasReplyThread = canReplyParam && rootId != null && repliesRepository.hasMessageThread(rootId), + canReply = connectionState == ConnectionState.CONNECTED && canReplyParam, + ) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MessageOptionsState.Loading) + + fun timeoutUser(index: Int) = viewModelScope.launch { + val duration = TIMEOUT_MAP[index] ?: return@launch + val name = (state.value as? MessageOptionsState.Found)?.name ?: return@launch + sendCommand(".timeout $name $duration") + } + + fun banUser() = viewModelScope.launch { + val name = (state.value as? MessageOptionsState.Found)?.name ?: return@launch + sendCommand(".ban $name") + } + + fun unbanUser() = viewModelScope.launch { + val name = (state.value as? MessageOptionsState.Found)?.name ?: return@launch + sendCommand(".unban $name") + } + + fun deleteMessage() = viewModelScope.launch { + sendCommand(".delete $messageId") + } + + private suspend fun sendCommand(message: String) { + val activeChannel = channel ?: return + val roomState = channelRepository.getRoomState(activeChannel) ?: return + val userState = userStateRepository.userState.value + val result = + runCatching { + commandRepository.checkForCommands(message, activeChannel, roomState, userState) + }.getOrNull() ?: return + + when (result) { + is CommandResult.IrcCommand -> chatRepository.sendMessage(message, forceIrc = true) + is CommandResult.AcceptedTwitchCommand -> result.response?.let { chatRepository.makeAndPostCustomSystemMessage(it, activeChannel) } + else -> Unit + } + } + + companion object { + private val TIMEOUT_MAP = + mapOf( + 0 to "1", + 1 to "30", + 2 to "60", + 3 to "300", + 4 to "600", + 5 to "1800", + 6 to "3600", + 7 to "86400", + 8 to "604800", + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt new file mode 100644 index 000000000..9c2c261b7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/AutomodMessage.kt @@ -0,0 +1,282 @@ +package com.flxrs.dankchat.ui.chat.messages + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.ChatMessageUiState.AutomodMessageUi.AutomodMessageStatus +import com.flxrs.dankchat.ui.chat.emote.emoteBaseHeight +import com.flxrs.dankchat.ui.chat.messages.common.BadgeInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.EmoteDimensions +import com.flxrs.dankchat.ui.chat.messages.common.TextWithMeasuredInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.appendInlineSpacer +import com.flxrs.dankchat.ui.chat.messages.common.rememberNormalizedColor +import com.flxrs.dankchat.utils.resolve +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap + +private val AutoModBlue = Color(0xFF448AFF) + +private const val ALLOW_TAG = "ALLOW" +private const val DENY_TAG = "DENY" + +@Composable +fun AutomodMessageComposable( + message: ChatMessageUiState.AutomodMessageUi, + fontSize: Float, + onAllow: (heldMessageId: String, channel: UserName) -> Unit, + onDeny: (heldMessageId: String, channel: UserName) -> Unit, + modifier: Modifier = Modifier, +) { + val textColor = MaterialTheme.colorScheme.onSurface + val timestampColor = MaterialTheme.colorScheme.onSurface + val allowColor = MaterialTheme.colorScheme.primary + val denyColor = MaterialTheme.colorScheme.error + val textSize = fontSize.sp + val isPending = message.status == AutomodMessageStatus.Pending + val backgroundColor = MaterialTheme.colorScheme.background + val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) + + // Resolve strings + val headerText = stringResource(R.string.automod_header, message.reason.resolve()) + val allowText = stringResource(R.string.automod_allow) + val denyText = stringResource(R.string.automod_deny) + val approvedText = stringResource(R.string.automod_status_approved) + val deniedText = stringResource(R.string.automod_status_denied) + val expiredText = stringResource(R.string.automod_status_expired) + val userHeldText = stringResource(R.string.automod_user_held) + val userAcceptedText = stringResource(R.string.automod_user_accepted) + val userDeniedText = stringResource(R.string.automod_user_denied) + + // Header line: [badge] "AutoMod: ..." + val headerString = + remember( + message, + textColor, + timestampColor, + allowColor, + denyColor, + textSize, + headerText, + allowText, + denyText, + approvedText, + deniedText, + expiredText, + userHeldText, + userAcceptedText, + userDeniedText, + ) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = textSize * 0.95f, + color = timestampColor, + letterSpacing = (-0.03).em, + ), + ) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Badges + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") + } + + // "AutoMod: " in blue bold + withStyle(SpanStyle(color = AutoModBlue, fontWeight = FontWeight.Bold)) { + append("AutoMod: ") + } + + when { + // User-side: simple status messages, no Allow/Deny + message.isUserSide -> { + when (message.status) { + AutomodMessageStatus.Pending -> withStyle(SpanStyle(color = textColor)) { append(userHeldText) } + AutomodMessageStatus.Approved -> withStyle(SpanStyle(color = textColor)) { append(userAcceptedText) } + AutomodMessageStatus.Denied -> withStyle(SpanStyle(color = textColor)) { append(userDeniedText) } + AutomodMessageStatus.Expired -> withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f))) { append(expiredText) } + } + } + + // Mod-side: reason text + Allow/Deny buttons or status + else -> { + withStyle(SpanStyle(color = textColor)) { + append("$headerText ") + } + + when (message.status) { + AutomodMessageStatus.Pending -> { + pushStringAnnotation(tag = ALLOW_TAG, annotation = message.heldMessageId) + withStyle(SpanStyle(color = allowColor, fontWeight = FontWeight.Bold)) { + append(allowText) + } + pop() + + pushStringAnnotation(tag = DENY_TAG, annotation = message.heldMessageId) + withStyle(SpanStyle(color = denyColor, fontWeight = FontWeight.Bold)) { + append(" $denyText") + } + pop() + } + + AutomodMessageStatus.Approved -> { + withStyle(SpanStyle(color = allowColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { + append(approvedText) + } + } + + AutomodMessageStatus.Denied -> { + withStyle(SpanStyle(color = denyColor.copy(alpha = 0.6f), fontWeight = FontWeight.Bold)) { + append(deniedText) + } + } + + AutomodMessageStatus.Expired -> { + withStyle(SpanStyle(color = textColor.copy(alpha = 0.5f), fontWeight = FontWeight.Bold)) { + append(expiredText) + } + } + } + } + } + } + } + + // Body line: "timestamp {displayName}: {message}" + val bodyString = + remember(message, textColor, nameColor, timestampColor, textSize) { + message.messageText?.let { text -> + buildAnnotatedString { + // Timestamp for alignment + if (message.timestamp.isNotEmpty()) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = textSize * 0.95f, + color = timestampColor, + letterSpacing = (-0.03).em, + ), + ) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Username in bold with user color + withStyle(SpanStyle(color = nameColor, fontWeight = FontWeight.Bold)) { + append("${message.userDisplayName}: ") + } + + // Message text + withStyle(SpanStyle(color = textColor)) { + append(text) + } + } + } + } + + // Badge inline content providers (same pattern as PrivMessage) + val badgeSize = emoteBaseHeight(fontSize) + val inlineContentProviders = + remember(message.badges, fontSize) { + buildMap Unit> { + message.badges.forEach { badge -> + put("BADGE_${badge.position}") { + BadgeInlineContent(badge = badge, size = badgeSize) + } + } + }.toImmutableMap() + } + + val density = LocalDensity.current + val knownDimensions = + remember(message.badges, fontSize) { + buildMap { + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + message.badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + }.toImmutableMap() + } + + val resolvedAlpha = + when { + message.isUserSide -> 1f + message.status == AutomodMessageStatus.Pending -> 1f + else -> 0.5f + } + + Column( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(resolvedAlpha) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + // Header line with badge inline content + TextWithMeasuredInlineContent( + text = headerString, + inlineContentProviders = inlineContentProviders, + style = TextStyle(fontSize = textSize), + knownDimensions = knownDimensions, + modifier = Modifier.fillMaxWidth(), + onTextClick = { offset -> + if (isPending) { + headerString + .getStringAnnotations(ALLOW_TAG, offset, offset) + .firstOrNull() + ?.let { + onAllow(message.heldMessageId, message.channel) + } + headerString + .getStringAnnotations(DENY_TAG, offset, offset) + .firstOrNull() + ?.let { + onDeny(message.heldMessageId, message.channel) + } + } + }, + ) + + // Body line with held message text + bodyString?.let { + TextWithMeasuredInlineContent( + text = it, + inlineContentProviders = persistentMapOf(), + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt new file mode 100644 index 000000000..e2121c7bb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/PrivMessage.kt @@ -0,0 +1,347 @@ +package com.flxrs.dankchat.ui.chat.messages + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.appendInlineSpacer +import com.flxrs.dankchat.ui.chat.messages.common.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.messages.common.parseUserAnnotation +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveLinkColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberNormalizedColor +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.utils.resolve + +/** + * Renders a regular chat message with: + * - Optional reply thread header + * - Badges and username + * - Message text with inline emotes + * - Clickable username and emotes + * - Long-press to copy message + */ +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LambdaParameterEventTrailing") +@Composable +fun PrivMessageComposable( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (emotes: List) -> Unit, + onReplyClick: (rootMessageId: String, replyName: UserName) -> Unit, + modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, + showChannelPrefix: Boolean = false, + animateGifs: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + + Column( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor, highlightShape) + .indication(interactionSource, ripple()) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + // Highlight type header (First Time Chat, Elevated Chat) + if (message.highlightHeader != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val headerColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) + Icon( + imageVector = Icons.AutoMirrored.Filled.Chat, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = headerColor, + ) + Text( + text = message.highlightHeader.resolve(), + fontSize = (fontSize * 0.9f).sp, + fontWeight = FontWeight.Medium, + color = headerColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(start = 4.dp) + .weight(1f, fill = false), + ) + if (message.highlightHeaderImageUrl != null && message.highlightHeaderCost != null) { + AsyncImage( + model = message.highlightHeaderImageUrl, + contentDescription = null, + modifier = Modifier + .padding(start = 6.dp) + .size(14.dp), + alpha = 0.6f, + ) + val costText = buildString { + append(message.highlightHeaderCost) + if (message.highlightHeaderCostSuffix != null) { + append(" ${message.highlightHeaderCostSuffix}") + } + } + Text( + text = costText, + fontSize = (fontSize * 0.9f).sp, + fontWeight = FontWeight.Medium, + color = headerColor, + modifier = Modifier.padding(start = 2.dp), + ) + } + } + } + + // Reply thread header + if (message.thread != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { onReplyClick(message.thread.rootId, message.thread.userName.toUserName()) }, + onLongClick = { onMessageLongClick(message.id, message.channel.value, message.fullMessage) }, + ).padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val replyColor = rememberAdaptiveTextColor(backgroundColor).copy(alpha = 0.6f) + val replyNameColor = rememberNormalizedColor(message.thread.rawNameColor, backgroundColor) + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = replyColor, + ) + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = replyColor)) { + append("Reply to ") + } + withStyle(SpanStyle(color = replyNameColor)) { + append("@${message.thread.userName}: ") + } + withStyle(SpanStyle(color = replyColor)) { + append(message.thread.message) + } + }, + fontSize = (fontSize * 0.9f).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + + // Main message + PrivMessageText( + message = message, + fontSize = fontSize, + showChannelPrefix = showChannelPrefix, + animateGifs = animateGifs, + interactionSource = interactionSource, + backgroundColor = backgroundColor, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ) + } +} + +@Composable +private fun PrivMessageText( + message: ChatMessageUiState.PrivMessageUi, + fontSize: Float, + showChannelPrefix: Boolean, + animateGifs: Boolean, + interactionSource: MutableInteractionSource, + backgroundColor: Color, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (emotes: List) -> Unit, +) { + val context = LocalPlatformContext.current + val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) + val nameColor = rememberNormalizedColor(message.rawNameColor, backgroundColor) + val linkColor = rememberAdaptiveLinkColor(backgroundColor) + + // Build annotated string with text content + val annotatedString = + remember(message, defaultTextColor, nameColor, showChannelPrefix, linkColor) { + buildAnnotatedString { + // Channel prefix (for mention tab) + if (showChannelPrefix) { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = defaultTextColor, + ), + ) { + append("#${message.channel.value} ") + } + } + + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Badges (using appendInlineContent for proper rendering) + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") // Space between badges + } + + // Username with click annotation (only if nameText is not empty) + if (message.nameText.isNotEmpty()) { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = nameColor, + ), + ) { + pushStringAnnotation( + tag = "USER", + annotation = "${message.userId?.value.orEmpty()}|${message.userName.value}|${message.displayName.value}|${message.channel.value}", + ) + append(message.nameText) + pop() + } + } + + // Message text with emotes + val textColor = + if (message.isAction) { + nameColor + } else { + defaultTextColor + } + + withStyle(SpanStyle(color = textColor)) { + var currentPos = 0 + message.emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + val segment = message.message.substring(currentPos, emote.position.first) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) + } + + // Emote inline content + appendInlineContent("EMOTE_${emote.code}", emote.code) + + // Cheer amount text + if (emote.cheerAmount != null) { + withStyle( + SpanStyle( + color = emote.cheerColor ?: textColor, + fontWeight = FontWeight.Bold, + ), + ) { + append(emote.cheerAmount.toString()) + } + } + + // Add space after emote if next character exists and is not whitespace + val nextPos = emote.position.last + 1 + if (nextPos < message.message.length && !message.message[nextPos].isWhitespace()) { + append(" ") + } + + currentPos = emote.position.last + 1 + } + + // Remaining text + if (currentPos < message.message.length) { + val segment = message.message.substring(currentPos) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) + } + } + } + } + + MessageTextWithInlineContent( + annotatedString = annotatedString, + badges = message.badges, + emotes = message.emotes, + fontSize = fontSize, + animateGifs = animateGifs, + interactionSource = interactionSource, + onEmoteClick = onEmoteClick, + onTextClick = { offset -> + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + val url = annotatedString.getStringAnnotations("URL", offset, offset).firstOrNull() + + when { + user != null -> parseUserAnnotation(user.item)?.let { + onUserClick(it.userId, it.userName, it.displayName, it.channel.orEmpty(), message.badges, false) + } + + url != null -> launchCustomTab(context, url.item) + } + }, + onTextLongClick = { offset -> + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + + when { + user != null -> parseUserAnnotation(user.item)?.let { + onUserClick(it.userId, it.userName, it.displayName, it.channel.orEmpty(), message.badges, true) + } + + else -> onMessageLongClick(message.id, message.channel.value, message.fullMessage) + } + }, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt new file mode 100644 index 000000000..7d93250e0 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/SystemMessages.kt @@ -0,0 +1,321 @@ +package com.flxrs.dankchat.ui.chat.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.messages.common.LinkableText +import com.flxrs.dankchat.ui.chat.messages.common.SimpleMessageContainer +import com.flxrs.dankchat.ui.chat.messages.common.appendInlineSpacer +import com.flxrs.dankchat.ui.chat.messages.common.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveLinkColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberNormalizedColor +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle +import com.flxrs.dankchat.utils.TextResource +import com.flxrs.dankchat.utils.resolve + +/** + * Renders a system message (connected, disconnected, emote loading failures, etc.) + */ +@Composable +fun SystemMessageComposable( + message: ChatMessageUiState.SystemMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + SimpleMessageContainer( + message = message.message.resolve(), + timestamp = message.timestamp, + fontSize = fontSize.sp, + lightBackgroundColor = message.lightBackgroundColor, + darkBackgroundColor = message.darkBackgroundColor, + textAlpha = message.textAlpha, + modifier = modifier, + ) +} + +/** + * Renders a notice message from Twitch + */ +@Composable +fun NoticeMessageComposable( + message: ChatMessageUiState.NoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + SimpleMessageContainer( + message = message.message, + timestamp = message.timestamp, + fontSize = fontSize.sp, + lightBackgroundColor = message.lightBackgroundColor, + darkBackgroundColor = message.darkBackgroundColor, + textAlpha = message.textAlpha, + modifier = modifier, + ) +} + +/** + * Renders a user notice message (subscriptions, announcements, etc.) + * The display name is highlighted with the user's color. + */ +@Composable +fun UserNoticeMessageComposable( + message: ChatMessageUiState.UserNoticeMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, +) { + val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(bgColor) + val linkColor = rememberAdaptiveLinkColor(bgColor) + val timestampColor = rememberAdaptiveTextColor(bgColor) + val nameColor = rememberNormalizedColor(message.rawNameColor, bgColor) + val textSize = fontSize.sp + + val annotatedString = + remember(message, textColor, nameColor, linkColor, timestampColor, textSize) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Message text with colored display name + val displayName = message.displayName + val msgText = message.message + val nameIndex = + when { + displayName.isNotEmpty() -> msgText.indexOf(displayName, ignoreCase = true) + else -> -1 + } + + when { + nameIndex >= 0 -> { + // Text before name + if (nameIndex > 0) { + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(msgText.substring(0, nameIndex), linkColor) + } + } + + // Colored username + withStyle(SpanStyle(color = nameColor)) { + append(msgText.substring(nameIndex, nameIndex + displayName.length)) + } + + // Text after name + val afterIndex = nameIndex + displayName.length + if (afterIndex < msgText.length) { + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(msgText.substring(afterIndex), linkColor) + } + } + } + + else -> { + // No display name found, render as plain text + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(msgText, linkColor) + } + } + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(bgColor, highlightShape) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + LinkableText( + text = annotatedString, + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** + * Renders a date separator between messages from different days + */ +@Composable +fun DateSeparatorComposable( + message: ChatMessageUiState.DateSeparatorUi, + fontSize: Float, + modifier: Modifier = Modifier, +) { + SimpleMessageContainer( + message = message.dateText, + timestamp = message.timestamp, + fontSize = fontSize.sp, + lightBackgroundColor = message.lightBackgroundColor, + darkBackgroundColor = message.darkBackgroundColor, + textAlpha = message.textAlpha, + modifier = modifier, + ) +} + +@Immutable +private data class StyledRange( + val start: Int, + val length: Int, + val color: Color, +) + +/** + * Renders a moderation message (timeouts, bans, deletions) with colored usernames. + */ +@Composable +fun ModerationMessageComposable( + message: ChatMessageUiState.ModerationMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + showChannelPrefix: Boolean = false, +) { + val bgColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(bgColor) + val timestampColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + val creatorColor = rememberNormalizedColor(message.creatorColor, bgColor) + val targetColor = rememberNormalizedColor(message.targetColor, bgColor) + val textSize = fontSize.sp + val resolvedMessage = message.message.resolve() + + val linkColor = rememberAdaptiveLinkColor(bgColor) + + val dimmedTextColor = textColor.copy(alpha = 0.7f) + + val resolvedArguments = + remember(message.arguments) { + message.arguments.map { arg -> + when (arg) { + is TextResource -> arg + else -> arg.toString() + } + } + }.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg.toString() + } + } + + val annotatedString = + remember( + message, + resolvedMessage, + resolvedArguments, + textColor, + dimmedTextColor, + creatorColor, + targetColor, + linkColor, + timestampColor, + textSize, + ) { + // Collect all highlighted ranges: usernames (bold+colored) and arguments (regular text color) + val ranges = + buildList { + var searchFrom = 0 + message.creatorName?.let { name -> + val idx = resolvedMessage.indexOf(name, startIndex = searchFrom, ignoreCase = true) + if (idx >= 0) { + add(StyledRange(idx, name.length, creatorColor)) + searchFrom = idx + name.length + } + } + message.targetName?.let { name -> + val idx = resolvedMessage.indexOf(name, startIndex = searchFrom, ignoreCase = true) + if (idx >= 0) { + add(StyledRange(idx, name.length, targetColor)) + } + } + for (arg in resolvedArguments) { + if (arg.isBlank()) continue + val idx = resolvedMessage.indexOf(arg, ignoreCase = true) + if (idx >= 0 && none { it.start <= idx && idx < it.start + it.length }) { + add(StyledRange(idx, arg.length, textColor)) + } + } + }.sortedBy { it.start } + + buildAnnotatedString { + // Channel prefix + if (showChannelPrefix) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = textColor)) { + append("#${message.channel.value} ") + } + } + + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(textSize.value, timestampColor)) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Render message: highlighted ranges at full opacity, template text dimmed + var cursor = 0 + for (range in ranges) { + if (range.start < cursor) continue + if (range.start > cursor) { + withStyle(SpanStyle(color = dimmedTextColor)) { + append(resolvedMessage.substring(cursor, range.start)) + } + } + withStyle(SpanStyle(color = range.color)) { + append(resolvedMessage.substring(range.start, range.start + range.length)) + } + cursor = range.start + range.length + } + if (cursor < resolvedMessage.length) { + withStyle(SpanStyle(color = dimmedTextColor)) { + append(resolvedMessage.substring(cursor)) + } + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(bgColor) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + LinkableText( + text = annotatedString, + style = TextStyle(fontSize = textSize), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt new file mode 100644 index 000000000..2aae08f5e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/WhisperAndRedemption.kt @@ -0,0 +1,361 @@ +package com.flxrs.dankchat.ui.chat.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import com.flxrs.dankchat.ui.chat.messages.common.LinkableText +import com.flxrs.dankchat.ui.chat.messages.common.MessageTextWithInlineContent +import com.flxrs.dankchat.ui.chat.messages.common.appendInlineSpacer +import com.flxrs.dankchat.ui.chat.messages.common.appendWithLinks +import com.flxrs.dankchat.ui.chat.messages.common.isSpacerId +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.messages.common.parseUserAnnotation +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveLinkColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberAdaptiveTextColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberBackgroundColor +import com.flxrs.dankchat.ui.chat.messages.common.rememberNormalizedColor +import com.flxrs.dankchat.ui.chat.messages.common.spacerWidthDp +import com.flxrs.dankchat.ui.chat.messages.common.timestampSpanStyle + +/** + * Renders a whisper message (private message between users) + */ +@Composable +fun WhisperMessageComposable( + message: ChatMessageUiState.WhisperMessageUi, + fontSize: Float, + onUserClick: (userId: String?, userName: String, displayName: String, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, fullMessage: String) -> Unit, + onEmoteClick: (emotes: List) -> Unit, + modifier: Modifier = Modifier, + animateGifs: Boolean = true, + onWhisperReply: ((userName: UserName) -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor) + .indication(interactionSource, ripple()) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + WhisperMessageText( + message = message, + fontSize = fontSize, + animateGifs = animateGifs, + backgroundColor = backgroundColor, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ) + } + if (onWhisperReply != null) { + IconButton( + onClick = { onWhisperReply(message.replyTargetName) }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun WhisperMessageText( + message: ChatMessageUiState.WhisperMessageUi, + fontSize: Float, + animateGifs: Boolean, + backgroundColor: Color, + onUserClick: (userId: String?, userName: String, displayName: String, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, fullMessage: String) -> Unit, + onEmoteClick: (emotes: List) -> Unit, +) { + val context = LocalPlatformContext.current + val defaultTextColor = rememberAdaptiveTextColor(backgroundColor) + val senderColor = rememberNormalizedColor(message.rawSenderColor, backgroundColor) + val recipientColor = rememberNormalizedColor(message.rawRecipientColor, backgroundColor) + val linkColor = rememberAdaptiveLinkColor(backgroundColor) + + // Build annotated string with text content + val annotatedString = + remember(message, defaultTextColor, senderColor, recipientColor, linkColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, defaultTextColor)) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + // Badges (using appendInlineContent for proper rendering) + message.badges.forEach { badge -> + appendInlineContent("BADGE_${badge.position}", "[badge]") + append(" ") // Space between badges + } + + // Sender username with click annotation + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = senderColor, + ), + ) { + pushStringAnnotation( + tag = "USER", + annotation = "${message.userId.value}|${message.userName.value}|${message.displayName.value}", + ) + append(message.senderName) + pop() + } + withStyle(SpanStyle(color = defaultTextColor)) { + append(" -> ") + } + + // Recipient + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + color = recipientColor, + ), + ) { + append(message.recipientName) + } + withStyle(SpanStyle(color = defaultTextColor)) { + append(": ") + } + + // Message text with emotes + withStyle(SpanStyle(color = defaultTextColor)) { + var currentPos = 0 + message.emotes.sortedBy { it.position.first }.forEach { emote -> + // Text before emote + if (currentPos < emote.position.first) { + val segment = message.message.substring(currentPos, emote.position.first) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) + } + + // Emote inline content + appendInlineContent("EMOTE_${emote.code}", emote.code) + + // Add space after emote if next character exists and is not whitespace + val nextPos = emote.position.last + 1 + if (nextPos < message.message.length && !message.message[nextPos].isWhitespace()) { + append(" ") + } + + currentPos = emote.position.last + 1 + } + + // Remaining text + if (currentPos < message.message.length) { + val segment = message.message.substring(currentPos) + val prevChar = if (currentPos > 0) message.message[currentPos - 1] else null + appendWithLinks(segment, linkColor, prevChar) + } + } + } + } + + MessageTextWithInlineContent( + annotatedString = annotatedString, + badges = message.badges, + emotes = message.emotes, + fontSize = fontSize, + animateGifs = animateGifs, + onEmoteClick = onEmoteClick, + onTextClick = { offset -> + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + val url = annotatedString.getStringAnnotations("URL", offset, offset).firstOrNull() + + when { + user != null -> parseUserAnnotation(user.item)?.let { + onUserClick(it.userId, it.userName, it.displayName, message.badges, false) + } + + url != null -> launchCustomTab(context, url.item) + } + }, + onTextLongClick = { offset -> + val user = annotatedString.getStringAnnotations("USER", offset, offset).firstOrNull() + + when { + user != null -> parseUserAnnotation(user.item)?.let { + onUserClick(it.userId, it.userName, it.displayName, message.badges, true) + } + + else -> onMessageLongClick(message.id, message.fullMessage) + } + }, + ) +} + +/** + * Renders a channel point redemption message + */ +@Composable +fun PointRedemptionMessageComposable( + message: ChatMessageUiState.PointRedemptionMessageUi, + fontSize: Float, + modifier: Modifier = Modifier, + highlightShape: Shape = RectangleShape, +) { + val backgroundColor = rememberBackgroundColor(message.lightBackgroundColor, message.darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(backgroundColor) + + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + Box( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(message.textAlpha) + .background(backgroundColor, highlightShape) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + val nameColor = message.nameText?.let { rememberNormalizedColor(message.rawNameColor, backgroundColor) } + val rewardImageSize = (fontSize * 1.5f) + val costTextStyle = TextStyle(fontSize = fontSize.sp, color = textColor) + + val annotatedString = + remember(message, textColor, nameColor) { + buildAnnotatedString { + // Timestamp + if (message.timestamp.isNotEmpty()) { + withStyle(timestampSpanStyle(fontSize, textColor)) { + append(message.timestamp) + } + appendInlineSpacer(6.dp) + } + + when { + message.requiresUserInput -> { + append("Redeemed ") + } + + message.nameText != null -> { + withStyle(SpanStyle(color = nameColor ?: textColor)) { + append(message.nameText) + } + append(" redeemed ") + } + } + + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(message.title) + } + append(" ") + appendInlineContent(REWARD_COST_ID, "[reward ${message.cost}]") + } + } + + val inlineContent = remember(annotatedString, rewardImageSize, density, costTextStyle) { + val costText = " ${message.cost}" + val costWidthPx = textMeasurer.measure(costText, costTextStyle).size.width + + buildMap { + val spacerIds = annotatedString + .getStringAnnotations(INLINE_CONTENT_TAG, 0, annotatedString.length) + .filter { isSpacerId(it.item) } + .map { it.item } + .distinct() + + for (id in spacerIds) { + val widthSp = with(density) { spacerWidthDp(id).dp.toSp() } + put( + id, + InlineTextContent( + Placeholder(width = widthSp, height = 0.01.sp, PlaceholderVerticalAlign.TextCenter), + ) { }, + ) + } + + val imageSizeSp = with(density) { rewardImageSize.dp.toSp() } + val costWidthSp = with(density) { costWidthPx.toDp().toSp() } + val totalWidth = (imageSizeSp.value + costWidthSp.value).sp + put( + REWARD_COST_ID, + InlineTextContent( + Placeholder(width = totalWidth, height = imageSizeSp, PlaceholderVerticalAlign.TextCenter), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = message.rewardImageUrl, + contentDescription = message.title, + modifier = Modifier.size(rewardImageSize.dp), + ) + BasicText( + text = costText, + style = costTextStyle, + ) + } + }, + ) + } + } + + BasicText( + text = annotatedString, + style = costTextStyle, + inlineContent = inlineContent, + ) + } +} + +private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" +private const val REWARD_COST_ID = "REWARD_COST" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt new file mode 100644 index 000000000..7c74c85d3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/AdaptiveTextColor.kt @@ -0,0 +1,87 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils +import com.flxrs.dankchat.ui.theme.LocalAdaptiveColors +import com.flxrs.dankchat.utils.extensions.normalizeColor +import com.materialkolor.ktx.isLight + +/** + * Resolves the effective opaque background for contrast calculations. + * Semi-transparent colors are composited over the surface color. + */ +@Composable +private fun resolveEffectiveBackground(backgroundColor: Color): Color { + val surfaceColor = MaterialTheme.colorScheme.surface + return when { + backgroundColor == Color.Transparent -> surfaceColor + backgroundColor.alpha < 1f -> backgroundColor.compositeOver(surfaceColor) + else -> backgroundColor + } +} + +/** + * Returns appropriate text color (light or dark) based on background brightness. + * Uses Color.isLight() to determine if background is light, + * then selects dark text for light backgrounds and vice versa. + * + * For transparent backgrounds, uses the surface color for brightness calculation + * since that's what will be visible behind the text. + */ +@Composable +fun rememberAdaptiveTextColor(backgroundColor: Color): Color { + val adaptiveColors = LocalAdaptiveColors.current + val effectiveBackground = resolveEffectiveBackground(backgroundColor) + val isLightBackground = effectiveBackground.isLight() + val baseColor = if (isLightBackground) adaptiveColors.onSurfaceLight else adaptiveColors.onSurfaceDark + val effectiveBgArgb = effectiveBackground.toArgb() + + return remember(baseColor, effectiveBgArgb) { + Color(baseColor.toArgb().normalizeColor(effectiveBgArgb)) + } +} + +private const val MIN_LINK_CONTRAST_RATIO = 3.0 + +/** + * Returns a link color with sufficient contrast against the given background. + * Uses the theme's primary color when contrast is adequate, otherwise falls back + * to a lighter/darker variant that meets the minimum contrast ratio. + */ +@Composable +fun rememberAdaptiveLinkColor(backgroundColor: Color): Color { + val primary = MaterialTheme.colorScheme.primary + val inversePrimary = MaterialTheme.colorScheme.inversePrimary + val effectiveBg = resolveEffectiveBackground(backgroundColor) + + return remember(primary, inversePrimary, effectiveBg) { + val primaryContrast = ColorUtils.calculateContrast(primary.toArgb(), effectiveBg.toArgb()) + when { + primaryContrast >= MIN_LINK_CONTRAST_RATIO -> primary + else -> inversePrimary + } + } +} + +/** + * Normalizes a raw color int for readable contrast against the effective background. + * Semi-transparent backgrounds are composited over [MaterialTheme.colorScheme.surface] + * to produce an opaque color for accurate contrast calculation. + */ +@Composable +fun rememberNormalizedColor( + rawColor: Int, + backgroundColor: Color, +): Color { + val effectiveBg = resolveEffectiveBackground(backgroundColor) + val effectiveBgArgb = effectiveBg.toArgb() + + return remember(rawColor, effectiveBgArgb) { + Color(rawColor.normalizeColor(effectiveBgArgb)) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt new file mode 100644 index 000000000..fde563bce --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BackgroundColor.kt @@ -0,0 +1,29 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +/** + * Selects the appropriate background color based on current theme. + * Semi-transparent colors (e.g. checkered backgrounds) are composited over the + * theme background to produce an opaque result suitable for contrast calculations. + */ +@Composable +fun rememberBackgroundColor( + lightColor: Color, + darkColor: Color, +): Color { + val raw = if (isSystemInDarkTheme()) darkColor else lightColor + val background = MaterialTheme.colorScheme.background + return remember(raw, background) { + when { + raw == Color.Transparent -> Color.Transparent + raw.alpha < 1f -> raw.compositeOver(background) + else -> raw + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt new file mode 100644 index 000000000..21eb64072 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/BadgeInlineContent.kt @@ -0,0 +1,64 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.data.twitch.badge.Badge +import com.flxrs.dankchat.ui.chat.BadgeUi + +private val FfzModGreen = Color(0xFF34AE0A) + +/** + * Renders a badge as inline content in a message. + * FFZ mod badges get a green background fill since the badge image is foreground-only. + */ +@Composable +fun BadgeInlineContent( + badge: BadgeUi, + size: Dp, + modifier: Modifier = Modifier, +) { + when (badge.badge) { + is Badge.FFZModBadge -> { + Box( + modifier = + modifier + .size(size) + .background(FfzModGreen), + ) { + AsyncImage( + model = badge.url, + contentDescription = badge.badge.type.name, + modifier = Modifier.fillMaxSize(), + ) + } + } + + is Badge.SharedChatBadge -> { + AsyncImage( + model = badge.drawableResId ?: badge.url, + contentDescription = badge.badge.type.name, + modifier = + modifier + .size(size) + .clip(CircleShape), + ) + } + + else -> { + AsyncImage( + model = badge.drawableResId ?: badge.url, + contentDescription = badge.badge.type.name, + modifier = modifier.size(size), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/LinkableText.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/LinkableText.kt new file mode 100644 index 000000000..a3f0012fe --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/LinkableText.kt @@ -0,0 +1,74 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.LocalPlatformContext + +/** + * Replacement for deprecated [androidx.compose.foundation.text.ClickableText]. + * Renders annotated text with inline spacer support and URL click handling. + */ +@Composable +fun LinkableText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, +) { + val context = LocalPlatformContext.current + val density = LocalDensity.current + val layoutResult = remember { mutableStateOf(null) } + + val inlineContent = remember(text) { + val spacerIds = text + .getStringAnnotations(INLINE_CONTENT_TAG, 0, text.length) + .filter { isSpacerId(it.item) } + .map { it.item } + .distinct() + + buildMap { + for (id in spacerIds) { + val widthSp = with(density) { spacerWidthDp(id).dp.toSp() } + put( + id, + InlineTextContent( + Placeholder(width = widthSp, height = 0.01.sp, PlaceholderVerticalAlign.TextCenter), + ) { }, + ) + } + } + } + + BasicText( + text = text, + style = style, + inlineContent = inlineContent, + onTextLayout = { layoutResult.value = it }, + modifier = modifier.pointerInput(text) { + detectTapGestures { offset -> + layoutResult.value?.let { result -> + val position = result.getOffsetForPosition(offset) + val url = text.getStringAnnotations(URL_ANNOTATION_TAG, position, position).firstOrNull() + if (url != null) { + launchCustomTab(context, url.item) + } + } + } + }, + ) +} + +private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt new file mode 100644 index 000000000..63f88d175 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/Linkification.kt @@ -0,0 +1,64 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import android.util.Patterns +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle + +private val DISALLOWED_URL_CHARS = """<>\{}|^"`""".toSet() + +fun AnnotatedString.Builder.appendWithLinks( + text: String, + linkColor: Color, + previousChar: Char? = null, +) { + val matcher = Patterns.WEB_URL.matcher(text) + var lastIndex = 0 + + while (matcher.find()) { + val start = matcher.start() + var end = matcher.end() + + val prevChar = if (start > 0) text[start - 1] else previousChar + if (prevChar != null && !prevChar.isWhitespace()) { + continue + } + + var fixedEnd = end + while (fixedEnd < text.length) { + val c = text[fixedEnd] + if (c.isWhitespace() || c in DISALLOWED_URL_CHARS) { + break + } + fixedEnd++ + } + end = fixedEnd + + val rawUrl = text.substring(start, end) + val url = + when { + rawUrl.contains("://") -> rawUrl + else -> "https://$rawUrl" + } + + if (start > lastIndex) { + append(text.substring(lastIndex, start)) + } + + pushStringAnnotation(tag = URL_ANNOTATION_TAG, annotation = url) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(rawUrl) + } + pop() + + lastIndex = end + } + + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } +} + +const val URL_ANNOTATION_TAG = "URL" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt new file mode 100644 index 000000000..7e0c00efd --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/MessageTextRenderer.kt @@ -0,0 +1,152 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import android.content.Context +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.EmoteUi +import com.flxrs.dankchat.ui.chat.emote.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.emote.StackedEmote +import com.flxrs.dankchat.ui.chat.emote.emoteBaseHeight +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.toImmutableMap + +private val logger = KotlinLogging.logger("MessageTextRenderer") + +@Composable +fun MessageTextWithInlineContent( + annotatedString: AnnotatedString, + badges: ImmutableList, + emotes: ImmutableList, + fontSize: Float, + animateGifs: Boolean, + onTextClick: (Int) -> Unit, + onEmoteClick: (List) -> Unit, + modifier: Modifier = Modifier, + onTextLongClick: ((Int) -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val emoteCoordinator = LocalEmoteAnimationCoordinator.current + val density = LocalDensity.current + + val badgeSize = emoteBaseHeight(fontSize) + val inlineContentProviders: ImmutableMap Unit> = + remember(badges, emotes, fontSize) { + buildMap Unit> { + badges.forEach { badge -> + put("BADGE_${badge.position}") { + BadgeInlineContent(badge = badge, size = badgeSize) + } + } + + emotes.forEach { emote -> + put("EMOTE_${emote.code}") { + StackedEmote( + emote = emote, + fontSize = fontSize, + emoteCoordinator = emoteCoordinator, + animateGifs = animateGifs, + modifier = Modifier, + onClick = { onEmoteClick(emote.emotes) }, + ) + } + } + }.toImmutableMap() + } + + val knownDimensions = + remember(badges, emotes, fontSize, emoteCoordinator) { + buildMap { + val badgeSizePx = with(density) { badgeSize.toPx().toInt() } + badges.forEach { badge -> + put("BADGE_${badge.position}", EmoteDimensions("BADGE_${badge.position}", badgeSizePx, badgeSizePx)) + } + + val baseHeight = emoteBaseHeight(fontSize) + val baseHeightPx = with(density) { baseHeight.toPx().toInt() } + emotes.forEach { emote -> + val id = "EMOTE_${emote.code}" + val dims = when { + emote.urls.size == 1 -> { + emoteCoordinator.dimensionCache.get(emote.urls.first()) + } + + else -> { + val cacheKey = "${emote.emotes.joinToString("-") { it.id }}-$baseHeightPx" + emoteCoordinator.dimensionCache.get(cacheKey) + } + } + if (dims != null) { + put(id, EmoteDimensions(id, dims.first, dims.second)) + } + } + }.toImmutableMap() + } + + TextWithMeasuredInlineContent( + text = annotatedString, + inlineContentProviders = inlineContentProviders, + style = TextStyle(fontSize = fontSize.sp), + knownDimensions = knownDimensions, + modifier = modifier.fillMaxWidth(), + interactionSource = interactionSource, + onTextClick = onTextClick, + onTextLongClick = onTextLongClick, + ) +} + +fun launchCustomTab( + context: Context, + url: String, +) { + try { + CustomTabsIntent + .Builder() + .setShowTitle(true) + .build() + .launchUrl(context, url.toUri()) + } catch (e: Exception) { + logger.error(e) { "Error launching URL" } + } +} + +fun timestampSpanStyle( + fontSize: Float, + color: Color, +) = SpanStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = (fontSize * 0.95f).sp, + color = color, + letterSpacing = (-0.03).em, +) + +private const val SPACER_PREFIX = "spacer_" + +fun AnnotatedString.Builder.appendInlineSpacer(width: Dp) { + appendInlineContent("$SPACER_PREFIX${width.value}", " ") +} + +fun isSpacerId(id: String): Boolean = id.startsWith(SPACER_PREFIX) + +fun spacerWidthDp(id: String): Float = id.removePrefix(SPACER_PREFIX).toFloatOrNull() ?: 0f diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt new file mode 100644 index 000000000..2cfb07434 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/SimpleMessageContainer.kt @@ -0,0 +1,66 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +@Composable +fun SimpleMessageContainer( + message: String, + timestamp: String, + fontSize: TextUnit, + lightBackgroundColor: Color, + darkBackgroundColor: Color, + textAlpha: Float, + modifier: Modifier = Modifier, + timestampSpacerWidth: Dp = 6.dp, +) { + val bgColor = rememberBackgroundColor(lightBackgroundColor, darkBackgroundColor) + val textColor = rememberAdaptiveTextColor(bgColor) + val linkColor = rememberAdaptiveLinkColor(bgColor) + val timestampColor = MaterialTheme.colorScheme.onSurface + + val annotatedString = + remember(message, timestamp, textColor, linkColor, timestampColor, fontSize, timestampSpacerWidth) { + buildAnnotatedString { + withStyle(timestampSpanStyle(fontSize.value, timestampColor)) { + append(timestamp) + } + appendInlineSpacer(timestampSpacerWidth) + withStyle(SpanStyle(color = textColor)) { + appendWithLinks(message, linkColor) + } + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .wrapContentHeight() + .alpha(textAlpha) + .background(bgColor) + .padding(horizontal = 6.dp, vertical = 3.dp), + ) { + LinkableText( + text = annotatedString, + style = TextStyle(fontSize = fontSize), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt new file mode 100644 index 000000000..bb168ccb6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/TextWithMeasuredInlineContent.kt @@ -0,0 +1,254 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.launch + +data class EmoteDimensions( + val id: String, + val widthPx: Int, + val heightPx: Int, +) + +@Composable +fun TextWithMeasuredInlineContent( + text: AnnotatedString, + inlineContentProviders: ImmutableMap Unit>, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + knownDimensions: ImmutableMap = persistentMapOf(), + onTextClick: ((Int) -> Unit)? = null, + onTextLongClick: ((Int) -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val density = LocalDensity.current + val allDimensionsKnown = inlineContentProviders.keys.all { it in knownDimensions } + + if (allDimensionsKnown) { + // Fast path: all dimensions cached, skip SubcomposeLayout overhead + val inlineContent = remember(knownDimensions, text, density, inlineContentProviders) { + buildInlineContentMap(knownDimensions, text, density, inlineContentProviders) + } + ClickableInlineText( + text = text, + style = style, + inlineContent = inlineContent, + modifier = modifier, + onTextClick = onTextClick, + onTextLongClick = onTextLongClick, + interactionSource = interactionSource, + ) + } else { + // Slow path: SubcomposeLayout to measure unknown inline content dimensions + SubcomposeMeasuredInlineText( + text = text, + inlineContentProviders = inlineContentProviders, + knownDimensions = knownDimensions, + density = density, + style = style, + modifier = modifier, + onTextClick = onTextClick, + onTextLongClick = onTextLongClick, + interactionSource = interactionSource, + ) + } +} + +@Composable +private fun ClickableInlineText( + text: AnnotatedString, + style: TextStyle, + inlineContent: Map, + onTextClick: ((Int) -> Unit)?, + onTextLongClick: ((Int) -> Unit)?, + interactionSource: MutableInteractionSource?, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val textLayoutResultRef = remember { mutableStateOf(null) } + + BasicText( + text = text, + style = style, + inlineContent = inlineContent, + modifier = + modifier.pointerInput(text, interactionSource) { + detectTapGestures( + onPress = { offset -> + interactionSource?.let { source -> + val press = PressInteraction.Press(offset) + coroutineScope.launch { + source.emit(press) + tryAwaitRelease() + source.emit(PressInteraction.Release(press)) + } + } + }, + onTap = { offset -> + textLayoutResultRef.value?.let { layoutResult -> + val line = layoutResult.getLineForVerticalPosition(offset.y) + val lineLeft = layoutResult.getLineLeft(line) + val lineRight = layoutResult.getLineRight(line) + if (offset.x in lineLeft..lineRight) { + onTextClick?.invoke(layoutResult.getOffsetForPosition(offset)) + } + } + }, + onLongPress = { offset -> + val layoutResult = textLayoutResultRef.value + if (layoutResult != null) { + val line = layoutResult.getLineForVerticalPosition(offset.y) + val lineLeft = layoutResult.getLineLeft(line) + val lineRight = layoutResult.getLineRight(line) + if (offset.x in lineLeft..lineRight) { + onTextLongClick?.invoke(layoutResult.getOffsetForPosition(offset)) + } else { + onTextLongClick?.invoke(-1) + } + } else { + onTextLongClick?.invoke(-1) + } + }, + ) + }, + onTextLayout = { layoutResult -> + textLayoutResultRef.value = layoutResult + }, + ) +} + +@Composable +private fun SubcomposeMeasuredInlineText( + text: AnnotatedString, + inlineContentProviders: ImmutableMap Unit>, + knownDimensions: ImmutableMap, + density: Density, + style: TextStyle, + onTextClick: ((Int) -> Unit)?, + onTextLongClick: ((Int) -> Unit)?, + interactionSource: MutableInteractionSource?, + modifier: Modifier = Modifier, +) { + SubcomposeLayout(modifier = modifier) { constraints -> + val measuredDimensions = mutableMapOf() + measuredDimensions.putAll(knownDimensions) + + // Resolve spacer inline content from annotated string annotations + text + .getStringAnnotations(INLINE_CONTENT_TAG, 0, text.length) + .filter { isSpacerId(it.item) && it.item !in measuredDimensions } + .distinctBy { it.item } + .forEach { annotation -> + val widthPx = spacerWidthDp(annotation.item).dp.toPx().toInt() + measuredDimensions[annotation.item] = EmoteDimensions(annotation.item, widthPx, 1) + } + + // Only measure items that don't have known dimensions + inlineContentProviders.forEach { (id, provider) -> + if (id !in knownDimensions) { + val measurables = subcompose("measure_$id", provider) + if (measurables.isNotEmpty()) { + val placeable = + measurables.first().measure( + Constraints( + maxWidth = constraints.maxWidth, + maxHeight = Constraints.Infinity, + ), + ) + measuredDimensions[id] = + EmoteDimensions( + id = id, + widthPx = placeable.width, + heightPx = placeable.height, + ) + } + } + } + + val inlineContent = buildInlineContentMapFromDimensions(measuredDimensions, density, inlineContentProviders) + + val textMeasurables = + subcompose("text") { + ClickableInlineText( + text = text, + style = style, + inlineContent = inlineContent, + onTextClick = onTextClick, + onTextLongClick = onTextLongClick, + interactionSource = interactionSource, + ) + } + + if (textMeasurables.isEmpty()) { + return@SubcomposeLayout layout(0, 0) {} + } + + val textPlaceable = textMeasurables.first().measure(constraints) + + layout(textPlaceable.width, textPlaceable.height) { + textPlaceable.place(0, 0) + } + } +} + +private fun buildInlineContentMap( + knownDimensions: ImmutableMap, + text: AnnotatedString, + density: Density, + providers: ImmutableMap Unit>, +): Map { + val allDimensions = buildMap { + putAll(knownDimensions) + // Resolve spacers from annotated string + text + .getStringAnnotations(INLINE_CONTENT_TAG, 0, text.length) + .filter { isSpacerId(it.item) && it.item !in knownDimensions } + .distinctBy { it.item } + .forEach { annotation -> + val widthPx = with(density) { spacerWidthDp(annotation.item).dp.toPx().toInt() } + put(annotation.item, EmoteDimensions(annotation.item, widthPx, 1)) + } + } + return buildInlineContentMapFromDimensions(allDimensions, density, providers) +} + +private fun buildInlineContentMapFromDimensions( + dimensions: Map, + density: Density, + providers: ImmutableMap Unit>, +): Map = dimensions.mapValues { (id, dims) -> + InlineTextContent( + placeholder = + Placeholder( + width = with(density) { dims.widthPx.toDp() }.value.sp, + height = with(density) { dims.heightPx.toDp() }.value.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + providers[id]?.invoke() + } +} + +private const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/UserAnnotation.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/UserAnnotation.kt new file mode 100644 index 000000000..7d9aab467 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/messages/common/UserAnnotation.kt @@ -0,0 +1,35 @@ +package com.flxrs.dankchat.ui.chat.messages.common + +data class UserAnnotation( + val userId: String?, + val userName: String, + val displayName: String, + val channel: String?, +) + +fun parseUserAnnotation(annotation: String): UserAnnotation? { + val parts = annotation.split("|") + return when (parts.size) { + 4 -> { + UserAnnotation( + userId = parts[0].takeIf { it.isNotEmpty() }, + userName = parts[1], + displayName = parts[2], + channel = parts[3], + ) + } + + 3 -> { + UserAnnotation( + userId = parts[0].takeIf { it.isNotEmpty() }, + userName = parts[1], + displayName = parts[2], + channel = null, + ) + } + + else -> { + null + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt new file mode 100644 index 000000000..b8a58d2e3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesComposable.kt @@ -0,0 +1,68 @@ +package com.flxrs.dankchat.ui.chat.replies + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatScreen +import com.flxrs.dankchat.ui.chat.ChatScreenCallbacks +import kotlinx.collections.immutable.persistentListOf + +/** + * Standalone composable for reply thread display. + * Extracted from RepliesChatFragment to enable pure Compose integration. + * + * This composable: + * - Collects reply thread state from RepliesViewModel + * - Collects appearance settings + * - Handles NotFound state via onMissing callback + * - Renders ChatScreen for Found state + */ +@Composable +fun RepliesComposable( + repliesViewModel: RepliesViewModel, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, + onMissing: () -> Unit, + containerColor: Color, + modifier: Modifier = Modifier, + scrollModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + onScrollToBottom: () -> Unit = {}, +) { + val displaySettings by repliesViewModel.chatDisplaySettings.collectAsStateWithLifecycle() + val uiState by repliesViewModel.uiState.collectAsStateWithLifecycle(initialValue = RepliesUiState.Found(persistentListOf())) + + when (uiState) { + is RepliesUiState.Found -> { + ChatScreen( + messages = (uiState as RepliesUiState.Found).items, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ), + animateGifs = displaySettings.animateGifs, + modifier = modifier, + contentPadding = contentPadding, + scrollModifier = scrollModifier, + containerColor = containerColor, + onScrollToBottom = onScrollToBottom, + ) + } + + is RepliesUiState.NotFound -> { + LaunchedEffect(Unit) { + onMissing() + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt new file mode 100644 index 000000000..842ac23c8 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesState.kt @@ -0,0 +1,24 @@ +package com.flxrs.dankchat.ui.chat.replies + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.ui.chat.ChatMessageUiState +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface RepliesState { + data object NotFound : RepliesState + + data class Found( + val items: ImmutableList, + ) : RepliesState +} + +@Immutable +sealed interface RepliesUiState { + data object NotFound : RepliesUiState + + data class Found( + val items: ImmutableList, + ) : RepliesUiState +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt new file mode 100644 index 000000000..6128fea37 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/replies/RepliesViewModel.kt @@ -0,0 +1,87 @@ +package com.flxrs.dankchat.ui.chat.replies + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.repo.RepliesRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.ui.chat.ChatDisplaySettings +import com.flxrs.dankchat.ui.chat.ChatMessageMapper +import com.flxrs.dankchat.ui.chat.CheckeredMessageTracker +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class RepliesViewModel( + @InjectedParam private val rootMessageId: String, + repliesRepository: RepliesRepository, + private val chatMessageMapper: ChatMessageMapper, + private val preferenceStore: DankChatPreferenceStore, + appearanceSettingsDataStore: AppearanceSettingsDataStore, + chatSettingsDataStore: ChatSettingsDataStore, + dispatchersProvider: DispatchersProvider, +) : ViewModel() { + val chatDisplaySettings: StateFlow = + combine( + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { appearance, chat -> + ChatDisplaySettings( + fontSize = appearance.fontSize.toFloat(), + animateGifs = chat.animateGifs, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatDisplaySettings()) + + private val checkeredTracker = CheckeredMessageTracker() + + val state = + repliesRepository + .getThreadItemsFlow(rootMessageId) + .map { + when { + it.isEmpty() -> RepliesState.NotFound + else -> RepliesState.Found(it.toImmutableList()) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesState.Found(persistentListOf())) + + val uiState: StateFlow = + combine( + state, + appearanceSettingsDataStore.settings, + chatSettingsDataStore.settings, + ) { repliesState, appearanceSettings, chatSettings -> + when (repliesState) { + is RepliesState.NotFound -> { + RepliesUiState.NotFound + } + + is RepliesState.Found -> { + val uiMessages = chatMessageMapper + .run { + repliesState.items + .map { item -> + val altBg = checkeredTracker.isAlternate(item.message.id) && appearanceSettings.checkeredMessages + mapToUiState( + item = item, + chatSettings = chatSettings, + preferenceStore = preferenceStore, + isAlternateBackground = altBg, + ) + }.withHighlightLayout(showLineSeparator = appearanceSettings.lineSeparator) + }.toImmutableList() + RepliesUiState.Found(uiMessages) + } + } + }.flowOn(dispatchersProvider.default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RepliesUiState.Found(persistentListOf())) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt new file mode 100644 index 000000000..720d3749c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatItemFilter.kt @@ -0,0 +1,93 @@ +package com.flxrs.dankchat.ui.chat.search + +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import com.flxrs.dankchat.data.twitch.message.UserNoticeMessage + +object ChatItemFilter { + private val URL_REGEX = Regex("https?://\\S+", RegexOption.IGNORE_CASE) + + fun matches( + item: ChatItem, + filters: List, + ): Boolean { + if (filters.isEmpty()) return true + return filters.all { filter -> + val result = + when (filter) { + is ChatSearchFilter.Text -> matchText(item, filter.query) + is ChatSearchFilter.Author -> matchAuthor(item, filter.name) + is ChatSearchFilter.HasLink -> matchLink(item) + is ChatSearchFilter.HasEmote -> matchEmote(item, filter.emoteName) + is ChatSearchFilter.BadgeFilter -> matchBadge(item, filter.badgeName) + } + if (filter.negate) !result else result + } + } + + private fun matchText( + item: ChatItem, + query: String, + ): Boolean = when (val message = item.message) { + is PrivMessage -> message.message.contains(query, ignoreCase = true) + is UserNoticeMessage -> message.message.contains(query, ignoreCase = true) + else -> false + } + + private fun matchAuthor( + item: ChatItem, + name: String, + ): Boolean = when (val message = item.message) { + is PrivMessage -> { + message.name.value.equals(name, ignoreCase = true) || + message.displayName.value.equals(name, ignoreCase = true) + } + + is ModerationMessage -> { + message.targetUser?.value?.equals(name, ignoreCase = true) == true || + message.targetUserDisplay?.value?.equals(name, ignoreCase = true) == true + } + + else -> { + false + } + } + + private fun matchLink(item: ChatItem): Boolean = when (val message = item.message) { + is PrivMessage -> URL_REGEX.containsMatchIn(message.message) + else -> false + } + + private fun matchEmote( + item: ChatItem, + emoteName: String?, + ): Boolean = when (val message = item.message) { + is PrivMessage -> { + when (emoteName) { + null -> message.emotes.isNotEmpty() + else -> message.emotes.any { it.code.equals(emoteName, ignoreCase = true) } + } + } + + else -> { + false + } + } + + private fun matchBadge( + item: ChatItem, + badgeName: String, + ): Boolean = when (val message = item.message) { + is PrivMessage -> { + message.badges.any { badge -> + badge.badgeTag?.substringBefore('/')?.equals(badgeName, ignoreCase = true) == true || + badge.title?.contains(badgeName, ignoreCase = true) == true + } + } + + else -> { + false + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt new file mode 100644 index 000000000..184e01a7d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilter.kt @@ -0,0 +1,32 @@ +package com.flxrs.dankchat.ui.chat.search + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface ChatSearchFilter { + val negate: Boolean + + data class Text( + val query: String, + override val negate: Boolean = false, + ) : ChatSearchFilter + + data class Author( + val name: String, + override val negate: Boolean = false, + ) : ChatSearchFilter + + data class HasLink( + override val negate: Boolean = false, + ) : ChatSearchFilter + + data class HasEmote( + val emoteName: String?, + override val negate: Boolean = false, + ) : ChatSearchFilter + + data class BadgeFilter( + val badgeName: String, + override val negate: Boolean = false, + ) : ChatSearchFilter +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt new file mode 100644 index 000000000..ec023f372 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/ChatSearchFilterParser.kt @@ -0,0 +1,72 @@ +package com.flxrs.dankchat.ui.chat.search + +object ChatSearchFilterParser { + private val WHITESPACE_REGEX = "\\s+".toRegex() + + fun parse(query: String): List { + if (query.isBlank()) return emptyList() + + val tokens = query.trim().split(WHITESPACE_REGEX) + val lastTokenIncomplete = !query.endsWith(' ') + + return tokens.mapIndexedNotNull { index, token -> + val isBeingTyped = lastTokenIncomplete && index == tokens.lastIndex + parseToken(token, isBeingTyped) + } + } + + private fun parseToken( + token: String, + isBeingTyped: Boolean, + ): ChatSearchFilter? { + if (token.isBlank()) return null + + val (negate, raw) = extractNegation(token) + val colonIndex = raw.indexOf(':') + + if (colonIndex > 0) { + val prefix = raw.substring(0, colonIndex).lowercase() + val value = raw.substring(colonIndex + 1) + + when (prefix) { + "from" -> return when { + isBeingTyped || value.isEmpty() -> null + else -> ChatSearchFilter.Author(name = value, negate = negate) + } + + "has" -> return when (value.lowercase()) { + "link" -> { + ChatSearchFilter.HasLink(negate = negate) + } + + "emote" -> { + ChatSearchFilter.HasEmote(emoteName = null, negate = negate) + } + + "" -> { + null + } + + else -> { + when { + isBeingTyped -> null + else -> ChatSearchFilter.HasEmote(emoteName = value, negate = negate) + } + } + } + + "badge" -> return when { + isBeingTyped || value.isEmpty() -> null + else -> ChatSearchFilter.BadgeFilter(badgeName = value.lowercase(), negate = negate) + } + } + } + + return ChatSearchFilter.Text(query = raw, negate = negate) + } + + private fun extractNegation(token: String): Pair = when { + token.startsWith('!') || token.startsWith('-') -> true to token.substring(1) + else -> false to token + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt new file mode 100644 index 000000000..b9c09b48e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/search/SearchFilterSuggestions.kt @@ -0,0 +1,76 @@ +package com.flxrs.dankchat.ui.chat.search + +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion + +object SearchFilterSuggestions { + private val KEYWORD_FILTERS = + listOf( + Suggestion.FilterSuggestion("from:", R.string.search_filter_by_username), + Suggestion.FilterSuggestion("has:link", R.string.search_filter_has_link), + Suggestion.FilterSuggestion("has:emote", R.string.search_filter_has_emote), + Suggestion.FilterSuggestion("badge:", R.string.search_filter_by_badge), + ) + + private val HAS_VALUES = + listOf( + Suggestion.FilterSuggestion("has:link", R.string.search_filter_has_link, displayText = "link"), + Suggestion.FilterSuggestion("has:emote", R.string.search_filter_has_emote, displayText = "emote"), + ) + + private const val MAX_VALUE_SUGGESTIONS = 10 + private const val MIN_KEYWORD_CHARS = 2 + + fun filter( + input: String, + users: Set = emptySet(), + badgeNames: Set = emptySet(), + ): List { + val lastToken = + input + .trimEnd() + .substringAfterLast(' ') + .removePrefix("-") + .removePrefix("!") + val colonIndex = lastToken.indexOf(':') + if (colonIndex < 0) { + if (lastToken.length < MIN_KEYWORD_CHARS) { + return emptyList() + } + return KEYWORD_FILTERS.filter { suggestion -> + suggestion.keyword.startsWith(lastToken, ignoreCase = true) && !suggestion.keyword.equals(lastToken, ignoreCase = true) + } + } + + val prefix = lastToken.substring(0, colonIndex + 1) + val partial = lastToken.substring(colonIndex + 1) + + return when (prefix.lowercase()) { + "from:" -> { + users + .filter { it.value.startsWith(partial, ignoreCase = true) && !it.value.equals(partial, ignoreCase = true) } + .take(MAX_VALUE_SUGGESTIONS) + .map { Suggestion.FilterSuggestion(keyword = "from:${it.value}", descriptionRes = R.string.search_filter_user, displayText = it.value) } + } + + "has:" -> { + HAS_VALUES.filter { suggestion -> + val value = suggestion.displayText.orEmpty() + value.startsWith(partial, ignoreCase = true) && !value.equals(partial, ignoreCase = true) + } + } + + "badge:" -> { + badgeNames + .filter { it.startsWith(partial, ignoreCase = true) && !it.equals(partial, ignoreCase = true) } + .take(MAX_VALUE_SUGGESTIONS) + .map { Suggestion.FilterSuggestion(keyword = "badge:$it", descriptionRes = R.string.search_filter_badge, displayText = it) } + } + + else -> { + emptyList() + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt new file mode 100644 index 000000000..b31ded95f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/Suggestion.kt @@ -0,0 +1,43 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.repo.emote.EmojiData +import com.flxrs.dankchat.data.twitch.emote.GenericEmote + +@Immutable +sealed interface Suggestion { + data class EmoteSuggestion( + val emote: GenericEmote, + ) : Suggestion { + override fun toString() = emote.toString() + } + + data class UserSuggestion( + val name: DisplayName, + val withLeadingAt: Boolean = false, + ) : Suggestion { + override fun toString() = if (withLeadingAt) "@$name" else name.toString() + } + + data class EmojiSuggestion( + val emoji: EmojiData, + ) : Suggestion { + override fun toString() = emoji.unicode + } + + data class CommandSuggestion( + val command: String, + ) : Suggestion { + override fun toString() = command + } + + data class FilterSuggestion( + val keyword: String, + @param:StringRes val descriptionRes: Int, + val displayText: String? = null, + ) : Suggestion { + override fun toString() = keyword + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt new file mode 100644 index 000000000..36e8d4f6b --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProvider.kt @@ -0,0 +1,266 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.UsersRepository +import com.flxrs.dankchat.data.repo.command.CommandRepository +import com.flxrs.dankchat.data.repo.emote.EmojiData +import com.flxrs.dankchat.data.repo.emote.EmojiRepository +import com.flxrs.dankchat.data.repo.emote.EmoteRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository +import com.flxrs.dankchat.data.twitch.emote.GenericEmote +import com.flxrs.dankchat.preferences.chat.SuggestionType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class SuggestionProvider( + private val emoteRepository: EmoteRepository, + private val usersRepository: UsersRepository, + private val commandRepository: CommandRepository, + private val emoteUsageRepository: EmoteUsageRepository, + private val emojiRepository: EmojiRepository, +) { + fun getSuggestions( + inputText: String, + cursorPosition: Int, + channel: UserName?, + enabledTypes: List, + prefixOnly: Boolean = false, + ): Flow> { + if (inputText.isBlank() || channel == null || enabledTypes.isEmpty()) { + return flowOf(emptyList()) + } + + val currentWord = extractCurrentWord(inputText, cursorPosition) + if (currentWord.isBlank()) { + return flowOf(emptyList()) + } + + val emotesEnabled = SuggestionType.Emotes in enabledTypes + val usersEnabled = SuggestionType.Users in enabledTypes + val commandsEnabled = SuggestionType.Commands in enabledTypes + val supibotEnabled = SuggestionType.SupibotCommands in enabledTypes + + // ':' trigger: emote + emoji mode with reduced min chars + val isEmoteTrigger = currentWord.startsWith(':') + val emoteQuery = + when { + isEmoteTrigger -> currentWord.removePrefix(":") + else -> currentWord + } + + if ((isEmoteTrigger && emoteQuery.isEmpty()) || (!isEmoteTrigger && currentWord.length < MIN_SUGGESTION_CHARS)) { + return flowOf(emptyList()) + } + + if (isEmoteTrigger && emotesEnabled) { + val emojiResults = filterEmojis(emojiRepository.emojis.value, emoteQuery) + return getScoredEmoteSuggestions(channel, emoteQuery).map { emoteResults -> + mergeSorted(emoteResults, emojiResults) + } + } + + // '@' trigger: users only + if (currentWord.startsWith('@') && usersEnabled) { + return getUserSuggestions(channel, currentWord).map { users -> + users.take(MAX_SUGGESTIONS) + } + } + + // Built-in command prefixes (/, $): twitch commands + supibot + custom + val isCommandTrigger = currentWord.startsWith('/') || currentWord.startsWith('$') + if (isCommandTrigger && (commandsEnabled || supibotEnabled)) { + return getCommandSuggestions(channel, currentWord, commandsEnabled, supibotEnabled).map { commands -> + commands.take(MAX_SUGGESTIONS) + } + } + + // Custom commands with arbitrary triggers — always checked since prefixes are dynamic + val customCommandFlow = getCustomCommandSuggestions(currentWord) + + // In prefix-only mode, only custom commands can match (no free-type emotes/users) + if (prefixOnly) { + return customCommandFlow.map { it.take(MAX_SUGGESTIONS) } + } + + // General (free type): score emotes + users together, plus custom commands + val emoteFlow = when { + emotesEnabled -> getScoredEmoteSuggestions(channel, currentWord) + else -> flowOf(emptyList()) + } + val userFlow = when { + usersEnabled -> getScoredUserSuggestions(channel, currentWord) + else -> flowOf(emptyList()) + } + return combine(emoteFlow, userFlow, customCommandFlow) { emotes, users, customCommands -> + val merged = mergeSorted(emotes, users) + when { + customCommands.isEmpty() -> merged + else -> merged + customCommands + } + } + } + + private fun getScoredEmoteSuggestions( + channel: UserName, + constraint: String, + ): Flow> = emoteRepository.getEmotes(channel).map { emotes -> + val recentIds = emoteUsageRepository.recentEmoteIds.value + filterEmotesScored(emotes.suggestions, constraint, recentIds) + } + + private fun getScoredUserSuggestions( + channel: UserName, + constraint: String, + ): Flow> = usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsersScored(displayNameSet, constraint) + } + + private fun getUserSuggestions( + channel: UserName, + constraint: String, + ): Flow> = usersRepository.getUsersFlow(channel).map { displayNameSet -> + filterUsers(displayNameSet, constraint) + } + + private fun getCustomCommandSuggestions(constraint: String): Flow> = commandRepository.getCustomCommandTriggers().map { triggers -> + filterCommands(triggers, constraint) + } + + private fun getCommandSuggestions( + channel: UserName, + constraint: String, + commandsEnabled: Boolean, + supibotEnabled: Boolean, + ): Flow> = combine( + commandRepository.getCommandTriggers(channel), + commandRepository.getSupibotCommands(channel), + ) { triggers, supibotCommands -> + val combined = buildList { + if (commandsEnabled) addAll(triggers) + if (supibotEnabled) addAll(supibotCommands) + } + filterCommands(combined, constraint) + } + + // Merge two pre-sorted lists in O(n+m) without intermediate allocations + private fun mergeSorted( + a: List, + b: List, + ): List { + val result = mutableListOf() + var i = 0 + var j = 0 + while (result.size < MAX_SUGGESTIONS && (i < a.size || j < b.size)) { + val pick = + when { + i >= a.size -> b[j++] + j >= b.size -> a[i++] + a[i].score <= b[j].score -> a[i++] + else -> b[j++] + } + result.add(pick.suggestion) + } + return result + } + + internal fun extractCurrentWord( + text: String, + cursorPosition: Int, + ): String { + val cursorPos = cursorPosition.coerceIn(0, text.length) + val separator = ' ' + + var start = cursorPos + while (start > 0 && text[start - 1] != separator) start-- + + return text.substring(start, cursorPos) + } + + // Scoring based on Chatterino2's SmartEmoteStrategy by Mm2PL + // https://github.com/Chatterino/chatterino2/pull/4987 + internal fun scoreEmote( + code: String, + query: String, + isRecentlyUsed: Boolean, + ): Int { + val matchIndex = code.indexOf(query, ignoreCase = true) + if (matchIndex < 0) return NO_MATCH + + var caseDiffs = 0 + for (i in query.indices) { + if (code[matchIndex + i] != query[i]) caseDiffs++ + } + + val extraChars = code.length - query.length + val caseCost = if (caseDiffs == 0) -10 else caseDiffs + val usageBoost = if (isRecentlyUsed) -50 else 0 + return caseCost + extraChars * 100 + usageBoost + } + + internal fun filterEmotesScored( + emotes: List, + constraint: String, + recentEmoteIds: Set, + ): List = emotes + .mapNotNull { emote -> + val score = scoreEmote(emote.code, constraint, emote.id in recentEmoteIds) + if (score == NO_MATCH) null else ScoredSuggestion(Suggestion.EmoteSuggestion(emote), score) + }.sortedBy { it.score } + + // Score raw EmojiData, only wrap matches + internal fun filterEmojis( + emojis: List, + constraint: String, + ): List = emojis + .mapNotNull { emoji -> + val score = scoreEmote(emoji.code, constraint, isRecentlyUsed = false) + if (score == NO_MATCH) null else ScoredSuggestion(Suggestion.EmojiSuggestion(emoji), score) + }.sortedBy { it.score } + + // Filter raw DisplayName set, only wrap matches — used for @-prefix suggestions + internal fun filterUsers( + users: Set, + constraint: String, + ): List { + val withAt = constraint.startsWith('@') + return users + .mapNotNull { name -> + val suggestion = Suggestion.UserSuggestion(name, withLeadingAt = withAt) + suggestion.takeIf { it.toString().startsWith(constraint, ignoreCase = true) } + }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.value }) + } + + internal fun filterUsersScored( + users: Set, + constraint: String, + ): List = users + .mapNotNull { name -> + val score = scoreEmote(name.value, constraint, isRecentlyUsed = false) + if (score == NO_MATCH) null else ScoredSuggestion(Suggestion.UserSuggestion(name), score + USER_SCORE_PENALTY) + }.sortedBy { it.score } + + // Filter raw command strings, only wrap matches + internal fun filterCommands( + commands: List, + constraint: String, + ): List = commands + .filter { it.startsWith(constraint, ignoreCase = true) } + .sortedWith(String.CASE_INSENSITIVE_ORDER) + .map { Suggestion.CommandSuggestion(it) } + + companion object { + internal const val NO_MATCH = Int.MIN_VALUE + internal const val USER_SCORE_PENALTY = 25 + private const val MAX_SUGGESTIONS = 50 + private const val MIN_SUGGESTION_CHARS = 2 + } +} + +internal class ScoredSuggestion( + val suggestion: Suggestion, + val score: Int, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt new file mode 100644 index 000000000..05abf5be8 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupDialog.kt @@ -0,0 +1,362 @@ +package com.flxrs.dankchat.ui.chat.user + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Report +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.BadgeUi +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserPopupDialog( + state: UserPopupState, + badges: ImmutableList, + onBlockUser: () -> Unit, + onUnblockUser: () -> Unit, + onDismiss: () -> Unit, + onOpenChannel: (String) -> Unit, + onReport: (String) -> Unit, + onMention: ((String, String) -> Unit)? = null, + onWhisper: ((String) -> Unit)? = null, + isOwnUser: Boolean = false, + onMessageHistory: ((String) -> Unit)? = null, + onViewHistory: ((String) -> Unit)? = null, +) { + var showBlockConfirmation by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = showBlockConfirmation, + label = "UserPopupContent", + ) { isBlockConfirmation -> + when { + isBlockConfirmation -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.confirm_user_block_message), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = { showBlockConfirmation = false }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = { + onBlockUser() + showBlockConfirmation = false + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.confirm_user_block_positive_button)) + } + } + } + } + + else -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is UserPopupState.Error -> { + Text( + text = stringResource(R.string.error_with_message, state.throwable?.message.orEmpty()), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + else -> { + val userName = state.userName + val displayName = state.displayName + val isSuccess = state is UserPopupState.Success + val isBlocked = (state as? UserPopupState.Success)?.isBlocked == true + + UserInfoSection( + state = state, + userName = userName, + displayName = displayName, + badges = badges, + onOpenChannel = onOpenChannel, + ) + + if (onMention != null) { + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_mention)) }, + leadingContent = { Icon(Icons.Default.AlternateEmail, contentDescription = null) }, + modifier = + Modifier.clickable { + onMention(userName.value, displayName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (onWhisper != null && !isOwnUser) { + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_whisper)) }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }, + modifier = + Modifier.clickable { + onWhisper(userName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + val historyCallback = onViewHistory ?: onMessageHistory + if (historyCallback != null) { + val label = when (onViewHistory) { + null -> R.string.message_history + else -> R.string.view_history + } + ListItem( + headlineContent = { Text(stringResource(label)) }, + leadingContent = { Icon(Icons.Default.History, contentDescription = null) }, + modifier = + Modifier.clickable { + historyCallback(userName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (isSuccess && !isOwnUser) { + ListItem( + headlineContent = { Text(if (isBlocked) stringResource(R.string.user_popup_unblock) else stringResource(R.string.user_popup_block)) }, + leadingContent = { Icon(Icons.Default.Block, contentDescription = null) }, + modifier = + Modifier.clickable { + if (isBlocked) { + onUnblockUser() + } else { + showBlockConfirmation = true + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (!isOwnUser) { + ListItem( + headlineContent = { Text(stringResource(R.string.user_popup_report)) }, + leadingContent = { Icon(Icons.Default.Report, contentDescription = null) }, + modifier = + Modifier.clickable { + onReport(userName.value) + onDismiss() + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun UserInfoSection( + state: UserPopupState, + userName: UserName, + displayName: DisplayName, + badges: ImmutableList, + onOpenChannel: (String) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top, + ) { + when (state) { + is UserPopupState.Success -> { + AsyncImage( + model = state.avatarUrl, + contentDescription = null, + modifier = + Modifier + .size(96.dp) + .clip(CircleShape) + .clickable { onOpenChannel(state.userName.value) }, + ) + } + + is UserPopupState.Loading -> { + Box( + modifier = Modifier.size(96.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is UserPopupState.Error -> {} + } + + Spacer(modifier = Modifier.width(16.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) { + Text( + text = userName.formatWithDisplayName(displayName), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + when (state) { + is UserPopupState.Success -> { + Text( + text = stringResource(R.string.user_popup_created, state.created), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + if (state.showFollowingSince) { + Text( + text = + state.followingSince?.let { + stringResource(R.string.user_popup_following_since, it) + } ?: stringResource(R.string.user_popup_not_following), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + if (badges.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + badges.forEach { badge -> + val title = badge.badge.title + if (title != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(title) } }, + state = rememberTooltipState(), + ) { + AsyncImage( + model = badge.url, + contentDescription = title, + modifier = Modifier.size(32.dp), + ) + } + } else { + AsyncImage( + model = badge.url, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } + } + } + } + } + + else -> {} + } + } + } +} + +private val UserPopupState.userName: UserName + get() = + when (this) { + is UserPopupState.Loading -> userName + is UserPopupState.Success -> userName + is UserPopupState.Error -> UserName("") + } + +private val UserPopupState.displayName: DisplayName + get() = + when (this) { + is UserPopupState.Loading -> displayName + is UserPopupState.Success -> displayName + is UserPopupState.Error -> DisplayName("") + } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt similarity index 57% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupState.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt index 164399f1a..c9a893303 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupState.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupState.kt @@ -1,12 +1,21 @@ -package com.flxrs.dankchat.chat.user +package com.flxrs.dankchat.ui.chat.user +import androidx.compose.runtime.Immutable import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName +@Immutable sealed interface UserPopupState { - data class Loading(val userName: UserName, val displayName: DisplayName) : UserPopupState - data class Error(val throwable: Throwable? = null) : UserPopupState + data class Loading( + val userName: UserName, + val displayName: DisplayName, + ) : UserPopupState + + data class Error( + val throwable: Throwable? = null, + ) : UserPopupState + data class Success( val userId: UserId, val userName: UserName, @@ -15,8 +24,6 @@ sealed interface UserPopupState { val avatarUrl: String, val showFollowingSince: Boolean = false, val followingSince: String? = null, - val isBlocked: Boolean = false + val isBlocked: Boolean = false, ) : UserPopupState - - data class NotLoggedIn(val userName: UserName, val displayName: DisplayName) : UserPopupState } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt new file mode 100644 index 000000000..ff51e0bc2 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupStateParams.kt @@ -0,0 +1,18 @@ +package com.flxrs.dankchat.ui.chat.user + +import android.os.Parcelable +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.badge.Badge +import kotlinx.parcelize.Parcelize + +@Parcelize +data class UserPopupStateParams( + val targetUserId: UserId, + val targetUserName: UserName, + val targetDisplayName: DisplayName, + val channel: UserName?, + val isWhisperPopup: Boolean = false, + val badges: List = emptyList(), +) : Parcelable diff --git a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt similarity index 52% rename from app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupViewModel.kt rename to app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt index 27ddce14c..153db5ea6 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/chat/user/UserPopupViewModel.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/chat/user/UserPopupViewModel.kt @@ -1,9 +1,7 @@ -package com.flxrs.dankchat.chat.user +package com.flxrs.dankchat.ui.chat.user -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flxrs.dankchat.data.DisplayName import com.flxrs.dankchat.data.UserId import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.api.helix.dto.UserDto @@ -19,35 +17,21 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel @KoinViewModel class UserPopupViewModel( - savedStateHandle: SavedStateHandle, + @InjectedParam private val params: UserPopupStateParams, private val dataRepository: DataRepository, private val ignoresRepository: IgnoresRepository, private val channelRepository: ChannelRepository, private val userStateRepository: UserStateRepository, private val preferenceStore: DankChatPreferenceStore, ) : ViewModel() { - - private val args = UserPopupDialogFragmentArgs.fromSavedStateHandle(savedStateHandle) - - private val _userPopupState = MutableStateFlow(UserPopupState.Loading(args.targetUserName, args.targetDisplayName)) - + private val _userPopupState = MutableStateFlow(UserPopupState.Loading(params.targetUserName, params.targetDisplayName)) val userPopupState: StateFlow = _userPopupState.asStateFlow() - - val isBlocked: Boolean - get() { - val state = userPopupState.value - return state is UserPopupState.Success && state.isBlocked - } - - val userName: UserName - get() = (userPopupState.value as? UserPopupState.Success)?.userName ?: args.targetUserName - - val displayName: DisplayName - get() = (userPopupState.value as? UserPopupState.Success)?.displayName ?: args.targetDisplayName + val isOwnUser: Boolean get() = preferenceStore.userIdString == params.targetUserId init { loadData() @@ -66,52 +50,55 @@ class UserPopupViewModel( return@launch } - val result = runCatching { block(args.targetUserId, args.targetUserName) } + val result = runCatching { block(params.targetUserId, params.targetUserName) } when { result.isFailure -> _userPopupState.value = UserPopupState.Error(result.exceptionOrNull()) - else -> loadData() + else -> loadData() } } private fun loadData() = viewModelScope.launch { - if (!preferenceStore.isLoggedIn) { - _userPopupState.value = UserPopupState.NotLoggedIn(args.targetUserName, args.targetDisplayName) - return@launch - } - - _userPopupState.value = UserPopupState.Loading(args.targetUserName, args.targetDisplayName) + _userPopupState.value = UserPopupState.Loading(params.targetUserName, params.targetDisplayName) val currentUserId = preferenceStore.userIdString if (!preferenceStore.isLoggedIn || currentUserId == null) { _userPopupState.value = UserPopupState.Error() return@launch } - val targetUserId = args.targetUserId - val result = runCatching { - val channelId = args.channel?.let { channelRepository.getChannel(it)?.id } - val isBlocked = ignoresRepository.isUserBlocked(targetUserId) - val canLoadFollows = channelId != targetUserId && (currentUserId == channelId || userStateRepository.isModeratorInChannel(args.channel)) - - val channelUserFollows = async { - channelId?.takeIf { canLoadFollows }?.let { dataRepository.getChannelFollowers(channelId, targetUserId) } - } - val user = async { - dataRepository.getUser(targetUserId) + val targetUserId = params.targetUserId + val result = + runCatching { + val channelId = params.channel?.let { channelRepository.getChannel(it)?.id } + val isBlocked = ignoresRepository.isUserBlocked(targetUserId) + val canLoadFollows = channelId != targetUserId && (currentUserId == channelId || userStateRepository.isModeratorInChannel(params.channel)) + + val channelUserFollows = + async { + channelId?.takeIf { canLoadFollows }?.let { dataRepository.getChannelFollowers(channelId, targetUserId) } + } + val user = + async { + dataRepository.getUser(targetUserId) + } + + mapToState( + user = user.await(), + showFollowing = canLoadFollows, + channelUserFollows = channelUserFollows.await(), + isBlocked = isBlocked, + ) } - mapToState( - user = user.await(), - showFollowing = canLoadFollows, - channelUserFollows = channelUserFollows.await(), - isBlocked = isBlocked, - ) - } - val state = result.getOrElse { UserPopupState.Error(it) } _userPopupState.value = state } - private fun mapToState(user: UserDto?, showFollowing: Boolean, channelUserFollows: UserFollowsDto?, isBlocked: Boolean): UserPopupState { + private fun mapToState( + user: UserDto?, + showFollowing: Boolean, + channelUserFollows: UserFollowsDto?, + isBlocked: Boolean, + ): UserPopupState { user ?: return UserPopupState.Error() return UserPopupState.Success( @@ -121,12 +108,13 @@ class UserPopupViewModel( avatarUrl = user.avatarUrl, created = user.createdAt.asParsedZonedDateTime(), showFollowingSince = showFollowing, - followingSince = channelUserFollows?.data?.firstOrNull()?.followedAt?.asParsedZonedDateTime(), - isBlocked = isBlocked + followingSince = + channelUserFollows + ?.data + ?.firstOrNull() + ?.followedAt + ?.asParsedZonedDateTime(), + isBlocked = isBlocked, ) } - - companion object { - private val TAG = UserPopupViewModel::class.java.simpleName - } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashEmailConfirmationSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashEmailConfirmationSheet.kt new file mode 100644 index 000000000..53a406e3d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashEmailConfirmationSheet.kt @@ -0,0 +1,144 @@ +package com.flxrs.dankchat.ui.crash + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.repo.crash.CrashEntry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashEmailConfirmationSheet( + crashEntry: CrashEntry, + userName: String?, + userId: String?, + onConfirm: (includeLogs: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var includeLogs by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.crash_email_confirm_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Text( + text = stringResource(R.string.crash_email_confirm_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 8.dp) + .padding(bottom = 12.dp), + ) + + Column( + modifier = Modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Version: ${crashEntry.version}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Android: ${crashEntry.androidInfo}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Device: ${crashEntry.device}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (userName != null) { + Text( + text = "User: $userName (ID: ${userId.orEmpty()})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(R.string.crash_email_confirm_stacktrace), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickable { includeLogs = !includeLogs } + .padding(start = 2.dp), + ) { + Checkbox( + checked = includeLogs, + onCheckedChange = null, + ) + Text( + text = stringResource(R.string.crash_email_confirm_include_logs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.dialog_cancel)) + } + Button( + onClick = { onConfirm(includeLogs) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.crash_report_email)) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerSheet.kt new file mode 100644 index 000000000..eb6e2391e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerSheet.kt @@ -0,0 +1,296 @@ +package com.flxrs.dankchat.ui.crash + +import android.content.ClipData +import android.content.Intent +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.flxrs.dankchat.R +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun CrashViewerSheet(onDismiss: () -> Unit) { + val viewModel: CrashViewerViewModel = koinViewModel() + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val entry = viewModel.crashEntry + var showEmailConfirmation by remember { mutableStateOf(false) } + var showDeleteConfirmation by remember { mutableStateOf(false) } + + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) + + var backProgress by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (_: CancellationException) { + backProgress = 0f + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, + ) { + SelectionContainer { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = toolbarTopPadding) + .navigationBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + ) { + if (entry != null) { + Text( + text = "Version: ${entry.version}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Android: ${entry.androidInfo} | ${entry.device}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Thread: ${entry.threadName} | ${entry.timestamp}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + Text( + text = entry.stackTrace, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + // Floating toolbar + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + brush = + Brush.verticalGradient( + 0f to sheetBackgroundColor.copy(alpha = 0.7f), + 0.75f to sheetBackgroundColor.copy(alpha = 0.7f), + 1f to sheetBackgroundColor.copy(alpha = 0f), + ), + ).padding(top = statusBarHeight + 8.dp) + .padding(bottom = 16.dp) + .padding(horizontal = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = stringResource(R.string.crash_viewer_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton( + onClick = { + val fullReport = viewModel.buildEmailBody() ?: return@IconButton + scope.launch { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText("crash_report", fullReport)), + ) + } + }, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.crash_report_copy), + ) + } + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton( + onClick = { showEmailConfirmation = true }, + ) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = stringResource(R.string.crash_report_email), + ) + } + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton( + onClick = { showDeleteConfirmation = true }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.crash_viewer_delete), + ) + } + } + } + } + + // Status bar fill when toolbar scrolled + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), + ) + } + + if (showEmailConfirmation && entry != null) { + CrashEmailConfirmationSheet( + crashEntry = entry, + userName = viewModel.userName, + userId = viewModel.userId, + onConfirm = { includeLogs -> + val body = viewModel.buildEmailBody() ?: return@CrashEmailConfirmationSheet + val subject = viewModel.buildEmailSubject() ?: return@CrashEmailConfirmationSheet + + val emailIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf(REPORT_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + if (includeLogs) { + val logFile = viewModel.getLatestLogFile() + if (logFile != null) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + selector = Intent(Intent.ACTION_SENDTO, "mailto:".toUri()) + } + context.startActivity(Intent.createChooser(emailIntent, null)) + showEmailConfirmation = false + }, + onDismiss = { showEmailConfirmation = false }, + ) + } + + if (showDeleteConfirmation && entry != null) { + ConfirmationBottomSheet( + title = stringResource(R.string.crash_viewer_delete_confirm), + confirmText = stringResource(R.string.crash_viewer_delete), + confirmColors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onConfirm = { + viewModel.deleteCrash() + showDeleteConfirmation = false + onDismiss() + }, + onDismiss = { showDeleteConfirmation = false }, + ) + } +} + +private const val REPORT_EMAIL = "dankchat@flxrs.com" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerViewModel.kt new file mode 100644 index 000000000..dfb7c8107 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/crash/CrashViewerViewModel.kt @@ -0,0 +1,54 @@ +package com.flxrs.dankchat.ui.crash + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.navigation.toRoute +import com.flxrs.dankchat.data.repo.crash.CrashEntry +import com.flxrs.dankchat.data.repo.crash.CrashRepository +import com.flxrs.dankchat.data.repo.log.LogRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.ui.main.CrashViewer +import org.koin.core.annotation.KoinViewModel +import org.koin.core.annotation.Provided +import java.io.File + +@KoinViewModel +class CrashViewerViewModel( + savedStateHandle: SavedStateHandle, + @Provided private val crashRepository: CrashRepository, + private val preferenceStore: DankChatPreferenceStore, + private val logRepository: LogRepository, +) : ViewModel() { + private val route = savedStateHandle.toRoute() + + val crashEntry: CrashEntry? = when { + route.crashId != 0L -> crashRepository.getCrash(route.crashId) + else -> crashRepository.getMostRecentCrash() + } + + val userName: String? get() = preferenceStore.userName?.value + val userId: String? get() = preferenceStore.userIdString?.value + + fun buildEmailBody(): String? { + val entry = crashEntry ?: return null + return crashRepository.buildEmailBody(entry, userName, userId) + } + + fun buildEmailSubject(): String? { + val entry = crashEntry ?: return null + val exceptionType = entry.exceptionHeader.substringBefore(':').substringAfterLast('.') + return "DankChat Crash Report [${userName.orEmpty()}] - $exceptionType" + } + + fun buildChatReportMessage(): String? { + val entry = crashEntry ?: return null + return crashRepository.buildCrashReportMessage(entry) + } + + fun getLatestLogFile(): File? = logRepository.getLatestLogFile() + + fun deleteCrash() { + val entry = crashEntry ?: return + crashRepository.deleteCrash(entry.id) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt new file mode 100644 index 000000000..d60d8662c --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerSheet.kt @@ -0,0 +1,572 @@ +package com.flxrs.dankchat.ui.log + +import android.content.ClipData +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.repo.log.LogLevel +import com.flxrs.dankchat.data.repo.log.LogLine +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun LogViewerSheet(onDismiss: () -> Unit) { + val viewModel: LogViewerViewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val selectedIndices by viewModel.selectedIndices.collectAsStateWithLifecycle() + val isSelectionActive by remember { derivedStateOf { selectedIndices.isNotEmpty() } } + val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) + + var backProgress by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + + val ime = WindowInsets.ime + val navBars = WindowInsets.navigationBars + val navBarHeightDp = with(density) { navBars.getBottom(density).toDp() } + val currentImeHeight = (ime.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + val currentImeDp = with(density) { currentImeHeight.toDp() } + + var searchBarHeightPx by remember { mutableIntStateOf(0) } + val searchBarHeightDp = with(density) { searchBarHeightPx.toDp() } + + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 8.dp + 32.dp + 16.dp + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + when { + isSelectionActive -> viewModel.clearSelection() + else -> onDismiss() + } + } catch (_: CancellationException) { + backProgress = 0f + } + } + + var toolbarVisible by remember { mutableStateOf(true) } + val scrollTracker = + remember { + ScrollDirectionTracker( + hideThresholdPx = with(density) { 100.dp.toPx() }, + showThresholdPx = with(density) { 36.dp.toPx() }, + onHide = { toolbarVisible = false }, + onShow = { toolbarVisible = true }, + ) + } + + val listState = rememberLazyListState() + var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } + val isAtBottom by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + + LaunchedEffect(listState.isScrollInProgress) { + if (listState.lastScrolledForward && shouldAutoScroll) { + shouldAutoScroll = false + } + if (!listState.isScrollInProgress && isAtBottom && !shouldAutoScroll) { + shouldAutoScroll = true + } + } + + LaunchedEffect(shouldAutoScroll, state.lines.size) { + if (shouldAutoScroll && state.lines.isNotEmpty()) { + listState.scrollToItem(0) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, + ) { + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = PaddingValues( + top = toolbarTopPadding, + bottom = searchBarHeightDp + navBarHeightDp + currentImeDp, + ), + modifier = + Modifier + .fillMaxSize() + .nestedScroll(scrollTracker), + ) { + itemsIndexed( + items = state.lines, + key = { index, _ -> index }, + ) { index, line -> + LogLineItem( + line = line, + isSelected = index in selectedIndices, + onClick = { viewModel.toggleSelection(index) }, + ) + } + } + + // Scroll-to-bottom FAB + AnimatedVisibility( + visible = !shouldAutoScroll && state.lines.isNotEmpty(), + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp + 8.dp), + ) { + FloatingActionButton( + onClick = { shouldAutoScroll = true }, + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 2.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.scroll_to_bottom), + ) + } + } + + // Floating toolbar + LogViewerToolbar( + visible = toolbarVisible, + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + levelFilter = state.levelFilter, + isSelectionActive = isSelectionActive, + selectedCount = selectedIndices.size, + onBack = onDismiss, + onLevelFilter = viewModel::setLevelFilter, + onShare = { + val file = viewModel.getShareableLogFile() ?: return@LogViewerToolbar + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(android.content.Intent.createChooser(intent, null)) + }, + onClearSelection = viewModel::clearSelection, + onCopySelection = { + val text = viewModel.getSelectedLinesText() + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("log_lines", text))) + } + viewModel.clearSelection() + }, + modifier = Modifier.align(Alignment.TopCenter), + ) + + // Status bar fill when toolbar hidden + if (!toolbarVisible) { + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), + ) + } + + // Search bar + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = currentImeDp) + .navigationBarsPadding() + .onSizeChanged { searchBarHeightPx = it.height } + .padding(bottom = 8.dp) + .padding(horizontal = 8.dp), + ) { + LogSearchToolbar( + state = viewModel.searchFieldState, + enabled = !isSelectionActive, + ) + } + } +} + +@Composable +private fun LogViewerToolbar( + visible: Boolean, + statusBarHeight: Dp, + sheetBackgroundColor: Color, + levelFilter: LogLevel?, + isSelectionActive: Boolean, + selectedCount: Int, + onBack: () -> Unit, + onLevelFilter: (LogLevel) -> Unit, + onShare: () -> Unit, + onClearSelection: () -> Unit, + onCopySelection: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + brush = + Brush.verticalGradient( + 0f to sheetBackgroundColor.copy(alpha = 0.7f), + 0.75f to sheetBackgroundColor.copy(alpha = 0.7f), + 1f to sheetBackgroundColor.copy(alpha = 0f), + ), + ).padding(top = statusBarHeight + 8.dp) + .padding(bottom = 16.dp) + .padding(horizontal = 8.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AnimatedContent( + targetState = isSelectionActive, + transitionSpec = { + (fadeIn() + slideInVertically { -it / 4 }) + .togetherWith(fadeOut() + slideOutVertically { -it / 4 }) + }, + label = "ToolbarModeTransition", + ) { selectionMode -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when { + selectionMode -> { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onClearSelection) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.log_viewer_clear_selection), + ) + } + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = pluralStringResource(R.plurals.log_viewer_selected_count, selectedCount, selectedCount), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onCopySelection) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.log_viewer_copy_selection), + ) + } + } + } + + else -> { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = stringResource(R.string.log_viewer_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onShare) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(R.string.log_viewer_share), + ) + } + } + } + } + } + } + + // Level filter chips + AnimatedVisibility(visible = !isSelectionActive) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LogLevel.entries.forEach { level -> + FilterChip( + selected = levelFilter == level, + onClick = { onLevelFilter(level) }, + label = { Text(level.name) }, + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) + } + } + } + } + } + } +} + +@Composable +private fun LogLineItem( + line: LogLine, + isSelected: Boolean, + onClick: () -> Unit, +) { + val backgroundColor = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + else -> Color.Transparent + } + Text( + text = formatLogLine(line), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + modifier = + Modifier + .fillMaxWidth() + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ).padding(horizontal = 8.dp, vertical = 1.dp), + ) +} + +@Composable +private fun formatLogLine(line: LogLine): AnnotatedString { + if (line.timestamp.isEmpty()) { + return AnnotatedString(line.raw) + } + + val timestampColor = MaterialTheme.colorScheme.outline + val threadColor = MaterialTheme.colorScheme.tertiary + val loggerColor = MaterialTheme.colorScheme.secondary + val messageColor = MaterialTheme.colorScheme.onSurface + val levelColor = when (line.level) { + LogLevel.TRACE -> MaterialTheme.colorScheme.onSurfaceVariant + LogLevel.DEBUG -> MaterialTheme.colorScheme.onSurface + LogLevel.INFO -> MaterialTheme.colorScheme.primary + LogLevel.WARN -> Color(0xFFFFA000) + LogLevel.ERROR -> MaterialTheme.colorScheme.error + } + + return buildAnnotatedString { + withStyle(SpanStyle(color = timestampColor)) { + append(line.timestamp) + } + append(' ') + withStyle(SpanStyle(color = threadColor)) { + append('[') + append(line.thread) + append(']') + } + append(' ') + withStyle(SpanStyle(color = levelColor, fontWeight = FontWeight.Bold)) { + append(line.level.name.padEnd(5)) + } + append(' ') + withStyle(SpanStyle(color = loggerColor)) { + append(line.logger) + } + append(" - ") + withStyle(SpanStyle(color = messageColor)) { + append(line.message) + } + } +} + +@Composable +private fun LogSearchToolbar( + state: TextFieldState, + enabled: Boolean = true, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val textFieldColors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) + + TextField( + state = state, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.log_viewer_search_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (state.text.isNotEmpty()) { + IconButton(onClick = { state.clearText() }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.dialog_dismiss), + ) + } + } + }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + onKeyboardAction = { keyboardController?.hide() }, + shape = MaterialTheme.shapes.extraLarge, + colors = textFieldColors, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerState.kt new file mode 100644 index 000000000..9b816cae4 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerState.kt @@ -0,0 +1,15 @@ +package com.flxrs.dankchat.ui.log + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.repo.log.LogLevel +import com.flxrs.dankchat.data.repo.log.LogLine +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class LogViewerState( + val lines: ImmutableList = persistentListOf(), + val levelFilter: LogLevel? = null, + val availableFiles: ImmutableList = persistentListOf(), + val selectedFileName: String = "", +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt new file mode 100644 index 000000000..3e73fa136 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/log/LogViewerViewModel.kt @@ -0,0 +1,126 @@ +package com.flxrs.dankchat.ui.log + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.flxrs.dankchat.data.repo.log.LogLevel +import com.flxrs.dankchat.data.repo.log.LogLine +import com.flxrs.dankchat.data.repo.log.LogLineParser +import com.flxrs.dankchat.data.repo.log.LogRepository +import com.flxrs.dankchat.ui.main.LogViewer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import java.io.File + +@KoinViewModel +class LogViewerViewModel( + savedStateHandle: SavedStateHandle, + private val logRepository: LogRepository, +) : ViewModel() { + val searchFieldState = TextFieldState() + + private val _levelFilter = MutableStateFlow(null) + private val _selectedFileName = MutableStateFlow("") + private val _selectedIndices = MutableStateFlow>(emptySet()) + val selectedIndices: StateFlow> = _selectedIndices.asStateFlow() + + init { + val route = savedStateHandle.toRoute() + val initialFile = route.fileName.takeIf { it.isNotEmpty() } + _selectedFileName.value = initialFile ?: logRepository + .getLogFiles() + .firstOrNull() + ?.name + .orEmpty() + + viewModelScope.launch { + combine(_levelFilter, snapshotFlow { searchFieldState.text.toString() }) { _, _ -> }.collect { + _selectedIndices.value = emptySet() + } + } + } + + val state: StateFlow = combine( + _selectedFileName, + _levelFilter, + snapshotFlow { searchFieldState.text.toString() }, + ) { fileName, levelFilter, searchQuery -> + val files = logRepository.getLogFiles() + val lines = if (fileName.isNotEmpty()) { + LogLineParser.parseAll(logRepository.readLogFile(fileName)).toImmutableList() + } else { + emptyList().toImmutableList() + } + val filtered = filterLines(lines, levelFilter, searchQuery) + LogViewerState( + lines = filtered, + levelFilter = levelFilter, + availableFiles = files.map { it.name }.toImmutableList(), + selectedFileName = fileName, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LogViewerState()) + + fun selectFile(fileName: String) { + _selectedFileName.value = fileName + } + + fun setLevelFilter(level: LogLevel?) { + _levelFilter.update { current -> + if (current == level) null else level + } + } + + fun toggleSelection(index: Int) { + _selectedIndices.update { current -> + when (index) { + in current -> current - index + else -> current + index + } + } + } + + fun clearSelection() { + _selectedIndices.value = emptySet() + } + + fun getSelectedLinesText(): String { + val lines = state.value.lines + return _selectedIndices.value + .sorted() + .mapNotNull { lines.getOrNull(it) } + .joinToString("\n") { it.raw } + } + + fun getShareableLogFile(): File? { + val fileName = _selectedFileName.value + if (fileName.isEmpty()) return null + return logRepository.getLogFile(fileName) + } + + private fun filterLines( + lines: ImmutableList, + levelFilter: LogLevel?, + searchQuery: String, + ): ImmutableList { + if (levelFilter == null && searchQuery.isBlank()) return lines + + return lines + .filter { line -> + val matchesLevel = levelFilter == null || line.level >= levelFilter + val matchesSearch = searchQuery.isBlank() || line.raw.contains(searchQuery, ignoreCase = true) + matchesLevel && matchesSearch + }.toImmutableList() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt new file mode 100644 index 000000000..5ec3fa7ba --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginScreen.kt @@ -0,0 +1,156 @@ +package com.flxrs.dankchat.ui.login + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.view.ViewGroup +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import com.flxrs.dankchat.R +import io.github.oshai.kotlinlogging.KotlinLogging +import org.koin.compose.viewmodel.koinViewModel + +private val logger = KotlinLogging.logger("LoginScreen") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onCancel: () -> Unit, +) { + val viewModel: LoginViewModel = koinViewModel() + var isLoading by remember { mutableStateOf(true) } + var isZoomedOut by remember { mutableStateOf(false) } + var webViewRef by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + if (event.successful) { + onLoginSuccess() + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.login)) }, + navigationIcon = { + IconButton(onClick = onCancel) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.dialog_cancel)) + } + }, + actions = { + IconButton(onClick = { + val newZoom = if (isZoomedOut) 100 else 50 + webViewRef?.settings?.textZoom = newZoom + isZoomedOut = !isZoomedOut + }) { + Icon( + imageVector = if (isZoomedOut) Icons.Default.ZoomIn else Icons.Default.ZoomOut, + contentDescription = stringResource(if (isZoomedOut) R.string.login_menu_zoom_in else R.string.login_menu_zoom_out), + ) + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = + Modifier + .padding(paddingValues) + .fillMaxSize(), + ) { + if (isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + AndroidView( + factory = { context -> + WebView(context).also { webViewRef = it }.apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + @SuppressLint("SetJavaScriptEnabled") + settings.javaScriptEnabled = true + settings.setSupportZoom(true) + + clearCache(true) + clearFormData() + + webViewClient = + object : WebViewClient() { + override fun onPageStarted( + view: WebView?, + url: String?, + favicon: Bitmap?, + ) { + isLoading = true + } + + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + isLoading = false + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val fragment = request?.url?.fragment ?: return false + viewModel.parseToken(fragment) + return true // Consume + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + logger.error { "Error: ${error?.description}" } + isLoading = false + } + } + + loadUrl(viewModel.loginUrl) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + + BackHandler { + onCancel() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt new file mode 100644 index 000000000..4e1cea1a5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/login/LoginViewModel.kt @@ -0,0 +1,64 @@ +package com.flxrs.dankchat.ui.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.api.auth.AuthApiClient +import com.flxrs.dankchat.data.api.auth.dto.ValidateDto +import com.flxrs.dankchat.data.auth.AuthDataStore +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +private val logger = KotlinLogging.logger("LoginViewModel") + +@KoinViewModel +class LoginViewModel( + private val authApiClient: AuthApiClient, + private val authDataStore: AuthDataStore, +) : ViewModel() { + data class TokenParseEvent( + val successful: Boolean, + ) + + private val eventChannel = Channel(Channel.BUFFERED) + val events = eventChannel.receiveAsFlow() + + val loginUrl = AuthApiClient.LOGIN_URL + + fun parseToken(fragment: String) = viewModelScope.launch { + if (!fragment.startsWith("access_token")) { + eventChannel.send(TokenParseEvent(successful = false)) + return@launch + } + + val token = + fragment + .substringAfter("access_token=") + .substringBefore("&scope=") + + val result = + authApiClient.validateUser(token).fold( + onSuccess = { saveLoginDetails(token, it) }, + onFailure = { + logger.error { "Failed to validate token: ${it.message}" } + TokenParseEvent(successful = false) + }, + ) + eventChannel.send(result) + } + + private suspend fun saveLoginDetails( + oAuth: String, + validateDto: ValidateDto, + ): TokenParseEvent { + authDataStore.login( + oAuthKey = oAuth, + userName = validateDto.login.value.lowercase(), + userId = validateDto.userId.value, + clientId = validateDto.clientId, + ) + return TokenParseEvent(successful = true) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt new file mode 100644 index 000000000..c0c138d1f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/DraggableHandle.kt @@ -0,0 +1,57 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +@Composable +fun DraggableHandle( + onDrag: (deltaPx: Float) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .width(24.dp) + .fillMaxHeight() + .pointerInput(Unit) { + detectHorizontalDragGestures { _, dragAmount -> + onDrag(dragAmount) + } + }, + ) { + Box( + modifier = + Modifier + .width(16.dp) + .height(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.85f), + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .width(4.dp) + .height(40.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(2.dp), + ), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt new file mode 100644 index 000000000..dfadfa71d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/EmptyStateContent.kt @@ -0,0 +1,83 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@Composable +fun EmptyStateContent( + isLoggedIn: Boolean, + onAddChannel: () -> Unit, + onLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_dank_chat_mono_cropped), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + contentDescription = null, + modifier = Modifier.size(128.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.no_channels_added_body), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(32.dp)) + + Button(onClick = onAddChannel) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.add_channel)) + } + + if (!isLoggedIn) { + Spacer(modifier = Modifier.height(12.dp)) + FilledTonalButton(onClick = onLogin) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text(stringResource(R.string.login)) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt new file mode 100644 index 000000000..0b93469bb --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/FloatingToolbar.kt @@ -0,0 +1,785 @@ +package com.flxrs.dankchat.ui.main + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.Badge +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.composables.core.ScrollArea +import com.composables.core.Thumb +import com.composables.core.VerticalScrollbar +import com.composables.core.rememberScrollAreaState +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState +import com.flxrs.dankchat.ui.main.stream.AudioOnlyBar +import com.flxrs.dankchat.utils.compose.predictiveBackScale +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlin.coroutines.cancellation.CancellationException + +@Suppress("MultipleEmitters") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun FloatingToolbar( + tabState: ChannelTabUiState, + composePagerState: PagerState, + showAppBar: Boolean, + isFullscreen: Boolean, + isLoggedIn: Boolean, + currentStream: UserName?, + isAudioOnly: Boolean, + streamHeightDp: Dp, + totalMentionCount: Int, + onAction: (ToolbarAction) -> Unit, + onAudioOnly: () -> Unit, + onStreamClose: () -> Unit, + modifier: Modifier = Modifier, + endAligned: Boolean = false, + showTabs: Boolean = true, + addChannelTooltipState: TooltipState? = null, + onAddChannelTooltipDismiss: () -> Unit = {}, + onSkipTour: () -> Unit = {}, + keyboardHeightDp: Dp = 0.dp, + isEmoteMenuOpen: Boolean = false, + onCloseEmoteMenu: () -> Unit = {}, + streamToolbarAlpha: Float = 1f, +) { + val density = LocalDensity.current + var showOverflowMenu by remember { mutableStateOf(false) } + var showQuickSwitch by remember { mutableStateOf(false) } + var overflowInitialMenu by remember { mutableStateOf(AppBarMenu.Main) } + + val totalTabs = tabState.tabs.size + val selectedIndex = composePagerState.currentPage + val tabScrollState = rememberScrollState() + rememberCoroutineScope() + + val hasOverflow by remember { derivedStateOf { tabScrollState.maxValue > 0 } } + + // Track tab positions after layout for centering calculations + val tabOffsets = remember { mutableStateOf(IntArray(0)) } + val tabWidths = remember { mutableStateOf(IntArray(0)) } + var tabViewportWidth by remember { mutableIntStateOf(0) } + + val keyboardController = LocalSoftwareKeyboardController.current + + // Reset menus when toolbar hides or keyboard opens + LaunchedEffect(showAppBar) { + if (!showAppBar) { + showOverflowMenu = false + showQuickSwitch = false + } + } + val isKeyboardOpen = keyboardHeightDp > 0.dp + LaunchedEffect(isKeyboardOpen) { + if (isKeyboardOpen) { + showOverflowMenu = false + showQuickSwitch = false + } + } + LaunchedEffect(isEmoteMenuOpen) { + if (isEmoteMenuOpen) { + showOverflowMenu = false + showQuickSwitch = false + } + } + + // Dismiss scrim for menus + if (showOverflowMenu || showQuickSwitch) { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + showOverflowMenu = false + showQuickSwitch = false + overflowInitialMenu = AppBarMenu.Main + }, + ) + } + + val hasStream = currentStream != null && streamHeightDp > 0.dp + + AnimatedVisibility( + visible = showAppBar && !isFullscreen, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = + modifier + .fillMaxWidth() + .padding(top = if (hasStream) streamHeightDp + 8.dp else 0.dp) + .graphicsLayer { alpha = streamToolbarAlpha }, + ) { + val scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + val statusBarPx = WindowInsets.statusBars.getTop(density).toFloat() + var toolbarRowHeight by remember { mutableFloatStateOf(0f) } + val scrimModifier = + if (hasStream) { + Modifier.fillMaxWidth() + } else { + Modifier + .fillMaxWidth() + .drawBehind { + if (toolbarRowHeight > 0f) { + val gradientHeight = statusBarPx + 8.dp.toPx() + toolbarRowHeight + 16.dp.toPx() + drawRect( + brush = + Brush.verticalGradient( + 0f to scrimColor, + 0.75f to scrimColor, + 1f to scrimColor.copy(alpha = 0f), + endY = gradientHeight, + ), + size = Size(size.width, gradientHeight), + ) + } + }.padding(top = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + 8.dp) + } + + Column(modifier = scrimModifier) { + if (currentStream != null && isAudioOnly) { + AudioOnlyBar( + channel = currentStream, + onExpandVideo = onAudioOnly, + onClose = onStreamClose, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + ) + } + Box { + // Center selected tab when selection changes + LaunchedEffect(selectedIndex, tabOffsets.value, tabWidths.value, tabViewportWidth) { + val offsets = tabOffsets.value + val widths = tabWidths.value + if (selectedIndex !in offsets.indices || tabViewportWidth <= 0) return@LaunchedEffect + + val tabOffset = offsets[selectedIndex] + val tabWidth = widths[selectedIndex] + val centeredOffset = tabOffset - (tabViewportWidth / 2 - tabWidth / 2) + val clampedOffset = centeredOffset.coerceIn(0, tabScrollState.maxValue) + if (tabScrollState.value != clampedOffset) { + tabScrollState.animateScrollTo(clampedOffset) + } + } + + // Mention indicators based on scroll position and tab positions + val hasLeftMention by remember(tabState.tabs) { + derivedStateOf { + val scrollPos = tabScrollState.value + val offsets = tabOffsets.value + val widths = tabWidths.value + tabState.tabs.indices.any { i -> + i < offsets.size && offsets[i] + widths[i] < scrollPos && tabState.tabs[i].mentionCount > 0 + } + } + } + val hasRightMention by remember(tabState.tabs) { + derivedStateOf { + val scrollPos = tabScrollState.value + val offsets = tabOffsets.value + tabState.tabs.indices.any { i -> + i < offsets.size && offsets[i] > scrollPos + tabViewportWidth && tabState.tabs[i].mentionCount > 0 + } + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .onSizeChanged { + val h = it.height.toFloat() + if (toolbarRowHeight == 0f || h < toolbarRowHeight) toolbarRowHeight = h + }, + verticalAlignment = Alignment.Top, + ) { + // Push action pill to end when no tabs are shown + if (endAligned && (!showTabs || tabState.tabs.isEmpty())) { + Spacer(modifier = Modifier.weight(1f)) + } + + // Scrollable tabs pill + AnimatedVisibility( + visible = showTabs && tabState.tabs.isNotEmpty(), + modifier = Modifier.weight(1f, fill = endAligned), + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(), + ) { + Column(modifier = if (endAligned) Modifier.fillMaxWidth() else Modifier) { + val mentionGradientColor = MaterialTheme.colorScheme.error + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = + Modifier + .clip(MaterialTheme.shapes.extraLarge) + .drawWithContent { + drawContent() + val gradientWidth = 24.dp.toPx() + if (hasLeftMention) { + drawRect( + brush = + Brush.horizontalGradient( + colors = + listOf( + mentionGradientColor.copy(alpha = 0.5f), + mentionGradientColor.copy(alpha = 0f), + ), + endX = gradientWidth, + ), + size = Size(gradientWidth, size.height), + ) + } + if (hasRightMention) { + drawRect( + brush = + Brush.horizontalGradient( + colors = + listOf( + mentionGradientColor.copy(alpha = 0f), + mentionGradientColor.copy(alpha = 0.5f), + ), + startX = size.width - gradientWidth, + endX = size.width, + ), + topLeft = Offset(size.width - gradientWidth, 0f), + size = Size(gradientWidth, size.height), + ) + } + }, + ) { + val pillColor = MaterialTheme.colorScheme.surfaceContainer + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 12.dp) + .onSizeChanged { tabViewportWidth = it.width } + .clipToBounds() + .horizontalScroll(tabScrollState), + ) { + tabState.tabs.forEachIndexed { index, tab -> + val isSelected = index == selectedIndex + val hasActivity = tab.mentionCount > 0 || tab.hasUnread + val textColor = + when { + isSelected -> MaterialTheme.colorScheme.primary + hasActivity -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .combinedClickable( + onClick = { onAction(ToolbarAction.SelectTab(index)) }, + onLongClick = { onAction(ToolbarAction.LongClickTab) }, + ).defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 12.dp) + .onGloballyPositioned { coords -> + val offsets = tabOffsets.value + tabWidths.value + if (offsets.size != totalTabs) { + tabOffsets.value = IntArray(totalTabs) + tabWidths.value = IntArray(totalTabs) + } + tabOffsets.value[index] = coords.positionInParent().x.toInt() + tabWidths.value[index] = coords.size.width + }, + ) { + Text( + text = tab.displayName, + color = textColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = when { + isSelected -> FontWeight.Bold + hasActivity -> FontWeight.SemiBold + else -> FontWeight.Normal + }, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + if (hasOverflow) { + Spacer(Modifier.width(18.dp)) + } + } + + // Quick switch dropdown indicator (overlays end of tabs) + if (hasOverflow) { + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .clickable { + showOverflowMenu = false + showQuickSwitch = !showQuickSwitch + keyboardController?.hide() + onCloseEmoteMenu() + }.defaultMinSize(minHeight = 48.dp) + .padding(start = 4.dp, end = 8.dp) + .drawBehind { + val fadeWidth = 12.dp.toPx() + drawRect( + brush = + Brush.horizontalGradient( + colors = listOf(pillColor.copy(alpha = 0f), pillColor.copy(alpha = 0.6f)), + endX = fadeWidth, + ), + size = Size(fadeWidth, size.height), + topLeft = Offset(-fadeWidth, 0f), + ) + drawRect(color = pillColor.copy(alpha = 0.6f)) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.manage_channels), + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Quick switch channel menu + AnimatedVisibility( + visible = showQuickSwitch, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = + Modifier + .padding(top = 4.dp) + .endAlignedOverflow(), + ) { + var quickSwitchBackProgress by remember { mutableFloatStateOf(0f) } + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.predictiveBackScale(quickSwitchBackProgress), + ) { + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + quickSwitchBackProgress = event.progress + } + showQuickSwitch = false + } catch (_: CancellationException) { + quickSwitchBackProgress = 0f + } + } + val screenHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } + val maxMenuHeight = screenHeight * 0.3f + val quickSwitchScrollState = rememberScrollState() + val quickSwitchScrollAreaState = rememberScrollAreaState(quickSwitchScrollState) + var itemHeightPx by remember { mutableIntStateOf(0) } + ScrollArea( + state = quickSwitchScrollAreaState, + modifier = + Modifier + .width(IntrinsicSize.Min) + .widthIn(min = 125.dp, max = 200.dp) + .heightIn(max = maxMenuHeight), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(quickSwitchScrollState) + .padding(vertical = 8.dp), + ) { + tabState.tabs.forEachIndexed { index, tab -> + val isSelected = index == selectedIndex + val hasActivity = tab.mentionCount > 0 || tab.hasUnread + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onAction(ToolbarAction.SelectTab(index)) + showQuickSwitch = false + }.padding(horizontal = 16.dp, vertical = 10.dp) + .then(if (index == 0) Modifier.onSizeChanged { itemHeightPx = it.height } else Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = tab.displayName, + style = MaterialTheme.typography.bodyLarge, + color = + when { + isSelected -> MaterialTheme.colorScheme.primary + hasActivity -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = + when { + isSelected -> FontWeight.Bold + hasActivity -> FontWeight.SemiBold + else -> FontWeight.Normal + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tab.mentionCount > 0) { + Spacer(Modifier.width(8.dp)) + Badge() + } + } + } + } + if (quickSwitchScrollState.maxValue > itemHeightPx) { + VerticalScrollbar( + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(3.dp) + .padding(vertical = 2.dp), + ) { + Thumb( + Modifier.background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(100), + ), + ) + } + } + } + } + } + } + } + + // Action icons + inline overflow menu + Row(verticalAlignment = Alignment.Top) { + Spacer(Modifier.width(8.dp)) + + Column(modifier = Modifier.width(IntrinsicSize.Min)) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Reserve space at start when menu is open and not logged in, + // so the pill matches the 3-icon width and icons stay end-aligned + if (!isLoggedIn && showOverflowMenu) { + Spacer(modifier = Modifier.width(48.dp)) + } + val addChannelIcon: @Composable () -> Unit = { + IconButton(onClick = { onAction(ToolbarAction.AddChannel) }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_channel), + ) + } + } + if (addChannelTooltipState != null) { + LaunchedEffect(Unit) { + addChannelTooltipState.show() + } + LaunchedEffect(Unit) { + snapshotFlow { addChannelTooltipState.isVisible } + .dropWhile { !it } // skip initial false + .first { !it } // wait for dismiss (any cause) + onAddChannelTooltipDismiss() + } + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + val tourColors = + TooltipDefaults.richTooltipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + actionContentColor = MaterialTheme.colorScheme.secondary, + ) + RichTooltip( + colors = tourColors, + caretShape = TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)), + action = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { + addChannelTooltipState.dismiss() + onAddChannelTooltipDismiss() + onSkipTour() + }) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = { + addChannelTooltipState.dismiss() + onAddChannelTooltipDismiss() + }) { + Text(stringResource(R.string.tour_next)) + } + } + }, + ) { + Text(stringResource(R.string.tour_add_more_channels_hint)) + } + }, + state = addChannelTooltipState, + hasAction = true, + ) { + addChannelIcon() + } + } else { + addChannelIcon() + } + if (isLoggedIn) { + IconButton(onClick = { onAction(ToolbarAction.OpenMentions) }) { + Icon( + imageVector = when { + totalMentionCount > 0 -> Icons.Default.Notifications + else -> Icons.Outlined.Notifications + }, + contentDescription = stringResource(R.string.mentions_title), + tint = + if (totalMentionCount > 0) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + }, + ) + } + } + IconButton(onClick = { + showQuickSwitch = false + overflowInitialMenu = AppBarMenu.Main + showOverflowMenu = !showOverflowMenu + keyboardController?.hide() + onCloseEmoteMenu() + }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + ) + } + } + } + + AnimatedVisibility( + visible = showOverflowMenu, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = + Modifier + .skipIntrinsicHeight() + .padding(top = 4.dp) + .endAlignedOverflow(), + ) { + InlineOverflowMenu( + isLoggedIn = isLoggedIn, + onDismiss = { + showOverflowMenu = false + overflowInitialMenu = AppBarMenu.Main + }, + initialMenu = overflowInitialMenu, + onAction = onAction, + keyboardHeightDp = keyboardHeightDp, + ) + } + } + } + } + } + } + } +} + +/** + * Allows the child to measure at its natural width (up to 3x parent width) + * without affecting the parent Column's width. + * Reports 0 intrinsic width so [IntrinsicSize.Min] ignores this child. + * Places the child end-aligned (right edge matches parent right edge). + */ +private fun Modifier.endAlignedOverflow() = this.then( + object : LayoutModifier { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val parentWidth = constraints.maxWidth + val placeable = + measurable.measure( + constraints.copy(minWidth = 0, maxWidth = (parentWidth * 3).coerceAtMost(MAX_LAYOUT_SIZE)), + ) + return layout(parentWidth, placeable.height) { + placeable.place(parentWidth - placeable.width, 0) + } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = 0 + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = 0 + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = measurable.minIntrinsicHeight(width) + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = measurable.maxIntrinsicHeight(width) + }, +) + +/** + * Prevents intrinsic height queries from propagating to children. + * Needed because [com.composables.core.ScrollArea] crashes on intrinsic height measurement, + * and [IntrinsicSize.Min] on a parent Column triggers these queries. + */ +private fun Modifier.skipIntrinsicHeight() = this.then( + object : LayoutModifier { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = 0 + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurable: IntrinsicMeasurable, + width: Int, + ): Int = 0 + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = measurable.minIntrinsicWidth(height) + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurable: IntrinsicMeasurable, + height: Int, + ): Int = measurable.maxIntrinsicWidth(height) + }, +) + +private const val MAX_LAYOUT_SIZE = 16_777_215 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt new file mode 100644 index 000000000..1b27a1898 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/InputState.kt @@ -0,0 +1,18 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.runtime.Stable + +@Stable +sealed interface InputState { + object Default : InputState + + object Replying : InputState + + object Announcing : InputState + + object Whispering : InputState + + object NotLoggedIn : InputState + + object Disconnected : InputState +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt new file mode 100644 index 000000000..6b17cb240 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainActivity.kt @@ -0,0 +1,660 @@ +package com.flxrs.dankchat.ui.main + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.flxrs.dankchat.BuildConfig +import com.flxrs.dankchat.DankChatViewModel +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.api.ApiException +import com.flxrs.dankchat.data.notification.ChatTTSPlayer +import com.flxrs.dankchat.data.notification.NotificationService +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.repo.data.ServiceEvent +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.about.AboutScreen +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsScreen +import com.flxrs.dankchat.preferences.appearance.ThemePreference +import com.flxrs.dankchat.preferences.chat.ChatSettingsScreen +import com.flxrs.dankchat.preferences.chat.commands.CustomCommandsScreen +import com.flxrs.dankchat.preferences.chat.userdisplay.UserDisplayScreen +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsScreen +import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsScreen +import com.flxrs.dankchat.preferences.notifications.highlights.HighlightsScreen +import com.flxrs.dankchat.preferences.notifications.ignores.IgnoresScreen +import com.flxrs.dankchat.preferences.overview.OverviewSettingsScreen +import com.flxrs.dankchat.preferences.overview.SettingsNavigation +import com.flxrs.dankchat.preferences.stream.StreamsSettingsScreen +import com.flxrs.dankchat.preferences.tools.ToolsSettingsScreen +import com.flxrs.dankchat.preferences.tools.tts.TTSUserIgnoreListScreen +import com.flxrs.dankchat.preferences.tools.upload.ImageUploaderScreen +import com.flxrs.dankchat.ui.changelog.ChangelogScreen +import com.flxrs.dankchat.ui.crash.CrashViewerSheet +import com.flxrs.dankchat.ui.log.LogViewerSheet +import com.flxrs.dankchat.ui.login.LoginScreen +import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore +import com.flxrs.dankchat.ui.onboarding.OnboardingScreen +import com.flxrs.dankchat.ui.theme.DankChatTheme +import com.flxrs.dankchat.utils.createMediaFile +import com.flxrs.dankchat.utils.extensions.hasPermission +import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu +import com.flxrs.dankchat.utils.extensions.isInSupportedPictureInPictureMode +import com.flxrs.dankchat.utils.extensions.keepScreenOn +import com.flxrs.dankchat.utils.extensions.parcelable +import com.flxrs.dankchat.utils.removeExifAttributes +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.IOException + +private val logger = KotlinLogging.logger("MainActivity") + +class MainActivity : ComponentActivity() { + private val viewModel: DankChatViewModel by viewModel() + private val dankChatPreferenceStore: DankChatPreferenceStore by inject() + private val appearanceSettingsDataStore: AppearanceSettingsDataStore by inject() + private val mainEventBus: MainEventBus by inject() + private val onboardingDataStore: OnboardingDataStore by inject() + private val dataRepository: DataRepository by inject() + private val chatTTSPlayer: ChatTTSPlayer by inject() + private val dispatchersProvider: DispatchersProvider by inject() + private var currentMediaUri: Uri = Uri.EMPTY + + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + startService() + } + + private val requestImageCapture = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = true) + } + + private val requestVideoCapture = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) handleCaptureRequest(imageCapture = false) + } + + private val requestGalleryMedia = + registerForActivityResult(PickVisualMedia()) { uri -> + uri ?: return@registerForActivityResult + val contentResolver = contentResolver + val mimeType = contentResolver.getType(uri) + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) + if (extension == null) { + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadFailed(getString(R.string.snackbar_upload_failed), createMediaFile(this@MainActivity), false)) } + return@registerForActivityResult + } + + val copy = createMediaFile(this, extension) + try { + contentResolver.openInputStream(uri)?.use { input -> copy.outputStream().use { input.copyTo(it) } } + if (copy.extension == "jpg" || copy.extension == "jpeg") { + copy.removeExifAttributes() + } + uploadMedia(copy, imageCapture = false) + } catch (_: Throwable) { + copy.delete() + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadFailed(null, copy, false)) } + } + } + + private val twitchServiceConnection = TwitchServiceConnection() + private var notificationService: NotificationService? = null + private var isBound = false + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + + val theme = appearanceSettingsDataStore.current().theme + val systemDark = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val isDark = when (theme) { + ThemePreference.Dark -> true + ThemePreference.Light -> false + ThemePreference.System -> systemDark + } + val barStyle = when { + isDark -> SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + else -> SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + } + enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle) + window.isNavigationBarContrastEnforced = false + + super.onCreate(savedInstanceState) + + chatTTSPlayer.start() + setupComposeUi() + intent.parcelable(OPEN_CHANNEL_KEY)?.let { channel -> + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channel)) } + } + + viewModel.checkLogin() + viewModel.serviceEvents + .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) + .onEach { + logger.info { "Received service event: $it" } + when (it) { + ServiceEvent.Shutdown -> handleShutDown() + } + }.launchIn(lifecycleScope) + + viewModel.keepScreenOn + .flowWithLifecycle(lifecycle, minActiveState = Lifecycle.State.CREATED) + .onEach { + logger.info { "Setting FLAG_KEEP_SCREEN_ON to $it" } + keepScreenOn(it) + }.launchIn(lifecycleScope) + } + + private fun setupComposeUi() { + setContent { + val navController = rememberNavController() + DankChatTheme { + val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle( + initialValue = dankChatPreferenceStore.isLoggedIn, + ) + + val onboardingCompleted = onboardingDataStore.current().hasCompletedOnboarding + + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + NavHost( + navController = navController, + startDestination = if (onboardingCompleted) Main else Onboarding, + ) { + composable( + enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, + exitTransition = { fadeOut(animationSpec = tween(90)) }, + popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, + popExitTransition = { fadeOut(animationSpec = tween(90)) }, + ) { + OnboardingScreen( + onNavigateToLogin = { + navController.navigate(Login) + }, + onComplete = { + navController.navigate(Main) { + popUpTo(Onboarding) { inclusive = true } + } + }, + ) + } + composable
( + exitTransition = { scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) }, + popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)) }, + ) { + MainScreen( + isLoggedIn = isLoggedIn, + onNavigateToSettings = { + navController.navigate(Settings) + }, + onLogin = { + navController.navigate(Login) + }, + onRelogin = { + navController.navigate(Login) + }, + onLogout = { + viewModel.clearDataForLogout() + }, + onOpenChannel = { + val channel = viewModel.activeChannel.value ?: return@MainScreen + val url = "https://twitch.tv/$channel" + Intent(Intent.ACTION_VIEW).also { + it.data = url.toUri() + startActivity(it) + } + }, + onReportChannel = { + val channel = viewModel.activeChannel.value ?: return@MainScreen + val url = "https://twitch.tv/$channel/report" + Intent(Intent.ACTION_VIEW).also { + it.data = url.toUri() + startActivity(it) + } + }, + onOpenUrl = { url: String -> + Intent(Intent.ACTION_VIEW).also { + it.data = url.toUri() + startActivity(it) + } + }, + onCaptureImage = { + startCameraCapture(captureVideo = false) + }, + onCaptureVideo = { + startCameraCapture(captureVideo = true) + }, + onChooseMedia = { + requestGalleryMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) + }, + onOpenLogViewer = { + navController.navigate(LogViewer()) + }, + ) + } + composable( + enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, + exitTransition = { fadeOut(animationSpec = tween(90)) }, + popEnterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, + popExitTransition = { fadeOut(animationSpec = tween(90)) }, + ) { + LoginScreen( + onLoginSuccess = { navController.popBackStack() }, + onCancel = { navController.popBackStack() }, + ) + } + composable( + enterTransition = { slideInHorizontally(initialOffsetX = { it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(200)) }, + exitTransition = { scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) }, + popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)) }, + popExitTransition = { scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) }, + ) { + OverviewSettingsScreen( + isLoggedIn = isLoggedIn, + hasChangelog = com.flxrs.dankchat.ui.changelog.DankChatVersion.HAS_CHANGELOG, + onBack = { navController.popBackStack() }, + onLogout = { + lifecycleScope.launch { + mainEventBus.emitEvent(MainEvent.LogOutRequested) + navController.popBackStack() + } + }, + onNavigate = { destination -> + when (destination) { + SettingsNavigation.Appearance -> navController.navigate(AppearanceSettings) + SettingsNavigation.Notifications -> navController.navigate(NotificationsSettings) + SettingsNavigation.Chat -> navController.navigate(ChatSettings) + SettingsNavigation.Streams -> navController.navigate(StreamsSettings) + SettingsNavigation.Tools -> navController.navigate(ToolsSettings) + SettingsNavigation.Developer -> navController.navigate(DeveloperSettings) + SettingsNavigation.Changelog -> navController.navigate(ChangelogSettings) + SettingsNavigation.About -> navController.navigate(AboutSettings) + } + }, + ) + } + + val subEnter: @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition = { + slideInHorizontally(initialOffsetX = { it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(200)) + } + val subExit: @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition = { + scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) + } + val subPopEnter: @JvmSuppressWildcards AnimatedContentTransitionScope.() -> EnterTransition = { + slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)) + } + val subPopExit: @JvmSuppressWildcards AnimatedContentTransitionScope.() -> ExitTransition = { + scaleOut(targetScale = 0.92f, animationSpec = tween(300)) + fadeOut(animationSpec = tween(200)) + } + + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + AppearanceSettingsScreen( + onBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + NotificationsSettingsScreen( + onNavToHighlights = { navController.navigate(HighlightsSettings) }, + onNavToIgnores = { navController.navigate(IgnoresSettings) }, + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + HighlightsScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + IgnoresScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + ChatSettingsScreen( + onNavToCommands = { navController.navigate(CustomCommandsSettings) }, + onNavToUserDisplays = { navController.navigate(UserDisplaySettings) }, + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + CustomCommandsScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + UserDisplayScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + StreamsSettingsScreen( + onBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + ToolsSettingsScreen( + onNavToImageUploader = { navController.navigate(ImageUploaderSettings) }, + onNavToTTSUserIgnoreList = { navController.navigate(TTSUserIgnoreListSettings) }, + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + ImageUploaderScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + TTSUserIgnoreListScreen( + onNavBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + DeveloperSettingsScreen( + onBack = { navController.popBackStack() }, + onOpenLogViewer = { fileName -> navController.navigate(LogViewer(fileName)) }, + onOpenCrashViewer = { crashId -> navController.navigate(CrashViewer(crashId)) }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + CrashViewerSheet( + onDismiss = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + LogViewerSheet( + onDismiss = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + ChangelogScreen( + onBack = { navController.popBackStack() }, + ) + } + composable( + enterTransition = subEnter, + exitTransition = subExit, + popEnterTransition = subPopEnter, + popExitTransition = subPopExit, + ) { + AboutScreen( + onBack = { navController.popBackStack() }, + ) + } + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + if (!isChangingConfigurations && !isInSupportedPictureInPictureMode) { + handleShutDown() + } + } + + @SuppressLint("InlinedApi") + override fun onStart() { + super.onStart() + val hasCompletedOnboarding = onboardingDataStore.current().hasCompletedOnboarding + val needsNotificationPermission = hasCompletedOnboarding && isAtLeastTiramisu && !hasPermission(Manifest.permission.POST_NOTIFICATIONS) + when { + needsNotificationPermission -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + else -> startService() + } + } + + private fun startService() { + if (!isBound) { + Intent(this, NotificationService::class.java).also { + try { + isBound = true + ContextCompat.startForegroundService(this, it) + bindService(it, twitchServiceConnection, BIND_AUTO_CREATE) + } catch (t: Throwable) { + logger.error(t) { "Failed to start foreground service" } + } + } + } + } + + override fun onStop() { + super.onStop() + if (isBound) { + isBound = false + try { + unbindService(twitchServiceConnection) + } catch (t: Throwable) { + logger.error(t) { "Failed to unbind service" } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val channelExtra = intent.parcelable(OPEN_CHANNEL_KEY) ?: return + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.OpenChannel(channelExtra)) } + } + + fun clearNotificationsOfChannel(channel: UserName) { + notificationService?.clearNotificationsForChannel(channel) + } + + private fun handleShutDown() { + stopService(Intent(this, NotificationService::class.java)) + finish() + android.os.Process.killProcess(android.os.Process.myPid()) + } + + private fun startCameraCapture(captureVideo: Boolean = false) { + val (action, extension) = + when { + captureVideo -> MediaStore.ACTION_VIDEO_CAPTURE to "mp4" + else -> MediaStore.ACTION_IMAGE_CAPTURE to "jpg" + } + Intent(action).also { captureIntent -> + captureIntent.resolveActivity(packageManager)?.also { + try { + createMediaFile(this, extension).apply { currentMediaUri = toUri() } + } catch (_: IOException) { + null + }?.also { file -> + val uri = FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", file) + captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri) + when { + captureVideo -> requestVideoCapture.launch(captureIntent) + else -> requestImageCapture.launch(captureIntent) + } + } + } + } + } + + private fun handleCaptureRequest(imageCapture: Boolean) { + if (currentMediaUri == Uri.EMPTY) return + var mediaFile: java.io.File? = null + + try { + mediaFile = currentMediaUri.toFile() + currentMediaUri = Uri.EMPTY + uploadMedia(mediaFile, imageCapture) + } catch (_: IOException) { + currentMediaUri = Uri.EMPTY + mediaFile?.delete() + lifecycleScope.launch { mainEventBus.emitEvent(MainEvent.UploadFailed(null, mediaFile ?: return@launch, imageCapture)) } + } + } + + private fun uploadMedia( + file: java.io.File, + imageCapture: Boolean, + ) { + lifecycleScope.launch { + mainEventBus.emitEvent(MainEvent.UploadLoading) + withContext(dispatchersProvider.io) { + if (imageCapture) { + runCatching { file.removeExifAttributes() } + } + } + val result = withContext(dispatchersProvider.io) { dataRepository.uploadMedia(file) } + result.fold( + onSuccess = { url -> + file.delete() + mainEventBus.emitEvent(MainEvent.UploadSuccess(url)) + }, + onFailure = { throwable -> + val message = + when (throwable) { + is ApiException -> "${throwable.status} ${throwable.message}" + else -> throwable.message + } + mainEventBus.emitEvent(MainEvent.UploadFailed(message, file, imageCapture)) + }, + ) + } + } + + private inner class TwitchServiceConnection : ServiceConnection { + override fun onServiceConnected( + className: ComponentName, + service: IBinder, + ) { + val binder = service as NotificationService.LocalBinder + notificationService = binder.service + isBound = true + } + + override fun onServiceDisconnected(className: ComponentName?) { + notificationService = null + isBound = false + } + } + + companion object { + const val OPEN_CHANNEL_KEY = "open_channel" + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt new file mode 100644 index 000000000..a368dd4d1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainAppBar.kt @@ -0,0 +1,495 @@ +package com.flxrs.dankchat.ui.main + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RemoveCircleOutline +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composables.core.ScrollArea +import com.composables.core.Thumb +import com.composables.core.VerticalScrollbar +import com.composables.core.rememberScrollAreaState +import com.flxrs.dankchat.R +import com.flxrs.dankchat.utils.compose.predictiveBackScale +import kotlinx.coroutines.CancellationException + +@Immutable +sealed interface AppBarMenu { + data object Main : AppBarMenu + + data object Upload : AppBarMenu + + data object Channel : AppBarMenu +} + +@Composable +fun InlineOverflowMenu( + isLoggedIn: Boolean, + onDismiss: () -> Unit, + onAction: (ToolbarAction) -> Unit, + initialMenu: AppBarMenu = AppBarMenu.Main, + keyboardHeightDp: Dp = 0.dp, +) { + var currentMenu by remember(initialMenu) { mutableStateOf(initialMenu) } + var backProgress by remember { mutableFloatStateOf(0f) } + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + when (currentMenu) { + AppBarMenu.Main -> { + onDismiss() + } + + else -> { + backProgress = 0f + currentMenu = AppBarMenu.Main + } + } + } catch (_: CancellationException) { + backProgress = 0f + } + } + + val density = LocalDensity.current + val menuWidth = rememberMainMenuWidth(isLoggedIn) + + val screenHeight = + with(density) { + LocalWindowInfo.current.containerSize.height + .toDp() + } + val maxHeight = (screenHeight - keyboardHeightDp) * 0.4f + val scrollState = rememberScrollState() + val scrollAreaState = rememberScrollAreaState(scrollState) + var itemHeightPx by remember { mutableIntStateOf(0) } + val measureModifier = Modifier.onSizeChanged { if (itemHeightPx == 0) itemHeightPx = it.height } + + LaunchedEffect(currentMenu) { + scrollState.scrollTo(0) + } + + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.predictiveBackScale(backProgress), + ) { + ScrollArea( + state = scrollAreaState, + modifier = + Modifier + .width(menuWidth) + .heightIn(max = maxHeight), + ) { + AnimatedContent( + targetState = currentMenu, + transitionSpec = { + if (targetState != AppBarMenu.Main) { + (slideInHorizontally { it } + fadeIn()).togetherWith(slideOutHorizontally { -it } + fadeOut()) + } else { + (slideInHorizontally { -it } + fadeIn()).togetherWith(slideOutHorizontally { it } + fadeOut()) + }.using(SizeTransform(clip = false)) + }, + label = "InlineMenuTransition", + ) { menu -> + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(vertical = 8.dp), + ) { + when (menu) { + AppBarMenu.Main -> MainMenuContent( + isLoggedIn = isLoggedIn, + onAction = onAction, + onDismiss = onDismiss, + onNavigateToUpload = { currentMenu = AppBarMenu.Upload }, + onNavigateToChannel = { currentMenu = AppBarMenu.Channel }, + modifier = measureModifier, + ) + + AppBarMenu.Upload -> UploadMenuContent( + onAction = onAction, + onDismiss = onDismiss, + onBack = { currentMenu = AppBarMenu.Main }, + modifier = measureModifier, + ) + + AppBarMenu.Channel -> ChannelMenuContent( + isLoggedIn = isLoggedIn, + onAction = onAction, + onDismiss = onDismiss, + onBack = { currentMenu = AppBarMenu.Main }, + modifier = measureModifier, + ) + } + } + } + if (scrollState.maxValue > itemHeightPx) { + VerticalScrollbar( + modifier = + Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(3.dp) + .padding(vertical = 2.dp), + ) { + Thumb( + Modifier.background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(100), + ), + ) + } + } + } + } +} + +@Composable +private fun InlineMenuItem( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + maxLines: Int = 1, + hasSubMenu: Boolean = false, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (hasSubMenu) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun InlineSubMenuHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun ColumnScope.MainMenuContent( + isLoggedIn: Boolean, + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onNavigateToUpload: () -> Unit, + onNavigateToChannel: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!isLoggedIn) { + InlineMenuItem( + text = stringResource(R.string.login), + icon = Icons.AutoMirrored.Filled.Login, + onClick = { + onAction(ToolbarAction.Login) + onDismiss() + }, + modifier = modifier, + ) + } else { + InlineMenuItem( + text = stringResource(R.string.relogin), + icon = Icons.Default.Refresh, + onClick = { + onAction(ToolbarAction.Relogin) + onDismiss() + }, + modifier = modifier, + ) + InlineMenuItem( + text = stringResource(R.string.logout), + icon = Icons.AutoMirrored.Filled.Logout, + onClick = { + onAction(ToolbarAction.Logout) + onDismiss() + }, + ) + } + + HorizontalDivider() + + InlineMenuItem( + text = stringResource(R.string.manage_channels), + icon = Icons.Default.EditNote, + onClick = { + onAction(ToolbarAction.ManageChannels) + onDismiss() + }, + ) + InlineMenuItem( + text = stringResource(R.string.remove_channel), + icon = Icons.Default.RemoveCircleOutline, + onClick = { + onAction(ToolbarAction.RemoveChannel) + onDismiss() + }, + ) + InlineMenuItem( + text = stringResource(R.string.reload_emotes), + icon = Icons.Default.EmojiEmotions, + onClick = { + onAction(ToolbarAction.ReloadEmotes) + onDismiss() + }, + ) + InlineMenuItem( + text = stringResource(R.string.reconnect), + icon = Icons.Default.Autorenew, + onClick = { + onAction(ToolbarAction.Reconnect) + onDismiss() + }, + ) + + HorizontalDivider() + + InlineMenuItem( + text = stringResource(R.string.upload_media), + icon = Icons.Default.CloudUpload, + onClick = onNavigateToUpload, + hasSubMenu = true, + ) + InlineMenuItem( + text = stringResource(R.string.channel), + icon = Icons.Default.Info, + onClick = onNavigateToChannel, + hasSubMenu = true, + ) + + HorizontalDivider() + + InlineMenuItem( + text = stringResource(R.string.settings), + icon = Icons.Default.Settings, + onClick = { + onAction(ToolbarAction.OpenSettings) + onDismiss() + }, + ) +} + +@Composable +private fun ColumnScope.UploadMenuContent( + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + InlineSubMenuHeader(title = stringResource(R.string.upload_media), onBack = onBack) + InlineMenuItem( + text = stringResource(R.string.take_picture), + icon = Icons.Default.CameraAlt, + onClick = { + onAction(ToolbarAction.CaptureImage) + onDismiss() + }, + modifier = modifier, + maxLines = 2, + ) + InlineMenuItem( + text = stringResource(R.string.record_video), + icon = Icons.Default.Videocam, + onClick = { + onAction(ToolbarAction.CaptureVideo) + onDismiss() + }, + maxLines = 2, + ) + InlineMenuItem( + text = stringResource(R.string.choose_media), + icon = Icons.Default.Image, + onClick = { + onAction(ToolbarAction.ChooseMedia) + onDismiss() + }, + maxLines = 2, + ) +} + +@Composable +private fun ColumnScope.ChannelMenuContent( + isLoggedIn: Boolean, + onAction: (ToolbarAction) -> Unit, + onDismiss: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + InlineSubMenuHeader(title = stringResource(R.string.channel), onBack = onBack) + InlineMenuItem( + text = stringResource(R.string.open_channel), + icon = Icons.Default.OpenInBrowser, + onClick = { + onAction(ToolbarAction.OpenChannel) + onDismiss() + }, + modifier = modifier, + maxLines = 2, + ) + InlineMenuItem( + text = stringResource(R.string.report_channel), + icon = Icons.Default.Flag, + onClick = { + onAction(ToolbarAction.ReportChannel) + onDismiss() + }, + maxLines = 2, + ) + if (isLoggedIn) { + InlineMenuItem( + text = stringResource(R.string.block_channel), + icon = Icons.Default.Block, + onClick = { + onAction(ToolbarAction.BlockChannel) + onDismiss() + }, + maxLines = 2, + ) + } +} + +private val MENU_ITEM_CHROME_WIDTH = (16 + 20 + 12 + 20 + 16).dp // padding + icon + gap + submenu arrow + padding +private val MIN_MENU_WIDTH = 200.dp + +@Composable +private fun rememberMainMenuWidth(isLoggedIn: Boolean): Dp { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val textStyle = MaterialTheme.typography.bodyLarge + + val mainItemTexts = buildList { + if (isLoggedIn) { + add(stringResource(R.string.relogin)) + add(stringResource(R.string.logout)) + } else { + add(stringResource(R.string.login)) + } + add(stringResource(R.string.manage_channels)) + add(stringResource(R.string.remove_channel)) + add(stringResource(R.string.reload_emotes)) + add(stringResource(R.string.reconnect)) + add(stringResource(R.string.upload_media)) + add(stringResource(R.string.channel)) + add(stringResource(R.string.settings)) + } + + return remember(mainItemTexts, density, textStyle) { + val maxTextWidthPx = mainItemTexts.maxOf { textMeasurer.measure(it, textStyle).size.width } + val totalWidthPx = with(density) { MENU_ITEM_CHROME_WIDTH.roundToPx() } + maxTextWidthPx + maxOf(with(density) { totalWidthPx.toDp() }, MIN_MENU_WIDTH) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt new file mode 100644 index 000000000..047586ea8 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainDestination.kt @@ -0,0 +1,70 @@ +package com.flxrs.dankchat.ui.main + +import kotlinx.serialization.Serializable + +@Serializable +object Main + +@Serializable +object Settings + +@Serializable +object AppearanceSettings + +@Serializable +object NotificationsSettings + +@Serializable +object HighlightsSettings + +@Serializable +object IgnoresSettings + +@Serializable +object ChatSettings + +@Serializable +object CustomCommandsSettings + +@Serializable +object UserDisplaySettings + +@Serializable +object StreamsSettings + +@Serializable +object ToolsSettings + +@Serializable +object ImageUploaderSettings + +@Serializable +object TTSUserIgnoreListSettings + +@Serializable +object DeveloperSettings + +@Serializable +object ChangelogSettings + +@Serializable +object AboutSettings + +@Serializable +object EmoteMenu + +@Serializable +object Login + +@Serializable +object Onboarding + +@Serializable +data class LogViewer( + val fileName: String = "", +) + +@Serializable +data class CrashViewer( + val crashId: Long = 0L, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt new file mode 100644 index 000000000..36fdb9891 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEvent.kt @@ -0,0 +1,34 @@ +package com.flxrs.dankchat.ui.main + +import com.flxrs.dankchat.data.UserName +import java.io.File + +sealed interface MainEvent { + data class Error( + val throwable: Throwable, + ) : MainEvent + + data object LogOutRequested : MainEvent + + data object UploadLoading : MainEvent + + data class UploadSuccess( + val url: String, + ) : MainEvent + + data class UploadFailed( + val errorMessage: String?, + val mediaFile: File, + val imageCapture: Boolean, + ) : MainEvent + + data class OpenChannel( + val channel: UserName, + ) : MainEvent + + data class MessageCopied( + val text: String, + ) : MainEvent + + data object MessageIdCopied : MainEvent +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt new file mode 100644 index 000000000..506bddf3e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainEventBus.kt @@ -0,0 +1,15 @@ +package com.flxrs.dankchat.ui.main + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import org.koin.core.annotation.Single + +@Single +class MainEventBus { + private val _events = Channel(Channel.BUFFERED) + val events = _events.receiveAsFlow() + + suspend fun emitEvent(event: MainEvent) { + _events.send(event) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt new file mode 100644 index 000000000..9377a7669 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreen.kt @@ -0,0 +1,1321 @@ +package com.flxrs.dankchat.ui.main + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.ui.chat.FabMenuCallbacks +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.emote.LocalEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.emote.rememberEmoteAnimationCoordinator +import com.flxrs.dankchat.ui.chat.history.HistoryChannel +import com.flxrs.dankchat.ui.chat.mention.MentionViewModel +import com.flxrs.dankchat.ui.chat.messages.common.launchCustomTab +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion +import com.flxrs.dankchat.ui.chat.swipeDownToHide +import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel +import com.flxrs.dankchat.ui.main.channel.ChannelPagerUiState +import com.flxrs.dankchat.ui.main.channel.ChannelPagerViewModel +import com.flxrs.dankchat.ui.main.channel.ChannelTabViewModel +import com.flxrs.dankchat.ui.main.dialog.DialogStateViewModel +import com.flxrs.dankchat.ui.main.dialog.MainScreenDialogs +import com.flxrs.dankchat.ui.main.input.ChatBottomBar +import com.flxrs.dankchat.ui.main.input.ChatInputCallbacks +import com.flxrs.dankchat.ui.main.input.ChatInputViewModel +import com.flxrs.dankchat.ui.main.input.InputOverlay +import com.flxrs.dankchat.ui.main.input.SuggestionDropdown +import com.flxrs.dankchat.ui.main.input.TourOverlayState +import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetOverlay +import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState +import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel +import com.flxrs.dankchat.ui.main.stream.StreamView +import com.flxrs.dankchat.ui.main.stream.StreamViewModel +import com.flxrs.dankchat.ui.tour.FeatureTourUiState +import com.flxrs.dankchat.ui.tour.FeatureTourViewModel +import com.flxrs.dankchat.ui.tour.PostOnboardingStep +import com.flxrs.dankchat.ui.tour.TourStep +import com.flxrs.dankchat.utils.compose.rememberRoundedCornerBottomPadding +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +private val ROUNDED_CORNER_THRESHOLD = 8.dp +private const val MIN_VISIBLE_MESSAGE_LINES = 9 + +@Suppress("ModifierNotUsedAtRoot") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MainScreen( + isLoggedIn: Boolean, + onNavigateToSettings: () -> Unit, + onLogin: () -> Unit, + onRelogin: () -> Unit, + onLogout: () -> Unit, + onOpenChannel: () -> Unit, + onReportChannel: () -> Unit, + onOpenUrl: (String) -> Unit, + onCaptureImage: () -> Unit, + onCaptureVideo: () -> Unit, + onChooseMedia: () -> Unit, + modifier: Modifier = Modifier, + onOpenLogViewer: () -> Unit = {}, +) { + val density = LocalDensity.current + val messageNotInHistoryMsg = stringResource(R.string.message_not_in_history) + val mainScreenViewModel: MainScreenViewModel = koinViewModel() + val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() + val channelTabViewModel: ChannelTabViewModel = koinViewModel() + val channelPagerViewModel: ChannelPagerViewModel = koinViewModel() + val chatInputViewModel: ChatInputViewModel = koinViewModel() + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val streamViewModel: StreamViewModel = koinViewModel() + val dialogViewModel: DialogStateViewModel = koinViewModel() + val mentionViewModel: MentionViewModel = koinViewModel() + val preferenceStore: DankChatPreferenceStore = koinInject() + val mainEventBus: MainEventBus = koinInject() + val featureTourViewModel: FeatureTourViewModel = koinViewModel() + val featureTourState by featureTourViewModel.uiState.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val scrollTargets = remember { mutableStateMapOf() } + // Lazy ref for composePagerState, used in jump handlers declared before the pager + var composePagerStateRef by remember { mutableStateOf(null) } + val keyboardController = LocalSoftwareKeyboardController.current + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + val mainState by mainScreenViewModel.uiState.collectAsStateWithLifecycle() + + val ime = WindowInsets.ime + val navBars = WindowInsets.navigationBars + val imeTarget = WindowInsets.imeAnimationTarget + val currentImeHeight = (ime.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + + // Target height for stability during opening animation + val targetImeHeight = (imeTarget.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + val isImeOpening = targetImeHeight > 0 + + val imeHeightState = rememberUpdatedState(currentImeHeight) + val isImeVisible = WindowInsets.isImeVisible + + // Keyboard height tracking — VM handles debounce + persistence + LaunchedEffect(isLandscape) { mainScreenViewModel.initKeyboardHeight(isLandscape) } + val keyboardHeightPx by mainScreenViewModel.keyboardHeightPx.collectAsStateWithLifecycle() + val minKeyboardHeightPx = with(density) { 100.dp.toPx() } + mainScreenViewModel.trackKeyboardHeight(targetImeHeight, isLandscape, minKeyboardHeightPx) + + // Close emote menu when keyboard opens, but wait for keyboard to reach + // persisted height so scaffold padding doesn't jump during the transition + LaunchedEffect(isImeVisible) { + if (isImeVisible) { + if (keyboardHeightPx > 0) { + snapshotFlow { imeHeightState.value } + .first { it >= keyboardHeightPx } + } + chatInputViewModel.setEmoteMenuOpen(false) + } + } + + val inputState by chatInputViewModel.uiState(sheetNavigationViewModel.fullScreenSheetState, mentionViewModel.currentTab).collectAsStateWithLifecycle() + val isKeyboardVisible = isImeVisible || isImeOpening + var backProgress by remember { mutableFloatStateOf(0f) } + + // Stream state + val streamVmState by streamViewModel.streamState.collectAsStateWithLifecycle() + val currentStream = streamVmState.currentStream + val hasStreamData = streamVmState.hasStreamData + val isAudioOnly = streamVmState.isAudioOnly + val streamState = rememberStreamToolbarState(currentStream) + + // PiP state — observe via lifecycle since onPause fires when entering PiP + val isInPipMode = observePipMode(streamViewModel) + + // Wide split layout: side-by-side stream + chat on medium+ width windows + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isWideWindow = + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND, + ) + val useWideSplitLayout = isWideWindow && currentStream != null && !isInPipMode + + // Only intercept when menu is visible AND keyboard is fully GONE + // Using currentImeHeight == 0 ensures we don't intercept during system keyboard close gestures + PredictiveBackHandler(enabled = inputState.isEmoteMenuOpen && currentImeHeight == 0) { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + chatInputViewModel.setEmoteMenuOpen(false) + backProgress = 0f + } catch (_: Exception) { + backProgress = 0f + } + } + + val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() + + val sheetNavState by sheetNavigationViewModel.sheetState.collectAsStateWithLifecycle() + val fullScreenSheetState = sheetNavState.fullScreenSheet + val isSheetOpen = fullScreenSheetState !is FullScreenSheetState.Closed + val isHistorySheet = fullScreenSheetState is FullScreenSheetState.History + val inputSheetState = sheetNavState.inputSheet + + // Dismiss keyboard before opening sheets to prevent animation conflicts. + // Defers sheet rendering until the keyboard has started closing. + val hasBottomSheet = isSheetOpen || + dialogState.messageOptionsParams != null || + dialogState.userPopupParams != null || + dialogState.emoteInfoEmotes != null || + dialogState.showModActions + var sheetsReady by remember { mutableStateOf(true) } + LaunchedEffect(hasBottomSheet) { + if (hasBottomSheet && isImeVisible) { + sheetsReady = false + keyboardController?.hide() + snapshotFlow { imeHeightState.value } + .first { it == 0 } + } + sheetsReady = true + } + + MainScreenEventHandler( + snackbarHostState = snackbarHostState, + mainEventBus = mainEventBus, + dialogViewModel = dialogViewModel, + chatInputViewModel = chatInputViewModel, + channelTabViewModel = channelTabViewModel, + sheetNavigationViewModel = sheetNavigationViewModel, + mainScreenViewModel = mainScreenViewModel, + preferenceStore = preferenceStore, + ) + + val tabState = channelTabViewModel.uiState.collectAsStateWithLifecycle().value + val activeChannel = tabState.tabs.getOrNull(tabState.selectedIndex)?.channel + + MainScreenTourEffects( + featureTourViewModel = featureTourViewModel, + featureTourState = featureTourState, + mainScreenViewModel = mainScreenViewModel, + mainState = mainState, + channelsReady = !tabState.loading, + channelsEmpty = tabState.tabs.isEmpty() && !tabState.loading, + ) + + MainScreenDialogs( + dialogViewModel = dialogViewModel, + isLoggedIn = isLoggedIn, + activeChannel = activeChannel, + modActionsChannel = inputState.activeChannel, + isStreamActive = currentStream != null, + inputSheetState = inputSheetState, + sheetsReady = sheetsReady, + onAddChannel = { + channelManagementViewModel.addChannel(it) + dialogViewModel.dismissAddChannel() + }, + onLogout = onLogout, + onLogin = onLogin, + onReportChannel = onReportChannel, + onOpenUrl = onOpenUrl, + onOpenLogViewer = onOpenLogViewer, + onJumpToMessage = { messageId, channel -> + val target = channelPagerViewModel.resolveJumpTarget(channel, messageId) + if (target != null) { + dialogViewModel.dismissMessageOptions() + sheetNavigationViewModel.closeFullScreenSheet() + scrollTargets[target.channel] = target.messageId + scope.launch { composePagerStateRef?.scrollToPage(target.channelIndex) } + } else { + scope.launch { + snackbarHostState.showSnackbar(messageNotInHistoryMsg) + } + } + }, + ) + + val isFullscreen = mainState.isFullscreen + val showInput = mainState.showInput + val swipeNavigation = mainState.swipeNavigation + val effectiveShowAppBar = mainState.effectiveShowAppBar + + val toolbarTracker = + remember { + ScrollDirectionTracker( + hideThresholdPx = with(density) { 100.dp.toPx() }, + showThresholdPx = with(density) { 36.dp.toPx() }, + onHide = { mainScreenViewModel.setGestureToolbarHidden(true) }, + onShow = { mainScreenViewModel.setGestureToolbarHidden(false) }, + ) + } + + val swipeDownThresholdPx = with(density) { 56.dp.toPx() } + + FullscreenSystemBarsEffect(isFullscreen) + + val isInputSheet = fullScreenSheetState is FullScreenSheetState.Replies || + fullScreenSheetState is FullScreenSheetState.Mention || + fullScreenSheetState is FullScreenSheetState.Whisper + LaunchedEffect(isInputSheet) { + if (isInputSheet && !showInput) { + mainScreenViewModel.toggleInput() + } + } + + val pagerState by channelPagerViewModel.uiState.collectAsStateWithLifecycle() + + val composePagerState = + rememberPagerState( + initialPage = pagerState.currentPage, + pageCount = { pagerState.channels.size }, + ).also { composePagerStateRef = it } + var inputHeightPx by remember { mutableIntStateOf(0) } + var helperTextHeightPx by remember { mutableIntStateOf(0) } + var inputOverflowExpanded by remember { mutableStateOf(false) } + var isInputMultiline by remember { mutableStateOf(false) } + if (!showInput) inputHeightPx = 0 + if (showInput || inputState.helperText.isEmpty) helperTextHeightPx = 0 + val inputHeightDp = with(density) { inputHeightPx.toDp() } + val helperTextHeightDp = with(density) { helperTextHeightPx.toDp() } + + val focusManager = LocalFocusManager.current + MainScreenFocusEffects( + imeHeight = imeHeightState, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + currentStream = currentStream, + ) + + MainScreenPagerEffects( + composePagerState = composePagerState, + pagerState = pagerState, + onSetActivePage = channelPagerViewModel::setActivePage, + onClearNotifications = channelPagerViewModel::clearNotifications, + onShowToolbar = { mainScreenViewModel.setGestureToolbarHidden(false) }, + ) + + val emoteCoordinator = rememberEmoteAnimationCoordinator() + val customTabContext = LocalContext.current + val customTabUriHandler = + remember(customTabContext) { + object : UriHandler { + override fun openUri(uri: String) { + launchCustomTab(customTabContext, uri) + } + } + } + + CompositionLocalProvider( + LocalEmoteAnimationCoordinator provides emoteCoordinator, + LocalUriHandler provides customTabUriHandler, + ) { + var containerWidthPx by remember { mutableIntStateOf(0) } + var containerHeightPx by remember { mutableIntStateOf(0) } + Box( + modifier = + Modifier + .fillMaxSize() + .onSizeChanged { size -> + containerWidthPx = size.width + containerHeightPx = size.height + }.then(if (!isFullscreen && !isInPipMode) Modifier.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) else Modifier), + ) { + // Menu content height matches keyboard content area (above nav bar) + val targetMenuHeight = + if (keyboardHeightPx > 0) { + with(density) { keyboardHeightPx.toDp() } + } else { + if (isLandscape) 200.dp else 350.dp + }.coerceAtLeast(if (isLandscape) 150.dp else 250.dp) + + // Total menu height includes nav bar so the menu visually matches + // the keyboard's full extent. Without this, the menu is shorter than + // the keyboard by navBarHeight, causing a visible lag during reveal. + val navBarHeightDp = with(density) { navBars.getBottom(density).toDp() } + val roundedCornerBottomPadding = rememberRoundedCornerBottomPadding() + val effectiveRoundedCorner = + when { + roundedCornerBottomPadding >= ROUNDED_CORNER_THRESHOLD -> roundedCornerBottomPadding + else -> 0.dp + } + val totalMenuHeight = targetMenuHeight + navBarHeightDp + + // Shared scaffold bottom padding calculation + val hasDialogWithInput = dialogState.showAddChannel || dialogState.showModActions || dialogState.showManageChannels || dialogState.showNewWhisper + val currentImeDp = if (hasDialogWithInput) 0.dp else with(density) { currentImeHeight.toDp() } + val emoteMenuPadding = if (inputState.isEmoteMenuOpen) targetMenuHeight else 0.dp + val scaffoldBottomPadding = max(currentImeDp, emoteMenuPadding) + + // Shared bottom bar content + val bottomBar: @Composable () -> Unit = { + ChatBottomBar( + showInput = showInput && !isHistorySheet, + textFieldState = chatInputViewModel.textFieldState, + uiState = inputState, + callbacks = + ChatInputCallbacks( + onSend = chatInputViewModel::sendMessage, + onLastMessageClick = chatInputViewModel::getLastMessage, + onEmoteClick = { + if (!inputState.isEmoteMenuOpen) { + keyboardController?.hide() + chatInputViewModel.setEmoteMenuOpen(true) + } else { + keyboardController?.show() + } + }, + onOverlayDismiss = { + when (inputState.overlay) { + is InputOverlay.Reply -> chatInputViewModel.setReplying(false) + is InputOverlay.Whisper -> chatInputViewModel.setWhisperTarget(null) + is InputOverlay.Announce -> chatInputViewModel.setAnnouncing(false) + InputOverlay.None -> Unit + } + }, + onToggleFullscreen = mainScreenViewModel::toggleFullscreen, + onToggleInput = { + mainScreenViewModel.toggleInput() + chatInputViewModel.setEmoteMenuOpen(false) + }, + onToggleStream = { + when { + currentStream != null -> streamViewModel.closeStream() + else -> activeChannel?.let { streamViewModel.toggleStream(it) } + } + }, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, + onModActions = dialogViewModel::showModActions, + onInputActionsChange = mainScreenViewModel::updateInputActions, + onSearchClick = { activeChannel?.let { sheetNavigationViewModel.openHistory(HistoryChannel.Channel(it)) } }, + onDebugInfoClick = sheetNavigationViewModel::openDebugInfo, + onNewWhisper = + if (inputState.isWhisperTabActive) { + dialogViewModel::showNewWhisper + } else { + null + }, + onRepeatedSendChange = chatInputViewModel::setRepeatedSend, + onInputMultilineChanged = { isInputMultiline = it }, + ), + isUploading = dialogState.isUploading, + isLoading = tabState.loading, + isFullscreen = isFullscreen, + isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), + isStreamActive = currentStream != null, + isAudioOnly = isAudioOnly, + hasStreamData = hasStreamData, + isSheetOpen = isSheetOpen, + inputActions = + when (fullScreenSheetState) { + is FullScreenSheetState.Replies -> { + persistentListOf(InputAction.LastMessage) + } + + is FullScreenSheetState.Whisper, + is FullScreenSheetState.Mention, + -> { + when { + inputState.isWhisperTabActive && inputState.overlay is InputOverlay.Whisper -> persistentListOf(InputAction.LastMessage) + else -> persistentListOf() + } + } + + is FullScreenSheetState.History, + is FullScreenSheetState.Closed, + -> { + mainState.inputActions + } + }, + onInputHeightChange = { inputHeightPx = it }, + debugMode = mainState.debugMode, + overflowExpanded = inputOverflowExpanded, + onOverflowExpandedChange = { inputOverflowExpanded = it }, + onHelperTextHeightChange = { helperTextHeightPx = it }, + isInSplitLayout = useWideSplitLayout, + instantHide = isHistorySheet, + isRepeatedSendEnabled = mainState.isRepeatedSendEnabled, + tourState = + remember(featureTourState.currentTourStep, featureTourState.forceOverflowOpen, featureTourState.isTourActive) { + TourOverlayState( + inputActionsTooltipState = if (featureTourState.currentTourStep == TourStep.InputActions) featureTourViewModel.inputActionsTooltipState else null, + overflowMenuTooltipState = if (featureTourState.currentTourStep == TourStep.OverflowMenu) featureTourViewModel.overflowMenuTooltipState else null, + configureActionsTooltipState = if (featureTourState.currentTourStep == TourStep.ConfigureActions) featureTourViewModel.configureActionsTooltipState else null, + swipeGestureTooltipState = if (featureTourState.currentTourStep == TourStep.SwipeGesture) featureTourViewModel.swipeGestureTooltipState else null, + forceOverflowOpen = featureTourState.forceOverflowOpen, + isTourActive = + featureTourState.isTourActive || + featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint, + onAdvance = featureTourViewModel::advance, + onSkip = featureTourViewModel::skipTour, + ) + }, + ) + } + + // Shared toolbar action handler + val handleToolbarAction: (ToolbarAction) -> Unit = { action -> + when (action) { + is ToolbarAction.SelectTab -> { + channelTabViewModel.selectTab(action.index) + scope.launch { composePagerState.scrollToPage(action.index) } + } + + ToolbarAction.LongClickTab -> { + dialogViewModel.showManageChannels() + } + + ToolbarAction.AddChannel -> { + featureTourViewModel.onAddedChannelFromToolbar() + dialogViewModel.showAddChannel() + } + + ToolbarAction.OpenMentions -> { + sheetNavigationViewModel.openMentions() + channelTabViewModel.clearAllMentionCounts() + } + + ToolbarAction.Login -> { + onLogin() + } + + ToolbarAction.Relogin -> { + onRelogin() + } + + ToolbarAction.Logout -> { + dialogViewModel.showLogout() + } + + ToolbarAction.ManageChannels -> { + dialogViewModel.showManageChannels() + } + + ToolbarAction.OpenChannel -> { + onOpenChannel() + } + + ToolbarAction.RemoveChannel -> { + dialogViewModel.showRemoveChannel() + } + + ToolbarAction.ReportChannel -> { + onReportChannel() + } + + ToolbarAction.BlockChannel -> { + dialogViewModel.showBlockChannel() + } + + ToolbarAction.CaptureImage -> { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureImage() else dialogViewModel.setPendingUploadAction(onCaptureImage) + } + + ToolbarAction.CaptureVideo -> { + if (preferenceStore.hasExternalHostingAcknowledged) onCaptureVideo() else dialogViewModel.setPendingUploadAction(onCaptureVideo) + } + + ToolbarAction.ChooseMedia -> { + if (preferenceStore.hasExternalHostingAcknowledged) onChooseMedia() else dialogViewModel.setPendingUploadAction(onChooseMedia) + } + + ToolbarAction.ReloadEmotes -> { + activeChannel?.let { channelManagementViewModel.reloadEmotes(it) } + } + + ToolbarAction.Reconnect -> { + channelManagementViewModel.reconnect() + } + + ToolbarAction.OpenSettings -> { + onNavigateToSettings() + } + } + } + + // Shared floating toolbar + val floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit = { toolbarModifier, visible, endAligned, showTabs -> + FloatingToolbar( + tabState = tabState, + composePagerState = composePagerState, + showAppBar = effectiveShowAppBar && visible, + isFullscreen = isFullscreen, + isLoggedIn = isLoggedIn, + currentStream = currentStream, + isAudioOnly = isAudioOnly, + streamHeightDp = streamState.heightDp, + totalMentionCount = tabState.tabs.sumOf { it.mentionCount } + tabState.whisperMentionCount, + onAction = handleToolbarAction, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, + onStreamClose = { streamViewModel.closeStream() }, + endAligned = endAligned, + showTabs = showTabs, + addChannelTooltipState = if (featureTourState.postOnboardingStep is PostOnboardingStep.ToolbarPlusHint) featureTourViewModel.addChannelTooltipState else null, + onAddChannelTooltipDismiss = featureTourViewModel::onToolbarHintDismissed, + onSkipTour = featureTourViewModel::skipTour, + keyboardHeightDp = with(density) { currentImeHeight.toDp() }, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + onCloseEmoteMenu = { chatInputViewModel.setEmoteMenuOpen(false) }, + streamToolbarAlpha = streamState.effectiveAlpha, + modifier = toolbarModifier, + ) + } + + // Shared emote menu layer + val emoteMenuLayer: @Composable (Modifier) -> Unit = { menuModifier -> + EmoteMenuOverlay( + isVisible = inputState.isEmoteMenuOpen, + totalMenuHeight = totalMenuHeight, + backProgress = backProgress, + onEmoteClick = { code, id -> + chatInputViewModel.insertText("$code ") + chatInputViewModel.addEmoteUsage(id) + }, + onBackspace = chatInputViewModel::deleteLastWord, + modifier = menuModifier, + ) + } + + // Shared pager callbacks + val chatPagerCallbacks = + remember { + ChatPagerCallbacks( + onShowUserPopup = dialogViewModel::showUserPopup, + onMentionUser = chatInputViewModel::mentionUser, + onShowMessageOptions = dialogViewModel::showMessageOptions, + onShowEmoteInfo = dialogViewModel::showEmoteInfo, + onOpenReplies = sheetNavigationViewModel::openReplies, + onRecover = { + mainScreenViewModel.recoverInputAndFullscreen() + }, + onScrollToBottom = { mainScreenViewModel.setGestureToolbarHidden(false) }, + onTourAdvance = featureTourViewModel::advance, + onTourSkip = featureTourViewModel::skipTour, + scrollConnection = toolbarTracker, + ) + } + + // Shared scaffold content (pager) + val fabActionHandler: (InputAction) -> Unit = + remember { + { action -> + val channel = + channelTabViewModel.uiState.value.let { state -> + state.tabs.getOrNull(state.selectedIndex)?.channel + } + when (action) { + InputAction.Search -> { + channel?.let { sheetNavigationViewModel.openHistory(HistoryChannel.Channel(it)) } + } + + InputAction.LastMessage -> { + chatInputViewModel.getLastMessage() + } + + InputAction.Stream -> { + val stream = streamViewModel.streamState.value.currentStream + when { + stream != null -> streamViewModel.closeStream() + else -> channel?.let { streamViewModel.toggleStream(it) } + } + } + + InputAction.ModActions -> { + dialogViewModel.showModActions() + } + + InputAction.Fullscreen -> { + mainScreenViewModel.toggleFullscreen() + } + + InputAction.HideInput -> { + mainScreenViewModel.toggleInput() + chatInputViewModel.setEmoteMenuOpen(false) + } + + InputAction.Debug -> { + sheetNavigationViewModel.openDebugInfo() + } + } + } + } + val fabMenuCallbacks = + FabMenuCallbacks( + onAction = fabActionHandler, + onAudioOnly = { streamViewModel.toggleAudioOnly() }, + isStreamActive = currentStream != null, + isAudioOnly = isAudioOnly, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = mainScreenViewModel.isModeratorInChannel(inputState.activeChannel), + debugMode = mainState.debugMode, + enabled = inputState.enabled, + hasLastMessage = inputState.hasLastMessage, + ) + + val scaffoldContent: @Composable (PaddingValues, Dp) -> Unit = { paddingValues, chatTopPadding -> + MainScreenPagerContent( + paddingValues = paddingValues, + chatTopPadding = chatTopPadding, + tabState = tabState, + composePagerState = composePagerState, + pagerState = pagerState, + isLoggedIn = isLoggedIn, + showInput = showInput, + isFullscreen = isFullscreen, + swipeNavigation = swipeNavigation, + isSheetOpen = isSheetOpen, + inputHeightDp = inputHeightDp, + helperTextHeightDp = helperTextHeightDp, + navBarHeightDp = navBarHeightDp, + effectiveRoundedCorner = effectiveRoundedCorner, + userLongClickBehavior = inputState.userLongClickBehavior, + scrollTargets = scrollTargets.toImmutableMap(), + onClearScrollTarget = { scrollTargets.remove(it) }, + callbacks = chatPagerCallbacks, + fabMenuCallbacks = fabMenuCallbacks, + currentTourStep = featureTourState.currentTourStep, + recoveryFabTooltipState = featureTourViewModel.recoveryFabTooltipState, + onAddChannel = dialogViewModel::showAddChannel, + onLogin = onLogin, + ) + } + + // Shared fullscreen sheet overlay + val fullScreenSheetOverlay: @Composable (Dp) -> Unit = { bottomPadding -> + val effectiveBottomPadding = + when { + !showInput -> bottomPadding + max(navBarHeightDp, effectiveRoundedCorner) + else -> bottomPadding + } + FullScreenSheetOverlay( + sheetState = fullScreenSheetState, + mentionViewModel = mentionViewModel, + onDismiss = sheetNavigationViewModel::closeFullScreenSheet, + onDismissReplies = { + sheetNavigationViewModel.closeFullScreenSheet() + chatInputViewModel.setReplying(false) + }, + onUserClick = dialogViewModel::showUserPopup, + onMessageLongClick = dialogViewModel::showMessageOptions, + onEmoteClick = dialogViewModel::showEmoteInfo, + userLongClickBehavior = inputState.userLongClickBehavior, + onWhisperReply = chatInputViewModel::setWhisperTarget, + onUserMention = chatInputViewModel::mentionUser, + bottomContentPadding = effectiveBottomPadding, + ) + } + + val onStreamClose = { + keyboardController?.hide() + focusManager.clearFocus() + streamViewModel.closeStream() + } + val onAudioOnly = { streamViewModel.toggleAudioOnly() } + + if (useWideSplitLayout) { + WideSplitLayout( + currentStream = currentStream, + isAudioOnly = isAudioOnly, + onStreamClose = onStreamClose, + onAudioOnly = onAudioOnly, + scaffoldContent = scaffoldContent, + floatingToolbar = floatingToolbar, + fullScreenSheetOverlay = fullScreenSheetOverlay, + bottomBar = bottomBar, + emoteMenuLayer = emoteMenuLayer, + snackbarHostState = snackbarHostState, + scaffoldBottomPadding = scaffoldBottomPadding, + inputHeightDp = inputHeightDp, + isFullscreen = isFullscreen, + gestureToolbarHidden = mainState.gestureToolbarHidden, + isKeyboardVisible = isKeyboardVisible, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + isSheetOpen = isSheetOpen, + showInput = showInput, + isInputMultiline = isInputMultiline, + inputOverflowExpanded = inputOverflowExpanded, + forceOverflowOpen = featureTourState.forceOverflowOpen, + swipeDownThresholdPx = swipeDownThresholdPx, + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + onHideInput = { mainScreenViewModel.hideInput() }, + onDismissOverflow = { inputOverflowExpanded = false }, + modifier = modifier, + ) + } else { + NormalStackedLayout( + currentStream = currentStream, + isAudioOnly = isAudioOnly, + isInputMultiline = isInputMultiline, + onStreamClose = onStreamClose, + onAudioOnly = onAudioOnly, + hasWebViewBeenAttached = streamViewModel.hasWebViewBeenAttached, + streamState = streamState, + scaffoldContent = scaffoldContent, + floatingToolbar = floatingToolbar, + fullScreenSheetOverlay = fullScreenSheetOverlay, + bottomBar = bottomBar, + emoteMenuLayer = emoteMenuLayer, + snackbarHostState = snackbarHostState, + scaffoldBottomPadding = scaffoldBottomPadding, + inputHeightDp = inputHeightDp, + isFullscreen = isFullscreen, + gestureToolbarHidden = mainState.gestureToolbarHidden, + isKeyboardVisible = isKeyboardVisible, + isEmoteMenuOpen = inputState.isEmoteMenuOpen, + isSheetOpen = isSheetOpen, + isInPipMode = isInPipMode, + containerWidthPx = containerWidthPx, + containerHeightPx = containerHeightPx, + fontSize = mainState.fontSize, + showInput = showInput, + inputOverflowExpanded = inputOverflowExpanded, + forceOverflowOpen = featureTourState.forceOverflowOpen, + swipeDownThresholdPx = swipeDownThresholdPx, + suggestions = inputState.suggestions, + onSuggestionClick = chatInputViewModel::applySuggestion, + onHideInput = { mainScreenViewModel.hideInput() }, + onDismissOverflow = { inputOverflowExpanded = false }, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun BoxScope.WideSplitLayout( + currentStream: UserName?, + isAudioOnly: Boolean, + onStreamClose: () -> Unit, + onAudioOnly: () -> Unit, + scaffoldContent: @Composable (PaddingValues, Dp) -> Unit, + floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit, + fullScreenSheetOverlay: @Composable (Dp) -> Unit, + bottomBar: @Composable () -> Unit, + emoteMenuLayer: @Composable (Modifier) -> Unit, + snackbarHostState: SnackbarHostState, + scaffoldBottomPadding: Dp, + inputHeightDp: Dp, + isFullscreen: Boolean, + gestureToolbarHidden: Boolean, + isKeyboardVisible: Boolean, + isEmoteMenuOpen: Boolean, + isSheetOpen: Boolean, + showInput: Boolean, + isInputMultiline: Boolean, + inputOverflowExpanded: Boolean, + forceOverflowOpen: Boolean, + swipeDownThresholdPx: Float, + suggestions: ImmutableList, + onSuggestionClick: (Suggestion) -> Unit, + onHideInput: () -> Unit, + onDismissOverflow: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + var splitFraction by remember { mutableFloatStateOf(0.6f) } + var containerWidthPx by remember { mutableIntStateOf(0) } + + Box( + modifier = + modifier + .fillMaxSize() + .onSizeChanged { containerWidthPx = it.width }, + ) { + Row(modifier = Modifier.fillMaxSize()) { + // Left pane: Stream (hidden but composed in audio-only mode to keep audio playing) + Box( + modifier = + when { + isAudioOnly -> Modifier.width(0.dp) + else -> Modifier.weight(splitFraction).fillMaxSize() + }, + ) { + StreamView( + channel = currentStream ?: return, + fillPane = true, + onClose = onStreamClose, + onAudioOnly = onAudioOnly, + modifier = Modifier.fillMaxSize(), + ) + } + + // Right pane: Chat + all overlays + Box( + modifier = + Modifier + .weight(if (isAudioOnly) 1f else 1f - splitFraction) + .fillMaxSize(), + ) { + val statusBarTop = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + + Scaffold( + modifier = + Modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, + ) { paddingValues -> + scaffoldContent(paddingValues, statusBarTop) + } + + val chatPaneWidthDp = with(density) { (containerWidthPx * (1f - splitFraction)).toInt().toDp() } + val showTabsInSplit = chatPaneWidthDp > 250.dp + + floatingToolbar( + Modifier.align(Alignment.TopCenter), + !isKeyboardVisible && !isEmoteMenuOpen && !isSheetOpen, + false, + showTabsInSplit, + ) + + AnimatedStatusBarScrim( + visible = !isFullscreen && gestureToolbarHidden, + modifier = Modifier.align(Alignment.TopCenter), + ) + + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + + if (inputOverflowExpanded) { + InputDismissScrim( + forceOpen = forceOverflowOpen, + onDismiss = onDismissOverflow, + ) + } + + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = showInput && !isSheetOpen && !isInputMultiline && !isKeyboardVisible && !isEmoteMenuOpen, + thresholdPx = swipeDownThresholdPx, + onHide = onHideInput, + ), + ) { + bottomBar() + } + + emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + if (showInput && isKeyboardVisible) { + SuggestionDropdown( + suggestions = suggestions, + onSuggestionClick = onSuggestionClick, + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), + ) + } + } + } + + if (!isAudioOnly) { + DraggableHandle( + onDrag = { deltaPx -> + if (containerWidthPx > 0) { + splitFraction = (splitFraction + deltaPx / containerWidthPx).coerceIn(0.2f, 0.8f) + } + }, + modifier = + Modifier + .align(Alignment.CenterStart) + .graphicsLayer { translationX = containerWidthPx * splitFraction - 12.dp.toPx() }, + ) + } + } +} + +@Composable +private fun BoxScope.NormalStackedLayout( + currentStream: UserName?, + isAudioOnly: Boolean, + isInputMultiline: Boolean, + onStreamClose: () -> Unit, + onAudioOnly: () -> Unit, + hasWebViewBeenAttached: Boolean, + streamState: StreamToolbarState, + scaffoldContent: @Composable (PaddingValues, Dp) -> Unit, + floatingToolbar: @Composable (Modifier, Boolean, Boolean, Boolean) -> Unit, + fullScreenSheetOverlay: @Composable (Dp) -> Unit, + bottomBar: @Composable () -> Unit, + emoteMenuLayer: @Composable (Modifier) -> Unit, + snackbarHostState: SnackbarHostState, + scaffoldBottomPadding: Dp, + inputHeightDp: Dp, + isFullscreen: Boolean, + gestureToolbarHidden: Boolean, + isKeyboardVisible: Boolean, + isEmoteMenuOpen: Boolean, + isSheetOpen: Boolean, + isInPipMode: Boolean, + containerWidthPx: Int, + containerHeightPx: Int, + fontSize: Int, + showInput: Boolean, + inputOverflowExpanded: Boolean, + forceOverflowOpen: Boolean, + swipeDownThresholdPx: Float, + suggestions: ImmutableList, + onSuggestionClick: (Suggestion) -> Unit, + onHideInput: () -> Unit, + onDismissOverflow: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + + // Determine whether the stream should remain visible when keyboard/emote menu is open. + // Hide the stream only as a fallback when there isn't enough space for ~3 messages. + val isInputActive = isKeyboardVisible || isEmoteMenuOpen + val hasStream = currentStream != null && !isAudioOnly + val shouldHideStream = if (isInputActive && !isInPipMode && hasStream && containerHeightPx > 0) { + val containerHeightDp = with(density) { containerHeightPx.toDp() } + val streamNaturalHeight = with(density) { containerWidthPx.toDp() } * 9 / 16 + val minMessageArea = with(density) { (fontSize * MIN_VISIBLE_MESSAGE_LINES).sp.toDp() } + val available = containerHeightDp - streamNaturalHeight - scaffoldBottomPadding - inputHeightDp + available < minMessageArea + } else { + false + } + val showStream = hasStream && (isInPipMode || !shouldHideStream) + + if (!isInPipMode) { + Scaffold( + modifier = + modifier + .fillMaxSize() + .padding(bottom = scaffoldBottomPadding), + contentWindowInsets = WindowInsets(0), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = inputHeightDp), + ) + }, + ) { paddingValues -> + val chatTopPadding = maxOf(with(density) { WindowInsets.statusBars.getTop(density).toDp() }, streamState.heightDp * streamState.alpha.value) + scaffoldContent(paddingValues, chatTopPadding) + } + } + + // Stream View layer — kept in composition when hidden so the WebView + // stays attached and audio/video continues playing without re-buffering. + currentStream?.let { channel -> + var streamComposed by remember { mutableStateOf(hasWebViewBeenAttached) } + LaunchedEffect(showStream) { + if (showStream) { + delay(100) + streamComposed = true + } + } + if (streamComposed) { + StreamView( + channel = channel, + isInPipMode = isInPipMode, + onClose = onStreamClose, + onAudioOnly = onAudioOnly, + modifier = + when { + isInPipMode -> { + Modifier.fillMaxSize() + } + + showStream -> { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = streamState.alpha.value } + .onSizeChanged { size -> + streamState.heightDp = with(density) { size.height.toDp() } + } + } + + else -> { + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .graphicsLayer { alpha = 0f } + } + }, + ) + } + if (!showStream) { + streamState.heightDp = 0.dp + } + } + + // Status bar scrim when stream video is visible (not audio-only, not hidden by fallback) + if (showStream && !isFullscreen && !isInPipMode) { + StatusBarScrim( + colorAlpha = 1f, + modifier = + Modifier + .align(Alignment.TopCenter) + .graphicsLayer { alpha = streamState.alpha.value }, + ) + } + + if (!isInPipMode) { + floatingToolbar( + Modifier.align(Alignment.TopCenter), + (!isKeyboardVisible && !isEmoteMenuOpen) && !isSheetOpen, + true, + true, + ) + } + + AnimatedStatusBarScrim( + visible = !isInPipMode && !isFullscreen && gestureToolbarHidden, + modifier = Modifier.align(Alignment.TopCenter), + ) + + if (!isInPipMode) { + fullScreenSheetOverlay(inputHeightDp + scaffoldBottomPadding) + } + + if (!isInPipMode && inputOverflowExpanded) { + InputDismissScrim( + forceOpen = forceOverflowOpen, + onDismiss = onDismissOverflow, + ) + } + + if (!isInPipMode) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = scaffoldBottomPadding) + .swipeDownToHide( + enabled = showInput && !isSheetOpen && !isInputMultiline && !isKeyboardVisible && !isEmoteMenuOpen, + thresholdPx = swipeDownThresholdPx, + onHide = onHideInput, + ), + ) { + bottomBar() + } + } + + if (!isInPipMode) emoteMenuLayer(Modifier.align(Alignment.BottomCenter)) + + if (!isInPipMode && showInput && isKeyboardVisible) { + SuggestionDropdown( + suggestions = suggestions, + onSuggestionClick = onSuggestionClick, + modifier = + Modifier + .align(Alignment.BottomStart) + .navigationBarsPadding() + .imePadding() + .padding(bottom = inputHeightDp + 2.dp), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MainScreenPagerEffects( + composePagerState: PagerState, + pagerState: ChannelPagerUiState, + onSetActivePage: (Int) -> Unit, + onClearNotifications: (Int) -> Unit, + onShowToolbar: () -> Unit, +) { + // Sync Compose pager with ViewModel state + LaunchedEffect(pagerState.currentPage, pagerState.channels.size) { + if (!composePagerState.isScrollInProgress && + composePagerState.currentPage != pagerState.currentPage && + pagerState.currentPage in 0 until composePagerState.pageCount + ) { + composePagerState.scrollToPage(pagerState.currentPage) + } + } + + // Eagerly update active channel on page change for snappy UI (room state, stream info) + LaunchedEffect(composePagerState.currentPage) { + if (composePagerState.currentPage != pagerState.currentPage) { + onSetActivePage(composePagerState.currentPage) + } + } + + // Clear unread/mention indicators when page settles + LaunchedEffect(composePagerState.settledPage) { + onClearNotifications(composePagerState.settledPage) + } + + // Pager swipe reveals toolbar + LaunchedEffect(composePagerState.isScrollInProgress) { + if (composePagerState.isScrollInProgress) { + onShowToolbar() + } + } +} + +@Composable +private fun MainScreenTourEffects( + featureTourViewModel: FeatureTourViewModel, + featureTourState: FeatureTourUiState, + mainScreenViewModel: MainScreenViewModel, + mainState: MainScreenUiState, + channelsReady: Boolean, + channelsEmpty: Boolean, +) { + // Notify tour VM when channel state changes + LaunchedEffect(channelsReady, channelsEmpty) { + featureTourViewModel.onChannelsChanged(empty = channelsEmpty, ready = channelsReady) + } + + // Drive tooltip dismissals and tour start from the typed step + LaunchedEffect(featureTourState.postOnboardingStep) { + when (featureTourState.postOnboardingStep) { + PostOnboardingStep.FeatureTour -> { + featureTourViewModel.addChannelTooltipState.dismiss() + featureTourViewModel.startTour() + } + + PostOnboardingStep.Complete, PostOnboardingStep.Idle -> { + featureTourViewModel.addChannelTooltipState.dismiss() + } + + PostOnboardingStep.ToolbarPlusHint -> { + Unit + } + } + } + + // Sync tour's input hidden state with MainScreenViewModel + LaunchedEffect(featureTourState.gestureInputHidden, featureTourState.isTourActive) { + if (featureTourState.isTourActive) { + when { + featureTourState.gestureInputHidden -> mainScreenViewModel.hideInput() + else -> mainScreenViewModel.recoverInputAndFullscreen() + } + } + } + + // Auto-advance tour when input is hidden during the SwipeGesture step + LaunchedEffect(mainState.showInput, featureTourState.currentTourStep) { + if (!mainState.showInput && featureTourState.currentTourStep == TourStep.SwipeGesture) { + featureTourViewModel.advance() + } + } + + // Keep toolbar visible during tour + LaunchedEffect(featureTourState.isTourActive, mainState.gestureToolbarHidden) { + if (featureTourState.isTourActive && mainState.gestureToolbarHidden) { + mainScreenViewModel.setGestureToolbarHidden(false) + } + } +} + +@Composable +private fun MainScreenFocusEffects( + imeHeight: androidx.compose.runtime.State, + isEmoteMenuOpen: Boolean, + currentStream: UserName?, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val emoteMenuOpenState = rememberUpdatedState(isEmoteMenuOpen) + + // Clear focus when keyboard fully reaches the bottom and emote menu is closed. + // Uses rememberUpdatedState so snapshotFlow reads the latest emote menu state. + // Debounced to avoid premature focus loss during transitions. + LaunchedEffect(Unit) { + snapshotFlow { imeHeight.value == 0 && !emoteMenuOpenState.value } + .debounce(150) + .distinctUntilChanged() + .collect { shouldClearFocus -> + if (shouldClearFocus) { + focusManager.clearFocus() + } + } + } + + // Clear focus after stream closes — the layout shift from removing StreamView + // can cause the TextField to regain focus and open the keyboard. + LaunchedEffect(currentStream) { + if (currentStream == null) { + keyboardController?.hide() + focusManager.clearFocus() + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt new file mode 100644 index 000000000..502565bc5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenComponents.kt @@ -0,0 +1,236 @@ +package com.flxrs.dankchat.ui.main + +import android.app.Activity +import android.app.PictureInPictureParams +import android.os.Build +import android.util.Rational +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemGestures +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenu +import com.flxrs.dankchat.ui.main.stream.StreamViewModel +import kotlin.math.abs + +@Composable +internal fun observePipMode(streamViewModel: StreamViewModel): Boolean { + val context = LocalContext.current + val activity = context as? Activity + var isInPipMode by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, _ -> + isInPipMode = activity?.isInPictureInPictureMode == true + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + LaunchedEffect(Unit) { + streamViewModel.shouldEnablePipAutoMode.collect { enabled -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && activity != null) { + activity.setPictureInPictureParams( + PictureInPictureParams + .Builder() + .setAutoEnterEnabled(enabled) + .setAspectRatio(Rational(16, 9)) + .build(), + ) + } + } + } + + return isInPipMode +} + +@Composable +internal fun FullscreenSystemBarsEffect(isFullscreen: Boolean) { + val context = LocalContext.current + val window = (context as? Activity)?.window + val view = LocalView.current + + DisposableEffect(isFullscreen, window, view) { + if (window == null) return@DisposableEffect onDispose { } + val controller = WindowCompat.getInsetsController(window, view) + if (isFullscreen) { + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } else { + controller.show(WindowInsetsCompat.Type.systemBars()) + } + onDispose { + controller.show(WindowInsetsCompat.Type.systemBars()) + } + } +} + +@Composable +internal fun AnimatedStatusBarScrim( + visible: Boolean, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + ) { + StatusBarScrim() + } +} + +@Composable +internal fun StatusBarScrim( + modifier: Modifier = Modifier, + colorAlpha: Float = 0.7f, +) { + val density = LocalDensity.current + Box( + modifier = + modifier + .fillMaxWidth() + .height(with(density) { WindowInsets.statusBars.getTop(density).toDp() }) + .background(MaterialTheme.colorScheme.surface.copy(alpha = colorAlpha)), + ) +} + +@Composable +internal fun InputDismissScrim( + forceOpen: Boolean, + onDismiss: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (!forceOpen) { + onDismiss() + } + }, + ) +} + +/** + * Modifier that consumes horizontal drags originating from system gesture edge zones + * to prevent the HorizontalPager from intercepting system back/edge gestures. + * Uses [PointerEventPass.Initial] so the pager never sees these drags, + * while taps pass through normally to the content underneath. + */ +@Composable +internal fun Modifier.edgeGestureGuard(): Modifier { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val systemGestureInsets = WindowInsets.systemGestures + val leftEdgePx = systemGestureInsets.getLeft(density, layoutDirection).toFloat() + val rightEdgePx = systemGestureInsets.getRight(density, layoutDirection).toFloat() + + return pointerInput(leftEdgePx, rightEdgePx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val isInEdge = down.position.x < leftEdgePx || down.position.x > (size.width - rightEdgePx) + if (!isInEdge) return@awaitEachGesture + + var totalDx = 0f + var claimed = false + + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + + totalDx += change.positionChange().x + if (!claimed && abs(totalDx) > viewConfiguration.touchSlop) { + claimed = true + } + if (claimed) { + change.consume() + } + } while (true) + } + } +} + +/** + * Animated emote menu overlay that slides in from the bottom. + * Supports predictive back gesture scaling. + */ +@Composable +internal fun EmoteMenuOverlay( + isVisible: Boolean, + totalMenuHeight: Dp, + backProgress: Float, + onEmoteClick: (code: String, id: String) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(animationSpec = tween(durationMillis = 140), initialOffsetY = { it }), + exit = slideOutVertically(animationSpec = tween(durationMillis = 140), targetOffsetY = { it }), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(totalMenuHeight) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }.background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) { + EmoteMenu( + onEmoteClick = onEmoteClick, + onBackspace = onBackspace, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt new file mode 100644 index 000000000..3f85f6593 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenEventHandler.kt @@ -0,0 +1,176 @@ +package com.flxrs.dankchat.ui.main + +import android.content.ClipData +import android.content.ClipboardManager +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.core.content.getSystemService +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthEvent +import com.flxrs.dankchat.data.auth.AuthStateCoordinator +import com.flxrs.dankchat.data.repo.chat.toDisplayStrings +import com.flxrs.dankchat.data.repo.data.toDisplayStrings +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.ui.main.channel.ChannelTabViewModel +import com.flxrs.dankchat.ui.main.dialog.DialogStateViewModel +import com.flxrs.dankchat.ui.main.input.ChatInputViewModel +import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun MainScreenEventHandler( + snackbarHostState: SnackbarHostState, + mainEventBus: MainEventBus, + dialogViewModel: DialogStateViewModel, + chatInputViewModel: ChatInputViewModel, + channelTabViewModel: ChannelTabViewModel, + sheetNavigationViewModel: SheetNavigationViewModel, + mainScreenViewModel: MainScreenViewModel, + preferenceStore: DankChatPreferenceStore, +) { + val context = LocalContext.current + val resources = LocalResources.current + val authStateCoordinator: AuthStateCoordinator = koinInject() + + // MainEventBus event collection + LaunchedEffect(Unit) { + mainEventBus.events.collect { event -> + when (event) { + is MainEvent.LogOutRequested -> { + dialogViewModel.showLogout() + } + + is MainEvent.UploadLoading -> { + dialogViewModel.setUploading(true) + } + + is MainEvent.UploadSuccess -> { + dialogViewModel.setUploading(false) + context + .getSystemService() + ?.setPrimaryClip(ClipData.newPlainText("dankchat_media_url", event.url)) + chatInputViewModel.postSystemMessage(resources.getString(R.string.system_message_upload_complete, event.url)) + val result = snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_image_uploaded, event.url), + actionLabel = resources.getString(R.string.snackbar_paste), + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + chatInputViewModel.insertText(event.url) + } + } + + is MainEvent.UploadFailed -> { + dialogViewModel.setUploading(false) + val message = event.errorMessage + ?.let { resources.getString(R.string.snackbar_upload_failed_cause, it) } + ?: resources.getString(R.string.snackbar_upload_failed) + snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short) + } + + is MainEvent.MessageCopied -> { + val result = snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_message_copied), + actionLabel = resources.getString(R.string.snackbar_paste), + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + chatInputViewModel.insertText(event.text) + } + } + + is MainEvent.MessageIdCopied -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_message_id_copied), + duration = SnackbarDuration.Short, + ) + } + + is MainEvent.OpenChannel -> { + if (event.channel == UserName.EMPTY) { + sheetNavigationViewModel.openWhispers() + } else { + channelTabViewModel.selectTab( + preferenceStore.channels.indexOf(event.channel), + ) + } + (context as? MainActivity)?.clearNotificationsOfChannel(event.channel) + } + + else -> { + Unit + } + } + } + } + + // Collect auth events from AuthStateCoordinator (snackbar-only events like LoggedIn, ValidationFailed). + // Startup validation dialogs (ScopesOutdated, TokenInvalid) are driven by startupValidation state directly. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(Unit) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + authStateCoordinator.events.collect { event -> + when (event) { + is AuthEvent.LoggedIn -> { + launch { + delay(2000) + snackbarHostState.currentSnackbarData?.dismiss() + } + snackbarHostState.showSnackbar( + message = resources.getString(R.string.snackbar_login, event.userName), + duration = SnackbarDuration.Short, + ) + } + + AuthEvent.ValidationFailed -> { + snackbarHostState.showSnackbar( + message = resources.getString(R.string.oauth_verify_failed), + duration = SnackbarDuration.Short, + ) + } + + is AuthEvent.ScopesOutdated -> {} + + AuthEvent.TokenInvalid -> {} + } + } + } + } + + LaunchedEffect(Unit) { + mainScreenViewModel.loadingFailureState.collect { failureState -> + val state = failureState.failure ?: return@collect + if (failureState.acknowledged) return@collect + + mainScreenViewModel.acknowledgeFailure(state) + + val dataSteps = state.failures.map { it.step }.toDisplayStrings(resources) + val chatSteps = state.chatFailures.map { it.step }.toDisplayStrings(resources) + val allSteps = dataSteps + chatSteps + val stepsText = allSteps.joinToString(", ") + val message = when { + allSteps.size == 1 -> resources.getString(R.string.snackbar_data_load_failed_cause, stepsText) + else -> resources.getString(R.string.snackbar_data_load_failed_multiple_causes, stepsText) + } + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = resources.getString(R.string.snackbar_retry), + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + mainScreenViewModel.retryDataLoading(state) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt new file mode 100644 index 000000000..543fcfd46 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenPagerContent.kt @@ -0,0 +1,199 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior +import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.ui.chat.ChatComposable +import com.flxrs.dankchat.ui.chat.FabMenuCallbacks +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import com.flxrs.dankchat.ui.main.channel.ChannelPagerUiState +import com.flxrs.dankchat.ui.main.channel.ChannelTabUiState +import com.flxrs.dankchat.ui.tour.TourStep +import kotlinx.collections.immutable.ImmutableMap + +@Stable +internal class ChatPagerCallbacks( + val onShowUserPopup: (UserPopupStateParams) -> Unit, + val onMentionUser: (UserName, DisplayName) -> Unit, + val onShowMessageOptions: (MessageOptionsParams) -> Unit, + val onShowEmoteInfo: (List) -> Unit, + val onOpenReplies: (String, UserName) -> Unit, + val onRecover: () -> Unit, + val onScrollToBottom: () -> Unit, + val onTourAdvance: () -> Unit, + val onTourSkip: () -> Unit, + val scrollConnection: NestedScrollConnection? = null, +) + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun MainScreenPagerContent( + paddingValues: PaddingValues, + chatTopPadding: Dp, + tabState: ChannelTabUiState, + composePagerState: PagerState, + pagerState: ChannelPagerUiState, + isLoggedIn: Boolean, + showInput: Boolean, + isFullscreen: Boolean, + swipeNavigation: Boolean, + isSheetOpen: Boolean, + inputHeightDp: Dp, + helperTextHeightDp: Dp, + navBarHeightDp: Dp, + effectiveRoundedCorner: Dp, + userLongClickBehavior: UserLongClickBehavior, + scrollTargets: ImmutableMap, + onClearScrollTarget: (UserName) -> Unit, + callbacks: ChatPagerCallbacks, + fabMenuCallbacks: FabMenuCallbacks?, + currentTourStep: TourStep?, + recoveryFabTooltipState: TooltipState?, + onAddChannel: () -> Unit, + onLogin: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + val showFullScreenLoading = tabState.loading && tabState.tabs.isEmpty() + DankBackground(visible = showFullScreenLoading) + if (showFullScreenLoading) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .padding(paddingValues), + ) + return@Box + } + if (tabState.tabs.isEmpty() && !tabState.loading) { + EmptyStateContent( + isLoggedIn = isLoggedIn, + onAddChannel = onAddChannel, + onLogin = onLogin, + modifier = Modifier.padding(paddingValues), + ) + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), + ) { + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + state = composePagerState, + modifier = Modifier.fillMaxSize().edgeGestureGuard(), + userScrollEnabled = swipeNavigation, + key = { index -> pagerState.channels.getOrNull(index)?.value ?: index }, + ) { page -> + if (page in pagerState.channels.indices) { + val channel = pagerState.channels[page] + ChatComposable( + channel = channel, + onUserClick = { userId, userName, displayName, channel, badges, isLongPress -> + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } + if (shouldOpenPopup) { + callbacks.onShowUserPopup( + UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge }, + ), + ) + } else { + callbacks.onMentionUser(UserName(userName), DisplayName(displayName)) + } + }, + onMessageLongClick = { messageId, channel, fullMessage -> + callbacks.onShowMessageOptions( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = isLoggedIn, + canReply = isLoggedIn, + canCopy = true, + ), + ) + }, + onEmoteClick = { emotes -> + callbacks.onShowEmoteInfo(emotes) + }, + onReplyClick = { replyMessageId, replyName -> + callbacks.onOpenReplies(replyMessageId, replyName) + }, + showInput = showInput, + isFullscreen = isFullscreen, + showFabs = !isSheetOpen, + onRecover = callbacks.onRecover, + fabMenuCallbacks = fabMenuCallbacks, + contentPadding = + PaddingValues( + top = chatTopPadding + 56.dp, + bottom = + paddingValues.calculateBottomPadding() + + when { + showInput -> { + inputHeightDp + } + + !isFullscreen -> { + when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> max(navBarHeightDp, effectiveRoundedCorner) + } + } + + else -> { + when { + helperTextHeightDp > 0.dp -> helperTextHeightDp + else -> effectiveRoundedCorner + } + } + }, + ), + scrollModifier = if (callbacks.scrollConnection != null) Modifier.nestedScroll(callbacks.scrollConnection) else Modifier, + onScrollToBottom = callbacks.onScrollToBottom, + onScrollDirectionChange = { }, + scrollToMessageId = scrollTargets[channel], + onScrollToMessageHandle = { onClearScrollTarget(channel) }, + recoveryFabTooltipState = if (currentTourStep == TourStep.RecoveryFab) recoveryFabTooltipState else null, + onTourAdvance = callbacks.onTourAdvance, + onTourSkip = callbacks.onTourSkip, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt new file mode 100644 index 000000000..28b0c56aa --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenUiState.kt @@ -0,0 +1,21 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.preferences.appearance.InputAction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class MainScreenUiState( + val isFullscreen: Boolean = false, + val showInput: Boolean = true, + val inputActions: ImmutableList = persistentListOf(), + val showCharacterCounter: Boolean = false, + val isRepeatedSendEnabled: Boolean = false, + val debugMode: Boolean = false, + val swipeNavigation: Boolean = true, + val fontSize: Int = 14, + val gestureToolbarHidden: Boolean = false, +) { + val effectiveShowAppBar: Boolean get() = !gestureToolbarHidden +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt new file mode 100644 index 000000000..4d4da3f3f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/MainScreenViewModel.kt @@ -0,0 +1,196 @@ +package com.flxrs.dankchat.ui.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@OptIn(FlowPreview::class) +@KoinViewModel +class MainScreenViewModel( + private val channelDataCoordinator: ChannelDataCoordinator, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val preferenceStore: DankChatPreferenceStore, + private val developerSettingsDataStore: DeveloperSettingsDataStore, + private val userStateRepository: UserStateRepository, +) : ViewModel() { + private val _loadingFailureState = MutableStateFlow(LoadingFailureState()) + val loadingFailureState: StateFlow = _loadingFailureState.asStateFlow() + + private val _isFullscreen = MutableStateFlow(false) + private val _gestureToolbarHidden = MutableStateFlow(false) + private val _keyboardHeightUpdates = MutableSharedFlow(extraBufferCapacity = 1) + private val _keyboardHeightPx = MutableStateFlow(0) + val keyboardHeightPx: StateFlow = _keyboardHeightPx.asStateFlow() + + val uiState: StateFlow = + combine( + appearanceSettingsDataStore.settings, + developerSettingsDataStore.settings, + _isFullscreen, + _gestureToolbarHidden, + ) { appearance, developerSettings, isFullscreen, gestureToolbarHidden -> + MainScreenUiState( + isFullscreen = isFullscreen, + showInput = appearance.showInput, + inputActions = appearance.inputActions.toImmutableList(), + showCharacterCounter = appearance.showCharacterCounter, + isRepeatedSendEnabled = developerSettings.repeatedSending, + debugMode = developerSettings.debugMode, + fontSize = appearance.fontSize, + swipeNavigation = appearance.swipeNavigation, + gestureToolbarHidden = gestureToolbarHidden, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MainScreenUiState()) + + init { + channelDataCoordinator.loadGlobalData() + + viewModelScope.launch { + channelDataCoordinator.globalLoadingState.collect { state -> + val failure = state as? GlobalLoadingState.Failed + _loadingFailureState.update { current -> + when (failure) { + null -> LoadingFailureState() + current.failure if current.acknowledged -> current + else -> LoadingFailureState(failure = failure) + } + } + } + } + + viewModelScope.launch { + developerSettingsDataStore.settings + .map { it.debugMode } + .distinctUntilChanged() + .collect { enabled -> + appearanceSettingsDataStore.update { appearance -> + val actions = appearance.inputActions + when { + enabled && InputAction.Debug !in actions && actions.size < MAX_INPUT_ACTIONS -> { + appearance.copy(inputActions = actions + InputAction.Debug) + } + + !enabled && InputAction.Debug in actions -> { + appearance.copy(inputActions = actions - InputAction.Debug) + } + + else -> { + appearance + } + } + } + } + } + + viewModelScope.launch { + _keyboardHeightUpdates + .debounce(300) + .collect { (heightPx, isLandscape) -> + _keyboardHeightPx.value = heightPx + if (isLandscape) { + preferenceStore.keyboardHeightLandscape = heightPx + } else { + preferenceStore.keyboardHeightPortrait = heightPx + } + } + } + } + + fun isModeratorInChannel(channel: UserName?): Boolean = userStateRepository.isModeratorInChannel(channel) + + fun setGestureToolbarHidden(hidden: Boolean) { + _gestureToolbarHidden.value = hidden + } + + fun hideInput() { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(showInput = false) } + } + } + + fun initKeyboardHeight(isLandscape: Boolean) { + val persisted = if (isLandscape) preferenceStore.keyboardHeightLandscape else preferenceStore.keyboardHeightPortrait + _keyboardHeightPx.value = persisted + } + + fun trackKeyboardHeight( + heightPx: Int, + isLandscape: Boolean, + minHeightPx: Float, + ) { + if (heightPx > minHeightPx) { + _keyboardHeightUpdates.tryEmit(KeyboardHeightUpdate(heightPx, isLandscape)) + } + } + + fun toggleInput() { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(showInput = !it.showInput) } + } + } + + fun recoverInputAndFullscreen() { + _isFullscreen.value = false + _gestureToolbarHidden.value = false + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(showInput = true) } + } + } + + fun updateInputActions(actions: ImmutableList) { + viewModelScope.launch { + appearanceSettingsDataStore.update { it.copy(inputActions = actions) } + } + } + + fun toggleFullscreen() { + _isFullscreen.update { !it } + } + + fun acknowledgeFailure(failure: GlobalLoadingState.Failed) { + _loadingFailureState.update { current -> + if (current.failure == failure) current.copy(acknowledged = true) else current + } + } + + fun retryDataLoading(failedState: GlobalLoadingState.Failed) { + channelDataCoordinator.retryDataLoading(failedState) + } + + companion object { + private const val MAX_INPUT_ACTIONS = 4 + } +} + +data class LoadingFailureState( + val failure: GlobalLoadingState.Failed? = null, + val acknowledged: Boolean = false, +) + +private data class KeyboardHeightUpdate( + val heightPx: Int, + val isLandscape: Boolean, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt new file mode 100644 index 000000000..995ae81c9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/QuickActionsMenu.kt @@ -0,0 +1,317 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material.icons.outlined.VideocamOff +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.ui.main.input.TourOverlayState +import com.flxrs.dankchat.utils.compose.rememberStartAlignedTooltipPositionProvider +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuickActionsMenu( + surfaceColor: Color, + visibleActions: ImmutableList, + enabled: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isAudioOnly: Boolean, + hasStreamData: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + tourState: TourOverlayState, + onActionClick: (InputAction) -> Unit, + onAudioOnly: () -> Unit, + onConfigureClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), + color = surfaceColor, + modifier = modifier, + ) { + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + for (action in InputAction.entries) { + if (action in visibleActions) continue + val overflowItem = + getOverflowItem( + action = action, + isStreamActive = isStreamActive, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, + ) + if (overflowItem != null) { + val actionEnabled = isActionEnabled(action, enabled, hasLastMessage) + DropdownMenuItem( + text = { Text(stringResource(overflowItem.labelRes)) }, + onClick = { onActionClick(action) }, + enabled = actionEnabled, + leadingIcon = { + Icon( + imageVector = overflowItem.icon, + contentDescription = null, + ) + }, + ) + } + } + + if (isStreamActive) { + DropdownMenuItem( + text = { + Text( + stringResource( + if (isAudioOnly) R.string.menu_exit_audio_only else R.string.menu_audio_only, + ), + ) + }, + onClick = onAudioOnly, + enabled = enabled, + leadingIcon = { + Icon( + imageVector = if (isAudioOnly) Icons.Outlined.Videocam else Icons.Default.Headphones, + contentDescription = null, + ) + }, + ) + } + + HorizontalDivider() + + val configureItem: @Composable () -> Unit = { + DropdownMenuItem( + text = { Text(stringResource(R.string.input_action_configure)) }, + onClick = { + when { + tourState.configureActionsTooltipState != null -> tourState.onAdvance?.invoke() + else -> onConfigureClick() + } + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + }, + ) + } + when { + tourState.configureActionsTooltipState != null -> { + TooltipBox( + positionProvider = rememberStartAlignedTooltipPositionProvider(), + tooltip = { + EndCaretTourTooltip( + text = stringResource(R.string.tour_configure_actions), + onAction = { tourState.onAdvance?.invoke() }, + onSkip = { tourState.onSkip?.invoke() }, + ) + }, + state = tourState.configureActionsTooltipState, + onDismissRequest = {}, + focusable = true, + hasAction = true, + ) { + configureItem() + } + } + + else -> { + configureItem() + } + } + } + } +} + +@Immutable +private data class OverflowItem( + val labelRes: Int, + val icon: ImageVector, +) + +private fun getOverflowItem( + action: InputAction, + isStreamActive: Boolean, + hasStreamData: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, +): OverflowItem? = when (action) { + InputAction.Search -> { + OverflowItem( + labelRes = R.string.input_action_search, + icon = Icons.Default.Search, + ) + } + + InputAction.LastMessage -> { + OverflowItem( + labelRes = R.string.input_action_last_message, + icon = Icons.Default.History, + ) + } + + InputAction.Stream -> { + when { + hasStreamData || isStreamActive -> { + OverflowItem( + labelRes = if (isStreamActive) R.string.menu_hide_stream else R.string.menu_show_stream, + icon = if (isStreamActive) Icons.Outlined.VideocamOff else Icons.Outlined.Videocam, + ) + } + + else -> { + null + } + } + } + + InputAction.ModActions -> { + when { + isModerator -> { + OverflowItem( + labelRes = R.string.menu_mod_actions, + icon = Icons.Outlined.Shield, + ) + } + + else -> { + null + } + } + } + + InputAction.Fullscreen -> { + OverflowItem( + labelRes = if (isFullscreen) R.string.menu_exit_fullscreen else R.string.menu_fullscreen, + icon = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, + ) + } + + InputAction.HideInput -> { + OverflowItem( + labelRes = R.string.menu_hide_input, + icon = Icons.Default.VisibilityOff, + ) + } + + InputAction.Debug -> { + OverflowItem( + labelRes = R.string.input_action_debug, + icon = Icons.Default.BugReport, + ) + } +} + +private fun isActionEnabled( + action: InputAction, + inputEnabled: Boolean, + hasLastMessage: Boolean, +): Boolean = when (action) { + InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true + InputAction.LastMessage -> inputEnabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> inputEnabled +} + +/** + * Tour tooltip positioned to the start of its anchor, with a right-pointing caret on the end side. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EndCaretTourTooltip( + text: String, + onAction: () -> Unit, + onSkip: () -> Unit, +) { + val containerColor = MaterialTheme.colorScheme.secondaryContainer + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(12.dp), + color = containerColor, + shadowElevation = 2.dp, + tonalElevation = 2.dp, + modifier = Modifier.widthIn(max = 220.dp), + ) { + Column( + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 8.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.align(Alignment.End), + ) { + TextButton(onClick = onSkip) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = onAction) { + Text(stringResource(R.string.tour_next)) + } + } + } + } + Canvas(modifier = Modifier.size(width = 12.dp, height = 24.dp)) { + val path = + Path().apply { + moveTo(0f, 0f) + lineTo(size.width, size.height / 2f) + lineTo(0f, size.height) + close() + } + drawPath(path, containerColor) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt new file mode 100644 index 000000000..b53704c05 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/RepeatedSendData.kt @@ -0,0 +1,9 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.runtime.Immutable + +@Immutable +data class RepeatedSendData( + val enabled: Boolean, + val message: String, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt new file mode 100644 index 000000000..98095a05a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/StreamToolbarState.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.data.UserName + +@Stable +internal class StreamToolbarState( + val alpha: Animatable, +) { + var heightDp by mutableStateOf(0.dp) + private var prevHasVisibleStream by mutableStateOf(false) + + val hasVisibleStream: Boolean + get() = heightDp > 0.dp + + val effectiveAlpha: Float + get() = alpha.value + + suspend fun updateAnimation(hasVisibleStream: Boolean) { + when { + hasVisibleStream && !prevHasVisibleStream -> { + prevHasVisibleStream = true + // Only fade in from 0 if toolbar isn't already visible + // (e.g. first stream open). When returning from keyboard/emote menu + // the toolbar is already at 1.0, so skip the fade-in animation. + if (alpha.value < 0.1f) { + alpha.snapTo(0f) + alpha.animateTo(1f, tween(durationMillis = 350)) + } + } + + !hasVisibleStream && prevHasVisibleStream -> { + prevHasVisibleStream = false + // Stream is hiding — animate toolbar to fully visible so it stays + // visible while the stream is gone (keyboard/emote menu open). + alpha.animateTo(1f, tween(durationMillis = 150)) + } + } + } +} + +@Composable +internal fun rememberStreamToolbarState(currentStream: UserName?): StreamToolbarState { + val state = remember { StreamToolbarState(alpha = Animatable(1f)) } + + val hasVisibleStream = currentStream != null && state.heightDp > 0.dp + + LaunchedEffect(hasVisibleStream) { + state.updateAnimation(hasVisibleStream) + } + LaunchedEffect(currentStream) { + if (currentStream == null) { + state.heightDp = 0.dp + state.alpha.snapTo(1f) + } + } + + return state +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt new file mode 100644 index 000000000..2c6dc773a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/ToolbarAction.kt @@ -0,0 +1,44 @@ +package com.flxrs.dankchat.ui.main + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface ToolbarAction { + data class SelectTab( + val index: Int, + ) : ToolbarAction + + data object LongClickTab : ToolbarAction + + data object AddChannel : ToolbarAction + + data object OpenMentions : ToolbarAction + + data object Login : ToolbarAction + + data object Relogin : ToolbarAction + + data object Logout : ToolbarAction + + data object ManageChannels : ToolbarAction + + data object OpenChannel : ToolbarAction + + data object RemoveChannel : ToolbarAction + + data object ReportChannel : ToolbarAction + + data object BlockChannel : ToolbarAction + + data object CaptureImage : ToolbarAction + + data object CaptureVideo : ToolbarAction + + data object ChooseMedia : ToolbarAction + + data object ReloadEmotes : ToolbarAction + + data object Reconnect : ToolbarAction + + data object OpenSettings : ToolbarAction +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt new file mode 100644 index 000000000..b7e8e7d0a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelManagementViewModel.kt @@ -0,0 +1,171 @@ +package com.flxrs.dankchat.ui.main.channel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.IgnoresRepository +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChannelSelectionDataStore +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatConnector +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.model.ChannelWithRename +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class ChannelManagementViewModel( + private val preferenceStore: DankChatPreferenceStore, + private val channelDataCoordinator: ChannelDataCoordinator, + private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, + private val chatNotificationRepository: ChatNotificationRepository, + private val ignoresRepository: IgnoresRepository, + private val channelRepository: ChannelRepository, + channelSelectionDataStore: ChannelSelectionDataStore, +) : ViewModel() { + val channels: StateFlow> = + preferenceStore + .getChannelsWithRenamesFlow() + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + init { + // Restore persisted channel selection, falling back to first channel + viewModelScope.launch { + if (chatChannelProvider.activeChannel.value == null) { + val channels = preferenceStore.channels + val persisted = channelSelectionDataStore.current().activeChannel + val restoredChannel = channels.firstOrNull { it.value == persisted } ?: channels.firstOrNull() + if (restoredChannel != null) { + chatChannelProvider.setActiveChannel(restoredChannel) + } + } + } + + // Auto-load data when channels added and join if necessary + viewModelScope.launch { + var previousChannels = emptySet() + channels.collect { channelList -> + val currentChannels = channelList.map { it.channel }.toSet() + val newChannels = currentChannels - previousChannels + + newChannels.forEach { channel -> + chatRepository.joinChannel(channel) + channelDataCoordinator.loadChannelData(channel) + } + previousChannels = currentChannels + } + } + } + + fun isChannelAdded(name: String): Boolean = preferenceStore.channels.any { it.value.equals(name, ignoreCase = true) } + + fun addChannel(channel: UserName) { + val current = preferenceStore.channels + if (channel !in current) { + preferenceStore.channels = current + channel + chatRepository.joinChannel(channel) + chatChannelProvider.setActiveChannel(channel) + } + } + + fun removeChannel(channel: UserName) { + val wasActive = chatChannelProvider.activeChannel.value == channel + preferenceStore.removeChannel(channel) + chatRepository.updateChannels(preferenceStore.channels) + channelDataCoordinator.cleanupChannel(channel) + + if (wasActive) { + chatChannelProvider.setActiveChannel(preferenceStore.channels.firstOrNull()) + } + } + + fun renameChannel( + channel: UserName, + displayName: String?, + ) { + val rename = displayName?.ifBlank { null }?.let { UserName(it) } + preferenceStore.setRenamedChannel(ChannelWithRename(channel, rename)) + } + + fun retryChannelLoading(channel: UserName) { + channelDataCoordinator.loadChannelData(channel) + } + + fun reloadAllChannels() { + channelDataCoordinator.reloadAllChannels() + } + + fun reloadEmotes(channel: UserName) { + channelDataCoordinator.loadChannelData(channel) + channelDataCoordinator.reloadUserEmotes() + } + + fun reconnect() { + chatConnector.reconnect() + } + + fun blockChannel(channel: UserName) = viewModelScope.launch { + runCatching { + if (!preferenceStore.isLoggedIn) { + return@launch + } + + val channelId = channelRepository.getChannel(channel)?.id ?: return@launch + ignoresRepository.addUserBlock(channelId, channel) + removeChannel(channel) + } + } + + fun selectChannel(channel: UserName) { + chatChannelProvider.setActiveChannel(channel) + chatNotificationRepository.clearUnreadMessage(channel) + chatNotificationRepository.clearMentionCount(channel) + } + + fun applyChanges(updatedChannels: List) { + val currentChannels = preferenceStore.channels + val newChannelNames = updatedChannels.map { it.channel } + val removedChannels = currentChannels - newChannelNames.toSet() + + // 1. Cleanup removed channels + if (removedChannels.isNotEmpty()) { + chatRepository.updateChannels(newChannelNames) // This handles join/part + removedChannels.forEach { channel -> + channelDataCoordinator.cleanupChannel(channel) + // Remove rename + preferenceStore.setRenamedChannel(ChannelWithRename(channel, null)) + } + + // 2. Update active channel if removed + val activeChannel = chatChannelProvider.activeChannel.value + if (activeChannel in removedChannels) { + // Determine new active channel (try to keep index or go to first) + // For simplicity, pick the first one, or null if empty + val newActive = newChannelNames.firstOrNull() + chatChannelProvider.setActiveChannel(newActive) + } + } + + // 3. Update order and list in preferences + preferenceStore.channels = newChannelNames + + // 4. Apply renames + updatedChannels.forEach { + preferenceStore.setRenamedChannel(it) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt new file mode 100644 index 000000000..00d0f691f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelPagerViewModel.kt @@ -0,0 +1,89 @@ +package com.flxrs.dankchat.ui.main.channel + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class ChannelPagerViewModel( + private val chatChannelProvider: ChatChannelProvider, + private val chatMessageRepository: ChatMessageRepository, + private val chatNotificationRepository: ChatNotificationRepository, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + val uiState: StateFlow = + combine( + preferenceStore.getChannelsWithRenamesFlow(), + chatChannelProvider.activeChannel, + ) { channels, active -> + ChannelPagerUiState( + channels = channels.map { it.channel }.toImmutableList(), + currentPage = + channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelPagerUiState()) + + fun onPageChanged(page: Int) { + setActivePage(page) + clearNotifications(page) + } + + fun setActivePage(page: Int) { + val channels = preferenceStore.channels + if (page in channels.indices) { + chatChannelProvider.setActiveChannel(channels[page]) + } + } + + fun clearNotifications(page: Int) { + val channels = preferenceStore.channels + if (page in channels.indices) { + chatNotificationRepository.clearUnreadMessage(channels[page]) + chatNotificationRepository.clearMentionCount(channels[page]) + } + } + + /** + * Validates that the message exists in the channel's chat and returns the jump target, + * or null if the message can't be found. + */ + fun resolveJumpTarget( + channel: UserName, + messageId: String, + ): JumpTarget? { + val channels = preferenceStore.channels + val index = channels.indexOfFirst { it == channel } + if (index < 0) return null + if (chatMessageRepository.getChat(channel).value.none { it.message.id == messageId }) return null + onPageChanged(index) + return JumpTarget(channelIndex = index, channel = channel, messageId = messageId) + } +} + +@Immutable +data class JumpTarget( + val channelIndex: Int, + val channel: UserName, + val messageId: String, +) + +@Immutable +data class ChannelPagerUiState( + val channels: ImmutableList = persistentListOf(), + val currentPage: Int = 0, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabUiState.kt new file mode 100644 index 000000000..d892ed336 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabUiState.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.ui.main.channel + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.state.ChannelLoadingState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class ChannelTabUiState( + val tabs: ImmutableList = persistentListOf(), + val selectedIndex: Int = 0, + val loading: Boolean = true, + val whisperMentionCount: Int = 0, +) + +@Immutable +data class ChannelTabItem( + val channel: UserName, + val displayName: String, + val isSelected: Boolean, + val hasUnread: Boolean, + val mentionCount: Int, + val loadingState: ChannelLoadingState, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt new file mode 100644 index 000000000..eade046d9 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/channel/ChannelTabViewModel.kt @@ -0,0 +1,89 @@ +package com.flxrs.dankchat.ui.main.channel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatNotificationRepository +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.data.twitch.message.WhisperMessage +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class ChannelTabViewModel( + private val chatChannelProvider: ChatChannelProvider, + private val chatNotificationRepository: ChatNotificationRepository, + private val channelDataCoordinator: ChannelDataCoordinator, + private val preferenceStore: DankChatPreferenceStore, +) : ViewModel() { + val uiState: StateFlow = + preferenceStore + .getChannelsWithRenamesFlow() + .flatMapLatest { channels -> + if (channels.isEmpty()) { + return@flatMapLatest flowOf(ChannelTabUiState(loading = false)) + } + + val loadingFlows = + channels.map { + channelDataCoordinator.getChannelLoadingState(it.channel) + } + + combine( + chatChannelProvider.activeChannel, + chatNotificationRepository.unreadMessagesMap, + chatNotificationRepository.channelMentionCount, + combine(loadingFlows) { it.toList() }, + channelDataCoordinator.globalLoadingState, + ) { active, unread, mentions, loadingStates, globalState -> + val tabs = + channels.mapIndexed { index, channelWithRename -> + ChannelTabItem( + channel = channelWithRename.channel, + displayName = + channelWithRename.rename?.value + ?: channelWithRename.channel.value, + isSelected = channelWithRename.channel == active, + hasUnread = unread[channelWithRename.channel] ?: false, + mentionCount = mentions[channelWithRename.channel] ?: 0, + loadingState = loadingStates[index], + ) + } + ChannelTabUiState( + tabs = tabs.toImmutableList(), + selectedIndex = + channels + .indexOfFirst { it.channel == active } + .coerceAtLeast(0), + loading = + globalState == GlobalLoadingState.Loading || + tabs.any { it.loadingState == ChannelLoadingState.Loading }, + whisperMentionCount = mentions[WhisperMessage.WHISPER_CHANNEL] ?: 0, + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChannelTabUiState()) + + fun selectTab(index: Int) { + val channels = preferenceStore.channels + if (index in channels.indices) { + val channel = channels[index] + chatChannelProvider.setActiveChannel(channel) + chatNotificationRepository.clearUnreadMessage(channel) + chatNotificationRepository.clearMentionCount(channel) + } + } + + fun clearAllMentionCounts() { + chatNotificationRepository.clearMentionCounts() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt new file mode 100644 index 000000000..d89dfb029 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/AddChannelDialog.kt @@ -0,0 +1,32 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.utils.compose.InputBottomSheet + +@Composable +fun AddChannelDialog( + onDismiss: () -> Unit, + onAddChannel: (UserName) -> Unit, + isChannelAlreadyAdded: (String) -> Boolean, +) { + val alreadyAddedError = stringResource(R.string.add_channel_already_added) + InputBottomSheet( + title = stringResource(R.string.add_channel), + hint = stringResource(R.string.add_channel_hint), + showClearButton = true, + validate = { input -> + when { + isChannelAlreadyAdded(input) -> alreadyAddedError + else -> null + } + }, + onConfirm = { name -> + onAddChannel(UserName(name)) + onDismiss() + }, + onDismiss = onDismiss, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt new file mode 100644 index 000000000..e527eef29 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ConfirmationDialog.kt @@ -0,0 +1,26 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.material3.ButtonColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.flxrs.dankchat.R +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet + +@Composable +fun ConfirmationDialog( + title: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + confirmColors: ButtonColors? = null, + dismissText: String = stringResource(R.string.dialog_cancel), +) { + ConfirmationBottomSheet( + title = title, + confirmText = confirmText, + confirmColors = confirmColors, + dismissText = dismissText, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt new file mode 100644 index 000000000..1002529d7 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogState.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.repo.crash.CrashEntry +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class DialogState( + val showAddChannel: Boolean = false, + val showManageChannels: Boolean = false, + val showRemoveChannel: Boolean = false, + val showBlockChannel: Boolean = false, + val showModActions: Boolean = false, + val showLogout: Boolean = false, + val showNewWhisper: Boolean = false, + val pendingUploadAction: (() -> Unit)? = null, + val isUploading: Boolean = false, + val userPopupParams: UserPopupStateParams? = null, + val messageOptionsParams: MessageOptionsParams? = null, + val emoteInfoEmotes: ImmutableList? = null, + val crashEntry: CrashEntry? = null, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt new file mode 100644 index 000000000..c80668660 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/DialogStateViewModel.kt @@ -0,0 +1,156 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.lifecycle.ViewModel +import com.flxrs.dankchat.data.repo.crash.CrashRepository +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import com.flxrs.dankchat.preferences.tools.ToolsSettingsDataStore +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.koin.core.annotation.KoinViewModel +import org.koin.core.annotation.Provided + +@KoinViewModel +class DialogStateViewModel( + private val preferenceStore: DankChatPreferenceStore, + private val toolsSettingsDataStore: ToolsSettingsDataStore, + @Provided private val crashRepository: CrashRepository, + developerSettingsDataStore: DeveloperSettingsDataStore, +) : ViewModel() { + private val _state = MutableStateFlow(DialogState()) + val state: StateFlow = _state.asStateFlow() + + init { + val debugMode = developerSettingsDataStore.current().debugMode + if (debugMode && crashRepository.hasUnshownCrash()) { + val crash = crashRepository.getMostRecentCrash() + if (crash != null) { + update { copy(crashEntry = crash) } + } + crashRepository.markCrashShown() + } + } + + // Channel dialogs + fun showAddChannel() { + update { copy(showAddChannel = true) } + } + + fun dismissAddChannel() { + update { copy(showAddChannel = false) } + } + + fun showManageChannels() { + update { copy(showManageChannels = true) } + } + + fun dismissManageChannels() { + update { copy(showManageChannels = false) } + } + + fun showRemoveChannel() { + update { copy(showRemoveChannel = true) } + } + + fun dismissRemoveChannel() { + update { copy(showRemoveChannel = false) } + } + + fun showBlockChannel() { + update { copy(showBlockChannel = true) } + } + + fun dismissBlockChannel() { + update { copy(showBlockChannel = false) } + } + + fun showModActions() { + update { copy(showModActions = true) } + } + + fun dismissModActions() { + update { copy(showModActions = false) } + } + + // Auth dialogs + fun showLogout() { + update { copy(showLogout = true) } + } + + fun dismissLogout() { + update { copy(showLogout = false) } + } + + // Whisper dialog + fun showNewWhisper() { + update { copy(showNewWhisper = true) } + } + + fun dismissNewWhisper() { + update { copy(showNewWhisper = false) } + } + + // Upload + val uploadHost: String + get() = + runCatching { + java.net.URL(toolsSettingsDataStore.current().uploaderConfig.uploadUrl).host + }.getOrDefault("") + + fun setPendingUploadAction(action: (() -> Unit)?) { + update { copy(pendingUploadAction = action) } + } + + fun acknowledgeExternalHosting() { + preferenceStore.hasExternalHostingAcknowledged = true + } + + fun setUploading(uploading: Boolean) { + update { copy(isUploading = uploading) } + } + + // Message interactions + fun showUserPopup(params: UserPopupStateParams) { + if (!preferenceStore.isLoggedIn) return + update { copy(userPopupParams = params) } + } + + fun dismissUserPopup() { + update { copy(userPopupParams = null) } + } + + fun showMessageOptions(params: MessageOptionsParams) { + update { copy(messageOptionsParams = params) } + } + + fun dismissMessageOptions() { + update { copy(messageOptionsParams = null) } + } + + fun showEmoteInfo(emotes: List) { + update { copy(emoteInfoEmotes = emotes.toImmutableList()) } + } + + fun dismissEmoteInfo() { + update { copy(emoteInfoEmotes = null) } + } + + // Crash report + fun dismissCrashReport() { + update { copy(crashEntry = null) } + } + + fun getCrashReportMessage(): String? { + val entry = _state.value.crashEntry ?: return null + return crashRepository.buildCrashReportMessage(entry) + } + + private inline fun update(crossinline transform: DialogState.() -> DialogState) { + _state.value = _state.value.transform() + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt new file mode 100644 index 000000000..1694327ec --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/EmoteInfoDialog.kt @@ -0,0 +1,298 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.InsertEmoticon +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.R +import com.flxrs.dankchat.ui.chat.emote.EmoteInfoItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmoteInfoDialog( + items: ImmutableList, + isLoggedIn: Boolean, + onUseEmote: (String) -> Unit, + onCopyEmote: (String) -> Unit, + onOpenLink: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { items.size }) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (items.size > 1) { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + items.forEachIndexed { index, item -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { pagerState.animateScrollToPage(index) } + }, + text = { Text(item.name) }, + ) + } + } + } + + val density = LocalDensity.current + val pageHeights = remember { IntArray(items.size) } + + HorizontalPager( + state = pagerState, + beyondViewportPageCount = items.size, + overscrollEffect = null, + modifier = + Modifier + .fillMaxWidth() + .clipToBounds() + .interpolatedHeight(pagerState, pageHeights, density), + ) { page -> + val item = items[page] + EmoteInfoContent( + item = item, + showUseEmote = isLoggedIn, + onUseEmote = { + onUseEmote(item.name) + onDismiss() + }, + onCopyEmote = { + onCopyEmote(item.name) + onDismiss() + }, + onOpenLink = { + onOpenLink(item.providerUrl) + onDismiss() + }, + modifier = + Modifier + .wrapContentHeight(unbounded = true) + .onSizeChanged { pageHeights[page] = it.height }, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +private fun Modifier.interpolatedHeight( + pagerState: PagerState, + pageHeights: IntArray, + density: Density, +): Modifier { + val currentHeight = pageHeights.getOrNull(pagerState.currentPage) ?: 0 + if (currentHeight == 0) return this + + val fraction = pagerState.currentPageOffsetFraction + val targetPage = when { + fraction > 0 -> (pagerState.currentPage + 1).coerceAtMost(pagerState.pageCount - 1) + fraction < 0 -> (pagerState.currentPage - 1).coerceAtLeast(0) + else -> pagerState.currentPage + } + val targetHeight = pageHeights.getOrNull(targetPage) ?: currentHeight + val interpolatedPx = lerp(currentHeight, targetHeight, fraction.absoluteValue) + val heightDp = with(density) { interpolatedPx.toDp() } + return then(Modifier.height(heightDp)) +} + +private const val WIDE_EMOTE_THRESHOLD = 1.5f + +@Composable +private fun EmoteInfoContent( + item: EmoteInfoItem, + showUseEmote: Boolean, + onUseEmote: () -> Unit, + onCopyEmote: () -> Unit, + onOpenLink: () -> Unit, + modifier: Modifier = Modifier, +) { + var aspectRatio by remember { mutableFloatStateOf(1f) } + val isWide = aspectRatio > WIDE_EMOTE_THRESHOLD + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isWide) { + WideEmoteHeader(item = item, onAspectRatio = { aspectRatio = it }) + } else { + SquareEmoteHeader(item = item, onAspectRatio = { aspectRatio = it }) + } + + if (showUseEmote) { + ListItem( + headlineContent = { Text(stringResource(R.string.emote_sheet_use)) }, + leadingContent = { Icon(Icons.Default.InsertEmoticon, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onUseEmote), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + ListItem( + headlineContent = { Text(stringResource(R.string.emote_sheet_copy)) }, + leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onCopyEmote), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { Text(stringResource(R.string.emote_sheet_open_link)) }, + leadingContent = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onOpenLink), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } +} + +@Composable +private fun SquareEmoteHeader( + item: EmoteInfoItem, + onAspectRatio: (Float) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top, + ) { + AsyncImage( + model = item.imageUrl, + contentDescription = stringResource(R.string.emote_sheet_image_description), + modifier = Modifier.size(96.dp), + onSuccess = { state -> + val size = state.result.image + if (size.height > 0) { + onAspectRatio(size.width.toFloat() / size.height) + } + }, + ) + Spacer(modifier = Modifier.width(16.dp)) + EmoteInfoText(item = item, modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun WideEmoteHeader( + item: EmoteInfoItem, + onAspectRatio: (Float) -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + EmoteInfoText(item = item) + Spacer(modifier = Modifier.height(12.dp)) + AsyncImage( + model = item.imageUrl, + contentDescription = stringResource(R.string.emote_sheet_image_description), + contentScale = ContentScale.Fit, + modifier = + Modifier + .heightIn(max = 96.dp) + .fillMaxWidth(0.5f), + onSuccess = { state -> + val size = state.result.image + if (size.height > 0) { + onAspectRatio(size.width.toFloat() / size.height) + } + }, + ) + } +} + +@Composable +private fun EmoteInfoText( + item: EmoteInfoItem, + modifier: Modifier = Modifier, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Text( + text = item.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(item.emoteType), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + item.baseName?.let { + Text( + text = stringResource(R.string.emote_sheet_alias_of, it), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + item.creatorName?.let { + Text( + text = stringResource(R.string.emote_sheet_created_by, it), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + Text( + text = if (item.isZeroWidth) stringResource(R.string.emote_sheet_zero_width_emote) else "", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt new file mode 100644 index 000000000..06ad9cd8a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MainScreenDialogs.kt @@ -0,0 +1,867 @@ +package com.flxrs.dankchat.ui.main.dialog + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.StartupValidation +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.data.repo.crash.CrashEntry +import com.flxrs.dankchat.data.repo.crash.CrashRepository +import com.flxrs.dankchat.data.repo.log.LogRepository +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.emote.EmoteInfoViewModel +import com.flxrs.dankchat.ui.chat.history.HistoryChannel +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.message.MessageOptionsState +import com.flxrs.dankchat.ui.chat.message.MessageOptionsViewModel +import com.flxrs.dankchat.ui.chat.user.UserPopupDialog +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import com.flxrs.dankchat.ui.chat.user.UserPopupViewModel +import com.flxrs.dankchat.ui.main.MainEvent +import com.flxrs.dankchat.ui.main.MainEventBus +import com.flxrs.dankchat.ui.main.channel.ChannelManagementViewModel +import com.flxrs.dankchat.ui.main.input.ChatInputViewModel +import com.flxrs.dankchat.ui.main.sheet.DebugInfoSheet +import com.flxrs.dankchat.ui.main.sheet.DebugInfoViewModel +import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState +import com.flxrs.dankchat.ui.main.sheet.InputSheetState +import com.flxrs.dankchat.ui.main.sheet.SheetNavigationViewModel +import com.flxrs.dankchat.utils.compose.ConfirmationBottomSheet +import com.flxrs.dankchat.utils.compose.InfoBottomSheet +import com.flxrs.dankchat.utils.compose.InputBottomSheet +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreenDialogs( + dialogViewModel: DialogStateViewModel, + isLoggedIn: Boolean, + activeChannel: UserName?, + modActionsChannel: UserName?, + isStreamActive: Boolean, + inputSheetState: InputSheetState, + sheetsReady: Boolean, + onAddChannel: (UserName) -> Unit, + onLogout: () -> Unit, + onLogin: () -> Unit, + onReportChannel: () -> Unit, + onOpenUrl: (String) -> Unit, + onJumpToMessage: (messageId: String, channel: UserName) -> Unit = { _, _ -> }, + onOpenLogViewer: () -> Unit = {}, +) { + val dialogState by dialogViewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + val channelManagementViewModel: ChannelManagementViewModel = koinViewModel() + val chatInputViewModel: ChatInputViewModel = koinViewModel() + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val startupValidationHolder: StartupValidationHolder = koinInject() + val startupValidation by startupValidationHolder.state.collectAsStateWithLifecycle() + + if (dialogState.showAddChannel) { + AddChannelDialog( + onDismiss = dialogViewModel::dismissAddChannel, + onAddChannel = onAddChannel, + isChannelAlreadyAdded = channelManagementViewModel::isChannelAdded, + ) + } + + if (dialogState.showManageChannels) { + val channels by channelManagementViewModel.channels.collectAsStateWithLifecycle() + ManageChannelsDialog( + channels = channels, + onApplyChanges = channelManagementViewModel::applyChanges, + onChannelSelect = channelManagementViewModel::selectChannel, + onDismiss = dialogViewModel::dismissManageChannels, + ) + } + + if (sheetsReady && dialogState.showModActions && modActionsChannel != null) { + ModActionsDialogContainer( + channel = modActionsChannel, + isStreamActive = isStreamActive, + onSendCommand = chatInputViewModel::trySendMessageOrCommand, + onAnnounce = { chatInputViewModel.setAnnouncing(true) }, + onDismiss = dialogViewModel::dismissModActions, + ) + } + + if (dialogState.showRemoveChannel && activeChannel != null) { + ConfirmationDialog( + title = stringResource(R.string.confirm_channel_removal_message_named, activeChannel), + confirmText = stringResource(R.string.confirm_channel_removal_positive_button), + confirmColors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onConfirm = { + channelManagementViewModel.removeChannel(activeChannel) + dialogViewModel.dismissRemoveChannel() + }, + onDismiss = dialogViewModel::dismissRemoveChannel, + ) + } + + if (dialogState.showBlockChannel && activeChannel != null) { + ConfirmationDialog( + title = stringResource(R.string.confirm_channel_block_message_named, activeChannel), + confirmText = stringResource(R.string.confirm_user_block_positive_button), + confirmColors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onConfirm = { + channelManagementViewModel.blockChannel(activeChannel) + dialogViewModel.dismissBlockChannel() + }, + onDismiss = dialogViewModel::dismissBlockChannel, + ) + } + + if (dialogState.showLogout) { + ConfirmationBottomSheet( + title = stringResource(R.string.confirm_logout_message), + confirmText = stringResource(R.string.confirm_logout_positive_button), + confirmColors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onConfirm = { + onLogout() + dialogViewModel.dismissLogout() + }, + onDismiss = dialogViewModel::dismissLogout, + ) + } + + if (dialogState.pendingUploadAction != null) { + UploadDisclaimerSheet( + host = dialogViewModel.uploadHost, + onConfirm = { + dialogViewModel.acknowledgeExternalHosting() + val action = dialogState.pendingUploadAction + dialogViewModel.setPendingUploadAction(null) + action?.invoke() + }, + onDismiss = { dialogViewModel.setPendingUploadAction(null) }, + ) + } + + if (dialogState.showNewWhisper) { + InputBottomSheet( + title = stringResource(R.string.whisper_new_dialog_title), + hint = stringResource(R.string.whisper_new_dialog_hint), + confirmText = stringResource(R.string.whisper_new_dialog_start), + showClearButton = true, + onConfirm = { username -> + chatInputViewModel.setWhisperTarget(UserName(username)) + dialogViewModel.dismissNewWhisper() + }, + onDismiss = dialogViewModel::dismissNewWhisper, + ) + } + + if (startupValidation is StartupValidation.ScopesOutdated) { + InfoBottomSheet( + title = stringResource(R.string.login_outdated_title), + message = stringResource(R.string.login_outdated_message), + confirmText = stringResource(R.string.oauth_expired_login_again), + dismissible = false, + onConfirm = onLogin, + onDismiss = startupValidationHolder::acknowledge, + ) + } + + if (startupValidation is StartupValidation.TokenInvalid) { + InfoBottomSheet( + title = stringResource(R.string.oauth_expired_title), + message = stringResource(R.string.oauth_expired_message), + confirmText = stringResource(R.string.oauth_expired_login_again), + dismissText = stringResource(R.string.confirm_logout_positive_button), + dismissible = false, + onConfirm = onLogin, + onDismiss = { + startupValidationHolder.acknowledge() + onLogout() + }, + ) + } + + if (sheetsReady) { + dialogState.messageOptionsParams?.let { params -> + MessageOptionsDialogContainer( + params = params, + onJumpToMessage = onJumpToMessage, + onSetReplying = chatInputViewModel::setReplying, + onOpenReplies = sheetNavigationViewModel::openReplies, + onDismiss = dialogViewModel::dismissMessageOptions, + ) + } + } + + if (sheetsReady) { + dialogState.emoteInfoEmotes?.let { emotes -> + EmoteInfoDialogContainer( + emotes = emotes, + isLoggedIn = isLoggedIn, + onInsertText = chatInputViewModel::insertText, + onOpenUrl = onOpenUrl, + onDismiss = dialogViewModel::dismissEmoteInfo, + ) + } + } + + if (sheetsReady) { + dialogState.userPopupParams?.let { params -> + val currentSheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() + val isHistoryOpen = currentSheetState is FullScreenSheetState.History + UserPopupDialogContainer( + params = params, + onMention = chatInputViewModel::mentionUser, + onWhisper = { userName -> + sheetNavigationViewModel.openWhispers() + chatInputViewModel.setWhisperTarget(userName) + }, + onOpenUrl = onOpenUrl, + onReportChannel = onReportChannel, + onOpenHistory = { channel, filter -> + sheetNavigationViewModel.openHistory(channel, filter) + dialogViewModel.dismissUserPopup() + }, + onViewHistory = when { + isHistoryOpen -> { userName -> + val historyState = currentSheetState as FullScreenSheetState.History + sheetNavigationViewModel.openHistory(historyState.channel, "from:$userName") + dialogViewModel.dismissUserPopup() + } + + else -> null + }, + onDismiss = dialogViewModel::dismissUserPopup, + ) + } + } + + dialogState.crashEntry?.let { crashEntry -> + val crashRepository: CrashRepository = koinInject() + val logRepository: LogRepository = koinInject() + val preferenceStore: DankChatPreferenceStore = koinInject() + CrashReportDialog( + crashEntry = crashEntry, + userName = preferenceStore.userName?.value, + userId = preferenceStore.userIdString?.value, + onCopy = { + val fullReport = crashRepository.buildEmailBody(crashEntry, preferenceStore.userName?.value, preferenceStore.userIdString?.value) + val clipboardManager = context.getSystemService() + clipboardManager?.setPrimaryClip(ClipData.newPlainText("crash_report", fullReport)) + }, + onReport = { + val reportMessage = dialogViewModel.getCrashReportMessage() ?: return@CrashReportDialog + val channel = UserName(CRASH_REPORT_CHANNEL) + if (!channelManagementViewModel.isChannelAdded(CRASH_REPORT_CHANNEL)) { + channelManagementViewModel.addChannel(channel) + } else { + channelManagementViewModel.selectChannel(channel) + } + chatInputViewModel.insertText(reportMessage) + dialogViewModel.dismissCrashReport() + }, + onSendEmail = { includeLogs -> + sendCrashEmail( + context = context, + crashEntry = crashEntry, + crashRepository = crashRepository, + logRepository = logRepository, + preferenceStore = preferenceStore, + includeLogs = includeLogs, + ) + dialogViewModel.dismissCrashReport() + }, + onDismiss = dialogViewModel::dismissCrashReport, + ) + } + + if (sheetsReady && inputSheetState is InputSheetState.DebugInfo) { + val debugInfoViewModel: DebugInfoViewModel = koinViewModel() + DebugInfoSheet( + viewModel = debugInfoViewModel, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismiss = sheetNavigationViewModel::closeInputSheet, + onOpenLogViewer = onOpenLogViewer, + ) + } +} + +@Composable +private fun UploadDisclaimerSheet( + host: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + LocalUriHandler.current + val disclaimerTemplate = stringResource(R.string.external_upload_disclaimer, host) + val hostStart = disclaimerTemplate.indexOf(host) + val annotatedText = + buildAnnotatedString { + append(disclaimerTemplate) + if (hostStart >= 0) { + addStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + start = hostStart, + end = hostStart + host.length, + ) + addLink( + url = LinkAnnotation.Url("https://$host"), + start = hostStart, + end = hostStart + host.length, + ) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.nuuls_upload_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button(onClick = onConfirm, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_ok)) + } + } + } + } +} + +@Composable +private fun ModActionsDialogContainer( + channel: UserName, + isStreamActive: Boolean, + onSendCommand: (String) -> Unit, + onAnnounce: () -> Unit, + onDismiss: () -> Unit, +) { + val viewModel: ModActionsViewModel = + koinViewModel( + key = "mod-actions-${channel.value}", + parameters = { parametersOf(channel) }, + ) + val shieldModeActive by viewModel.shieldModeActive.collectAsStateWithLifecycle() + val roomState by viewModel.roomState.collectAsStateWithLifecycle() + ModActionsDialog( + roomState = roomState, + isBroadcaster = viewModel.isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, + onSendCommand = onSendCommand, + onAnnounce = onAnnounce, + onDismiss = onDismiss, + ) +} + +@Composable +private fun MessageOptionsDialogContainer( + params: MessageOptionsParams, + onJumpToMessage: (String, UserName) -> Unit, + onSetReplying: (Boolean, String, UserName, String) -> Unit, + onOpenReplies: (String, UserName) -> Unit, + onDismiss: () -> Unit, +) { + val viewModel: MessageOptionsViewModel = + koinViewModel( + key = "${params.messageId}-${params.canReply}-${params.canModerate}", + parameters = { parametersOf(params.messageId, params.channel, params.canModerate, params.canReply) }, + ) + val state by viewModel.state.collectAsStateWithLifecycle() + val clipboardManager = LocalClipboard.current + val mainEventBus: MainEventBus = koinInject() + val scope = rememberCoroutineScope() + + (state as? MessageOptionsState.Found)?.let { s -> + MessageOptionsDialog( + channel = params.channel?.value, + canModerate = s.canModerate, + canReply = s.canReply, + canCopy = params.canCopy, + canJump = params.canJump, + hasReplyThread = s.hasReplyThread, + onJumpToMessage = { + params.channel?.let { channel -> + onJumpToMessage(params.messageId, channel) + } + }, + onReply = { onSetReplying(true, s.messageId, s.replyName, s.originalMessage) }, + onReplyToOriginal = { onSetReplying(true, s.rootThreadId, s.rootThreadName ?: s.replyName, s.rootThreadMessage.orEmpty()) }, + onViewThread = { onOpenReplies(s.rootThreadId, s.replyName) }, + onCopy = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message", s.originalMessage))) + mainEventBus.emitEvent(MainEvent.MessageCopied(s.originalMessage)) + } + }, + onCopyFullMessage = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("full message", params.fullMessage))) + mainEventBus.emitEvent(MainEvent.MessageCopied(params.fullMessage)) + } + }, + onCopyMessageId = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("message id", s.messageId))) + mainEventBus.emitEvent(MainEvent.MessageIdCopied) + } + }, + onDelete = viewModel::deleteMessage, + onTimeout = viewModel::timeoutUser, + onBan = viewModel::banUser, + onUnban = viewModel::unbanUser, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun EmoteInfoDialogContainer( + emotes: List, + isLoggedIn: Boolean, + onInsertText: (String) -> Unit, + onOpenUrl: (String) -> Unit, + onDismiss: () -> Unit, +) { + val viewModel: EmoteInfoViewModel = + koinViewModel( + key = emotes.joinToString { it.id }, + parameters = { parametersOf(emotes) }, + ) + val sheetNavigationViewModel: SheetNavigationViewModel = koinViewModel() + val chatInputViewModel: ChatInputViewModel = koinViewModel() + val sheetState by sheetNavigationViewModel.fullScreenSheetState.collectAsStateWithLifecycle() + val whisperTarget by chatInputViewModel.whisperTarget.collectAsStateWithLifecycle() + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val canUseEmote = + isLoggedIn && + when (sheetState) { + is FullScreenSheetState.Closed, + is FullScreenSheetState.Replies, + -> true + + is FullScreenSheetState.Mention, + is FullScreenSheetState.Whisper, + -> whisperTarget != null + + is FullScreenSheetState.History -> false + } + EmoteInfoDialog( + items = viewModel.items, + isLoggedIn = canUseEmote, + onUseEmote = { onInsertText("$it ") }, + onCopyEmote = { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("emote", it))) + } + }, + onOpenLink = onOpenUrl, + onDismiss = onDismiss, + ) +} + +@Composable +private fun UserPopupDialogContainer( + params: UserPopupStateParams, + onMention: (UserName, DisplayName) -> Unit, + onWhisper: (UserName) -> Unit, + onOpenUrl: (String) -> Unit, + onReportChannel: () -> Unit, + onOpenHistory: (HistoryChannel, String) -> Unit, + onViewHistory: ((String) -> Unit)? = null, + onDismiss: () -> Unit, +) { + val viewModel: UserPopupViewModel = + koinViewModel( + key = "${params.targetUserId}${params.channel?.value.orEmpty()}", + parameters = { parametersOf(params) }, + ) + val state by viewModel.userPopupState.collectAsStateWithLifecycle() + UserPopupDialog( + state = state, + badges = params.badges.mapIndexed { index, badge -> BadgeUi(badge.url, badge, index) }.toImmutableList(), + isOwnUser = viewModel.isOwnUser, + onBlockUser = viewModel::blockUser, + onUnblockUser = viewModel::unblockUser, + onDismiss = onDismiss, + onMention = when (onViewHistory) { + null -> { name: String, displayName: String -> + onMention(UserName(name), DisplayName(displayName)) + } + + else -> null + }, + onWhisper = when (onViewHistory) { + null -> { name: String -> onWhisper(UserName(name)) } + else -> null + }, + onOpenChannel = { userName -> onOpenUrl("https://twitch.tv/$userName") }, + onReport = { _ -> onReportChannel() }, + onMessageHistory = when (onViewHistory) { + null -> { userName: String -> + params.channel?.let { channel -> + onOpenHistory(HistoryChannel.Channel(channel), "from:$userName") + } + Unit + } + + else -> null + }, + onViewHistory = onViewHistory, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CrashReportDialog( + crashEntry: CrashEntry, + userName: String?, + userId: String?, + onCopy: () -> Unit, + onReport: () -> Unit, + onSendEmail: (includeLogs: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var showEmailConfirmation by remember { mutableStateOf(false) } + var includeLogs by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = showEmailConfirmation, + label = "CrashReportContent", + ) { emailConfirmation -> + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + when { + emailConfirmation -> { + Text( + text = stringResource(R.string.crash_email_confirm_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Text( + text = stringResource(R.string.crash_email_confirm_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 8.dp) + .padding(bottom = 12.dp), + ) + + Column( + modifier = Modifier.padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Version: ${crashEntry.version}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Android: ${crashEntry.androidInfo}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Device: ${crashEntry.device}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (userName != null) { + Text( + text = "User: $userName (ID: ${userId.orEmpty()})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(R.string.crash_email_confirm_stacktrace), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clickable { includeLogs = !includeLogs } + .padding(start = 2.dp), + ) { + Checkbox( + checked = includeLogs, + onCheckedChange = null, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.crash_email_confirm_include_logs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 8.dp, end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { showEmailConfirmation = false }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.dialog_cancel)) + } + Button( + onClick = { onSendEmail(includeLogs) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.crash_report_email)) + } + } + } + + else -> { + Text( + text = stringResource(R.string.crash_report_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Text( + text = stringResource(R.string.crash_report_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp), + ) + + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 8.dp, end = 8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = crashEntry.exceptionHeader, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + fontFamily = FontFamily.Monospace, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.crash_report_thread, crashEntry.threadName), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = "v${crashEntry.version}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp, start = 8.dp, end = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = onCopy, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.crash_report_copy)) + } + Button( + onClick = { showEmailConfirmation = true }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Email, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.crash_report_email)) + } + } + OutlinedButton( + onClick = onReport, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.crash_report_send)) + } + Text( + text = stringResource(R.string.crash_report_send_description), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +private fun sendCrashEmail( + context: Context, + crashEntry: CrashEntry, + crashRepository: CrashRepository, + logRepository: LogRepository, + preferenceStore: DankChatPreferenceStore, + includeLogs: Boolean, +) { + val userName = preferenceStore.userName?.value + val userId = preferenceStore.userIdString?.value + val body = crashRepository.buildEmailBody(crashEntry, userName, userId) + val exceptionType = crashEntry.exceptionHeader.substringBefore(':').substringAfterLast('.') + val subject = "DankChat Crash Report [${userName.orEmpty()}] - $exceptionType" + + val emailIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf(CRASH_REPORT_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + if (includeLogs) { + val logFile = logRepository.getLatestLogFile() + if (logFile != null) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + selector = Intent(Intent.ACTION_SENDTO, "mailto:".toUri()) + } + context.startActivity(Intent.createChooser(emailIntent, null)) +} + +private const val CRASH_REPORT_CHANNEL = "flex3rs" +private const val CRASH_REPORT_EMAIL = "dankchat@flxrs.com" diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt new file mode 100644 index 000000000..ce1954d65 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ManageChannelsDialog.kt @@ -0,0 +1,408 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.preferences.model.ChannelWithRename +import com.flxrs.dankchat.utils.compose.BottomSheetNestedScrollConnection +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ManageChannelsDialog( + channels: List, + onApplyChanges: (List) -> Unit, + onChannelSelect: (UserName) -> Unit, + onDismiss: () -> Unit, +) { + var channelToDelete by remember { mutableStateOf(null) } + var editingChannel by remember { mutableStateOf(null) } + + // Local state for smooth reordering and deferred updates + val localChannels = remember { mutableStateListOf() } + LaunchedEffect(channels) { + if (localChannels.isEmpty() && channels.isNotEmpty()) { + localChannels.addAll(channels) + } + } + + val lazyListState = rememberLazyListState() + val reorderableState = + rememberReorderableLazyListState(lazyListState) { from, to -> + if (from.index in localChannels.indices && to.index in localChannels.indices) { + localChannels.apply { + add(to.index, removeAt(from.index)) + } + } + } + + ModalBottomSheet( + onDismissRequest = { + onApplyChanges(localChannels.toList()) + onDismiss() + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + contentWindowInsets = { WindowInsets.statusBars }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = channelToDelete, + label = "ManageChannelsContent", + ) { deleteTarget -> + when (deleteTarget) { + null -> { + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .nestedScroll(BottomSheetNestedScrollConnection), + state = lazyListState, + contentPadding = navBarPadding, + ) { + itemsIndexed(localChannels, key = { _, item -> item.channel.value }) { index, channelWithRename -> + ReorderableItem(reorderableState, key = channelWithRename.channel.value) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) + + Surface( + shadowElevation = elevation, + color = + when { + isDragging -> MaterialTheme.colorScheme.surfaceContainerHighest + else -> Color.Transparent + }, + ) { + Column { + ChannelItem( + channelWithRename = channelWithRename, + isEditing = editingChannel == channelWithRename.channel, + modifier = + Modifier.longPressDraggableHandle( + onDragStarted = { /* Optional haptic feedback here */ }, + onDragStopped = { /* Optional haptic feedback here */ }, + ), + onNavigate = { + onApplyChanges(localChannels.toList()) + onChannelSelect(channelWithRename.channel) + onDismiss() + }, + onEdit = { + editingChannel = + when (editingChannel) { + channelWithRename.channel -> null + else -> channelWithRename.channel + } + }, + onRename = { newName -> + val rename = newName?.ifBlank { null }?.let { UserName(it) } + localChannels[index] = localChannels[index].copy(rename = rename) + editingChannel = null + }, + onDelete = { channelToDelete = channelWithRename.channel }, + ) + if (index < localChannels.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + } + } + } + } + + if (localChannels.isEmpty()) { + item { + Text( + text = stringResource(R.string.no_channels_added), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + } + } + + else -> { + DeleteChannelConfirmation( + channelName = deleteTarget, + onConfirm = { + localChannels.removeIf { it.channel == deleteTarget } + channelToDelete = null + }, + onBack = { channelToDelete = null }, + ) + } + } + } + } +} + +@Suppress("LambdaParameterEventTrailing") +@Composable +private fun ChannelItem( + channelWithRename: ChannelWithRename, + isEditing: Boolean, + onNavigate: () -> Unit, + onEdit: () -> Unit, + onRename: (String?) -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp), + ) + + Text( + text = + buildAnnotatedString { + append(channelWithRename.rename?.value ?: channelWithRename.channel.value) + if (channelWithRename.rename != null && channelWithRename.rename != channelWithRename.channel) { + append(" ") + withStyle(SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), fontStyle = FontStyle.Italic)) { + append(channelWithRename.channel.value) + } + } + }, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + + IconButton(onClick = onNavigate) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.open_channel), + ) + } + + IconButton(onClick = onEdit) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = stringResource(R.string.edit_dialog_title), + ) + } + + IconButton(onClick = onDelete) { + Icon( + painter = painterResource(R.drawable.ic_delete_outline), + contentDescription = stringResource(R.string.remove_channel), + ) + } + } + + AnimatedVisibility( + visible = isEditing, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = Modifier.imePadding(), + ) { + InlineRenameField( + channelWithRename = channelWithRename, + onRename = onRename, + ) + } + } +} + +@Composable +private fun InlineRenameField( + channelWithRename: ChannelWithRename, + onRename: (String?) -> Unit, +) { + val initialText = channelWithRename.rename?.value.orEmpty() + var renameText by remember(channelWithRename.channel) { + mutableStateOf( + TextFieldValue( + text = initialText, + selection = TextRange(initialText.length), + ), + ) + } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 56.dp, end = 8.dp, bottom = 8.dp), + ) { + Text( + text = stringResource(R.string.edit_dialog_title), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = renameText, + onValueChange = { renameText = it }, + placeholder = { Text(channelWithRename.channel.value) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions(onDone = { + onRename(renameText.text) + }), + trailingIcon = + if (renameText.text.isNotEmpty()) { + { + IconButton(onClick = { onRename(null) }) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.clear), + ) + } + } + } else { + null + }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + ) + + TextButton( + onClick = { onRename(renameText.text) }, + ) { + Text(stringResource(R.string.save)) + } + } + } +} + +@Composable +private fun DeleteChannelConfirmation( + channelName: UserName, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.confirm_channel_removal_message_named, channelName.value), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.confirm_channel_removal_positive_button)) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt new file mode 100644 index 000000000..c6c67b1ad --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/MessageOptionsDialog.kt @@ -0,0 +1,375 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +private enum class MessageOptionsSubView { + Timeout, + Ban, + Delete, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageOptionsDialog( + channel: String?, + canModerate: Boolean, + canReply: Boolean, + canCopy: Boolean, + canJump: Boolean, + hasReplyThread: Boolean, + onReply: () -> Unit, + onReplyToOriginal: () -> Unit, + onJumpToMessage: () -> Unit, + onViewThread: () -> Unit, + onCopy: () -> Unit, + onCopyFullMessage: () -> Unit, + onCopyMessageId: () -> Unit, + onDelete: () -> Unit, + onTimeout: (index: Int) -> Unit, + onBan: () -> Unit, + onUnban: () -> Unit, + onDismiss: () -> Unit, +) { + var subView by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = subView, + label = "MessageOptionsContent", + ) { currentView -> + when (currentView) { + null -> { + MessageOptionsMainView( + canReply = canReply, + canJump = canJump, + canCopy = canCopy, + canModerate = canModerate, + hasReplyThread = hasReplyThread, + channel = channel, + onReply = { + onReply() + onDismiss() + }, + onReplyToOriginal = { + onReplyToOriginal() + onDismiss() + }, + onJumpToMessage = { + onJumpToMessage() + onDismiss() + }, + onViewThread = { + onViewThread() + onDismiss() + }, + onCopy = { + onCopy() + onDismiss() + }, + onCopyFullMessage = { + onCopyFullMessage() + onDismiss() + }, + onCopyMessageId = { + onCopyMessageId() + onDismiss() + }, + onUnban = { + onUnban() + onDismiss() + }, + onTimeout = { subView = MessageOptionsSubView.Timeout }, + onBan = { subView = MessageOptionsSubView.Ban }, + onDelete = { subView = MessageOptionsSubView.Delete }, + ) + } + + MessageOptionsSubView.Timeout -> { + TimeoutSubView( + onConfirm = { index -> + onTimeout(index) + onDismiss() + }, + onBack = { subView = null }, + ) + } + + MessageOptionsSubView.Ban -> { + ConfirmationSubView( + title = stringResource(R.string.confirm_user_ban_message), + confirmText = stringResource(R.string.confirm_user_ban_positive_button), + onConfirm = { + onBan() + onDismiss() + }, + onBack = { subView = null }, + ) + } + + MessageOptionsSubView.Delete -> { + ConfirmationSubView( + title = stringResource(R.string.confirm_user_delete_message), + confirmText = stringResource(R.string.confirm_user_delete_positive_button), + onConfirm = { + onDelete() + onDismiss() + }, + onBack = { subView = null }, + ) + } + } + } + } +} + +@Composable +private fun MessageOptionsMainView( + canReply: Boolean, + canJump: Boolean, + canCopy: Boolean, + canModerate: Boolean, + hasReplyThread: Boolean, + channel: String?, + onReply: () -> Unit, + onReplyToOriginal: () -> Unit, + onJumpToMessage: () -> Unit, + onViewThread: () -> Unit, + onCopy: () -> Unit, + onCopyFullMessage: () -> Unit, + onCopyMessageId: () -> Unit, + onUnban: () -> Unit, + onTimeout: () -> Unit, + onBan: () -> Unit, + onDelete: () -> Unit, +) { + var moreExpanded by remember { mutableStateOf(false) } + val arrowRotation by animateFloatAsState( + targetValue = if (moreExpanded) 180f else 0f, + label = "arrowRotation", + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + if (canReply) { + MessageOptionItem(Icons.AutoMirrored.Filled.Reply, stringResource(R.string.message_reply), onReply) + } + if (canReply && hasReplyThread) { + MessageOptionItem(Icons.AutoMirrored.Filled.Reply, stringResource(R.string.message_reply_original), onReplyToOriginal) + MessageOptionItem(Icons.AutoMirrored.Filled.Reply, stringResource(R.string.message_view_thread), onViewThread) + } + if (canJump && channel != null) { + MessageOptionItem(Icons.AutoMirrored.Filled.OpenInNew, stringResource(R.string.message_jump_to), onJumpToMessage) + } + if (canCopy) { + MessageOptionItem(Icons.Default.ContentCopy, stringResource(R.string.message_copy), onCopy) + ListItem( + headlineContent = { Text(stringResource(R.string.message_more_actions)) }, + leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = null) }, + trailingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.rotate(arrowRotation), + ) + }, + modifier = Modifier.clickable { moreExpanded = !moreExpanded }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + AnimatedVisibility( + visible = moreExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + MessageOptionItem(Icons.Default.ContentCopy, stringResource(R.string.message_copy_full), onCopyFullMessage) + MessageOptionItem(Icons.Default.ContentCopy, stringResource(R.string.message_copy_id), onCopyMessageId) + } + } + } + if (canModerate) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + MessageOptionItem(Icons.Default.Timer, stringResource(R.string.user_popup_timeout), onTimeout) + MessageOptionItem(Icons.Default.Delete, stringResource(R.string.user_popup_delete), onDelete) + MessageOptionItem(Icons.Default.Gavel, stringResource(R.string.user_popup_ban), onBan) + MessageOptionItem(Icons.Default.Gavel, stringResource(R.string.user_popup_unban), onUnban) + } + } +} + +@Composable +private fun MessageOptionItem( + icon: ImageVector, + text: String, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(text) }, + leadingContent = { Icon(icon, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +@Composable +private fun TimeoutSubView( + onConfirm: (Int) -> Unit, + onBack: () -> Unit, +) { + val choices = stringArrayResource(R.array.timeout_entries) + var sliderPosition by remember { mutableFloatStateOf(0f) } + val currentIndex = sliderPosition.toInt() + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.confirm_user_timeout_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Text( + text = choices[currentIndex], + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + ) + + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + valueRange = 0f..(choices.size - 1).toFloat(), + steps = choices.size - 2, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button(onClick = { onConfirm(currentIndex) }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.confirm_user_timeout_positive_button)) + } + } + } +} + +@Composable +private fun ConfirmationSubView( + title: String, + confirmText: String, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(confirmText) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt new file mode 100644 index 000000000..44c01d872 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsDialog.kt @@ -0,0 +1,755 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.message.RoomState +import com.flxrs.dankchat.utils.DateTimeUtils + +private sealed interface SubView { + data object SlowMode : SubView + + data object SlowModeCustom : SubView + + data object FollowerMode : SubView + + data object FollowerModeCustom : SubView + + data object CommercialPresets : SubView + + data object RaidInput : SubView + + data object ShoutoutInput : SubView + + data object ClearChatConfirm : SubView + + data object ShieldModeConfirm : SubView +} + +private val SLOW_MODE_PRESETS = listOf(3, 5, 10, 20, 30, 60, 120) +private val COMMERCIAL_PRESETS = listOf(30, 60, 90, 120, 150, 180) + +private data class FollowerPreset( + val minutes: Int, + val commandArg: String, +) + +private val FOLLOWER_MODE_PRESETS = + listOf( + FollowerPreset(0, "0"), + FollowerPreset(10, "10m"), + FollowerPreset(30, "30m"), + FollowerPreset(60, "1h"), + FollowerPreset(1440, "1d"), + FollowerPreset(10080, "1w"), + FollowerPreset(43200, "30d"), + FollowerPreset(129600, "90d"), + ) + +@Composable +private fun formatFollowerPreset(minutes: Int): String = when (minutes) { + 0 -> stringResource(R.string.room_state_follower_any) + in 1..59 -> stringResource(R.string.room_state_duration_minutes, minutes) + in 60..1439 -> stringResource(R.string.room_state_duration_hours, minutes / 60) + in 1440..10079 -> stringResource(R.string.room_state_duration_days, minutes / 1440) + in 10080..43199 -> stringResource(R.string.room_state_duration_weeks, minutes / 10080) + else -> stringResource(R.string.room_state_duration_months, minutes / 43200) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ModActionsDialog( + roomState: RoomState?, + isBroadcaster: Boolean, + isStreamActive: Boolean, + shieldModeActive: Boolean?, + onSendCommand: (String) -> Unit, + onAnnounce: () -> Unit, + onDismiss: () -> Unit, +) { + var subView by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + AnimatedContent( + targetState = subView, + label = "ModActionsContent", + ) { currentView -> + when (currentView) { + null -> { + ModActionsMainView( + roomState = roomState, + isBroadcaster = isBroadcaster, + isStreamActive = isStreamActive, + shieldModeActive = shieldModeActive, + onSendCommand = onSendCommand, + onShowSubView = { subView = it }, + onClearChat = { subView = SubView.ClearChatConfirm }, + onAnnounce = onAnnounce, + onDismiss = onDismiss, + ) + } + + SubView.SlowMode -> { + PresetChips( + titleRes = R.string.room_state_slow_mode, + presets = SLOW_MODE_PRESETS, + formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, + onPresetClick = { value -> + onSendCommand("/slow $value") + onDismiss() + }, + onCustomClick = { subView = SubView.SlowModeCustom }, + ) + } + + SubView.SlowModeCustom -> { + UserInputSubView( + titleRes = R.string.room_state_slow_mode, + hintRes = R.string.seconds, + defaultValue = "30", + keyboardType = KeyboardType.Number, + onConfirm = { value -> + onSendCommand("/slow $value") + onDismiss() + }, + onDismiss = onDismiss, + ) + } + + SubView.FollowerMode -> { + FollowerPresetChips( + onPresetClick = { preset -> + onSendCommand("/followers ${preset.commandArg}") + onDismiss() + }, + onCustomClick = { subView = SubView.FollowerModeCustom }, + ) + } + + SubView.FollowerModeCustom -> { + UserInputSubView( + titleRes = R.string.room_state_follower_only, + hintRes = R.string.minutes, + defaultValue = "10", + keyboardType = KeyboardType.Number, + onConfirm = { value -> + onSendCommand("/followers $value") + onDismiss() + }, + onDismiss = onDismiss, + ) + } + + SubView.CommercialPresets -> { + PresetChips( + titleRes = R.string.mod_actions_commercial, + presets = COMMERCIAL_PRESETS, + formatLabel = { stringResource(R.string.room_state_duration_seconds, it) }, + onPresetClick = { value -> + onSendCommand("/commercial $value") + onDismiss() + }, + onCustomClick = null, + ) + } + + SubView.RaidInput -> { + UserInputSubView( + titleRes = R.string.mod_actions_raid, + hintRes = R.string.mod_actions_channel_hint, + onConfirm = { target -> + onSendCommand("/raid $target") + onDismiss() + }, + onDismiss = onDismiss, + ) + } + + SubView.ShoutoutInput -> { + UserInputSubView( + titleRes = R.string.mod_actions_shoutout, + hintRes = R.string.mod_actions_username_hint, + onConfirm = { target -> + onSendCommand("/shoutout $target") + onDismiss() + }, + onDismiss = onDismiss, + ) + } + + SubView.ShieldModeConfirm -> { + ShieldModeConfirmSubView( + onConfirm = { + onSendCommand("/shield") + onDismiss() + }, + onBack = { subView = null }, + ) + } + + SubView.ClearChatConfirm -> { + ClearChatConfirmSubView( + onConfirm = { + onSendCommand("/clear") + onDismiss() + }, + onBack = { subView = null }, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ModActionsMainView( + roomState: RoomState?, + isBroadcaster: Boolean, + isStreamActive: Boolean, + shieldModeActive: Boolean?, + onSendCommand: (String) -> Unit, + onShowSubView: (SubView) -> Unit, + onClearChat: () -> Unit, + onAnnounce: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + // Room state section + SectionHeader(stringResource(R.string.mod_actions_room_state_section)) + RoomStateModeChips( + roomState = roomState, + onSendCommand = onSendCommand, + onShowSubView = onShowSubView, + onDismiss = onDismiss, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Mod actions section + SectionHeader(stringResource(R.string.mod_actions_section)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isShieldActive = shieldModeActive == true + FilterChip( + selected = isShieldActive, + onClick = { + when { + isShieldActive -> { + onSendCommand("/shieldoff") + onDismiss() + } + + else -> { + onShowSubView(SubView.ShieldModeConfirm) + } + } + }, + label = { Text(stringResource(R.string.mod_actions_shield_mode)) }, + leadingIcon = + if (isShieldActive) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + AssistChip( + onClick = onClearChat, + label = { Text(stringResource(R.string.mod_actions_clear_chat)) }, + ) + AssistChip( + onClick = { + onAnnounce() + onDismiss() + }, + label = { Text(stringResource(R.string.mod_actions_announce)) }, + ) + if (isStreamActive) { + AssistChip( + onClick = { onShowSubView(SubView.ShoutoutInput) }, + label = { Text(stringResource(R.string.mod_actions_shoutout)) }, + ) + } + } + + // Broadcaster section + if (isBroadcaster && isStreamActive) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + SectionHeader(stringResource(R.string.mod_actions_broadcaster_section)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip( + onClick = { onShowSubView(SubView.CommercialPresets) }, + label = { Text(stringResource(R.string.mod_actions_commercial)) }, + ) + AssistChip( + onClick = { onShowSubView(SubView.RaidInput) }, + label = { Text(stringResource(R.string.mod_actions_raid)) }, + ) + AssistChip( + onClick = { + onSendCommand("/unraid") + onDismiss() + }, + label = { Text(stringResource(R.string.mod_actions_cancel_raid)) }, + ) + AssistChip( + onClick = { + onSendCommand("/marker") + onDismiss() + }, + label = { Text(stringResource(R.string.mod_actions_stream_marker)) }, + ) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RoomStateModeChips( + roomState: RoomState?, + onSendCommand: (String) -> Unit, + onShowSubView: (SubView) -> Unit, + onDismiss: () -> Unit, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isEmoteOnly = roomState?.isEmoteMode == true + FilterChip( + selected = isEmoteOnly, + onClick = { + onSendCommand(if (isEmoteOnly) "/emoteonlyoff" else "/emoteonly") + onDismiss() + }, + label = { Text(stringResource(R.string.room_state_emote_only)) }, + leadingIcon = + if (isEmoteOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + + val isSubOnly = roomState?.isSubscriberMode == true + FilterChip( + selected = isSubOnly, + onClick = { + onSendCommand(if (isSubOnly) "/subscribersoff" else "/subscribers") + onDismiss() + }, + label = { Text(stringResource(R.string.room_state_subscriber_only)) }, + leadingIcon = + if (isSubOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + + val isSlowMode = roomState?.isSlowMode == true + val slowModeWaitTime = roomState?.slowModeWaitTime + FilterChip( + selected = isSlowMode, + onClick = { + when { + isSlowMode -> { + onSendCommand("/slowoff") + onDismiss() + } + + else -> { + onShowSubView(SubView.SlowMode) + } + } + }, + label = { + val label = stringResource(R.string.room_state_slow_mode) + Text(if (isSlowMode && slowModeWaitTime != null) "$label (${DateTimeUtils.formatSeconds(slowModeWaitTime)})" else label) + }, + leadingIcon = + if (isSlowMode) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + + val isUniqueChat = roomState?.isUniqueChatMode == true + FilterChip( + selected = isUniqueChat, + onClick = { + onSendCommand(if (isUniqueChat) "/uniquechatoff" else "/uniquechat") + onDismiss() + }, + label = { Text(stringResource(R.string.room_state_unique_chat)) }, + leadingIcon = + if (isUniqueChat) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + + val isFollowerOnly = roomState?.isFollowMode == true + val followerDuration = roomState?.followerModeDuration + FilterChip( + selected = isFollowerOnly, + onClick = { + when { + isFollowerOnly -> { + onSendCommand("/followersoff") + onDismiss() + } + + else -> { + onShowSubView(SubView.FollowerMode) + } + } + }, + label = { + val label = stringResource(R.string.room_state_follower_only) + Text(if (isFollowerOnly && followerDuration != null && followerDuration > 0) "$label (${DateTimeUtils.formatSeconds(followerDuration * 60)})" else label) + }, + leadingIcon = + if (isFollowerOnly) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PresetChips( + titleRes: Int, + presets: List, + formatLabel: @Composable (Int) -> String, + onPresetClick: (Int) -> Unit, + onCustomClick: (() -> Unit)?, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + presets.forEach { value -> + AssistChip( + onClick = { onPresetClick(value) }, + label = { Text(formatLabel(value)) }, + ) + } + + if (onCustomClick != null) { + AssistChip( + onClick = onCustomClick, + label = { Text(stringResource(R.string.room_state_preset_custom)) }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FollowerPresetChips( + onPresetClick: (FollowerPreset) -> Unit, + onCustomClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + Text( + text = stringResource(R.string.room_state_follower_only), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FOLLOWER_MODE_PRESETS.forEach { preset -> + AssistChip( + onClick = { onPresetClick(preset) }, + label = { Text(formatFollowerPreset(preset.minutes)) }, + ) + } + + AssistChip( + onClick = onCustomClick, + label = { Text(stringResource(R.string.room_state_preset_custom)) }, + ) + } + } +} + +@Composable +private fun UserInputSubView( + titleRes: Int, + hintRes: Int, + onConfirm: (String) -> Unit, + defaultValue: String = "", + keyboardType: KeyboardType = KeyboardType.Text, + onDismiss: () -> Unit = {}, +) { + var inputValue by remember { mutableStateOf(TextFieldValue(defaultValue, selection = TextRange(defaultValue.length))) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + val density = LocalDensity.current + val current = WindowInsets.ime.getBottom(density) + val source = WindowInsets.imeAnimationSource.getBottom(density) + val target = WindowInsets.imeAnimationTarget.getBottom(density) + val isClosing = source > 0 && target == 0 + val nearlyDone = current < 200 + + LaunchedEffect(isClosing, nearlyDone) { + if (isClosing && nearlyDone) { + onDismiss() + } + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + ) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + OutlinedTextField( + value = inputValue, + onValueChange = { inputValue = it }, + label = { Text(stringResource(hintRes)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions(onDone = { + val text = inputValue.text.trim() + if (text.isNotBlank()) { + onConfirm(text) + } + }), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + TextButton( + onClick = { onConfirm(inputValue.text.trim()) }, + enabled = inputValue.text.isNotBlank(), + modifier = + Modifier + .align(Alignment.End) + .padding(top = 8.dp), + ) { + Text(stringResource(R.string.dialog_ok)) + } + } +} + +@Composable +private fun ClearChatConfirmSubView( + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.mod_actions_confirm_clear_chat), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.clear_chat)) + } + } + } +} + +@Composable +private fun ShieldModeConfirmSubView( + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Text( + text = stringResource(R.string.mod_actions_confirm_shield_mode_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + Text( + text = stringResource(R.string.mod_actions_confirm_shield_mode_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton(onClick = onBack, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.dialog_cancel)) + } + Spacer(modifier = Modifier.width(12.dp)) + Button(onClick = onConfirm, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.mod_actions_shield_mode_activate)) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsViewModel.kt new file mode 100644 index 000000000..dcfc31400 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/dialog/ModActionsViewModel.kt @@ -0,0 +1,35 @@ +package com.flxrs.dankchat.ui.main.dialog + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.repo.ShieldModeRepository +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.twitch.message.RoomState +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.InjectedParam +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class ModActionsViewModel( + @InjectedParam private val channel: UserName, + private val shieldModeRepository: ShieldModeRepository, + channelRepository: ChannelRepository, + authDataStore: AuthDataStore, +) : ViewModel() { + val shieldModeActive: StateFlow = shieldModeRepository.getState(channel) + val roomState: StateFlow = channelRepository + .getRoomStateFlow(channel) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), channelRepository.getRoomState(channel)) + val isBroadcaster: Boolean = authDataStore.userIdString == channelRepository.getRoomState(channel)?.channelId + + init { + viewModelScope.launch { + shieldModeRepository.fetch(channel) + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt new file mode 100644 index 000000000..1e9be4e5d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatBottomBar.kt @@ -0,0 +1,140 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.utils.compose.rememberRoundedCornerHorizontalPadding +import com.flxrs.dankchat.utils.resolve +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ChatBottomBar( + showInput: Boolean, + textFieldState: TextFieldState, + uiState: ChatInputUiState, + callbacks: ChatInputCallbacks, + isUploading: Boolean, + isLoading: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + isStreamActive: Boolean, + isAudioOnly: Boolean, + hasStreamData: Boolean, + isSheetOpen: Boolean, + inputActions: ImmutableList, + onInputHeightChange: (Int) -> Unit, + modifier: Modifier = Modifier, + debugMode: Boolean = false, + overflowExpanded: Boolean = false, + onOverflowExpandedChange: (Boolean) -> Unit = {}, + onHelperTextHeightChange: (Int) -> Unit = {}, + isInSplitLayout: Boolean = false, + instantHide: Boolean = false, + tourState: TourOverlayState = TourOverlayState(), + isRepeatedSendEnabled: Boolean = false, +) { + Column(modifier = modifier.fillMaxWidth()) { + AnimatedVisibility( + visible = showInput, + enter = EnterTransition.None, + exit = + when { + instantHide -> ExitTransition.None + else -> slideOutVertically(targetOffsetY = { it }) + }, + ) { + ChatInputLayout( + textFieldState = textFieldState, + uiState = uiState, + callbacks = callbacks, + isSheetOpen = isSheetOpen, + isUploading = isUploading, + isLoading = isLoading, + isFullscreen = isFullscreen, + isModerator = isModerator, + isStreamActive = isStreamActive, + isAudioOnly = isAudioOnly, + hasStreamData = hasStreamData, + inputActions = inputActions, + debugMode = debugMode, + overflowExpanded = overflowExpanded, + onOverflowExpandedChange = onOverflowExpandedChange, + tourState = tourState, + isRepeatedSendEnabled = isRepeatedSendEnabled, + modifier = + Modifier.onSizeChanged { size -> + onInputHeightChange(size.height) + }, + ) + } + + // Sticky helper text + nav bar spacer when input is hidden + if (!showInput && !isSheetOpen) { + val helperTextState = uiState.helperText + if (!helperTextState.isEmpty) { + val horizontalPadding = + when { + isFullscreen && isInSplitLayout -> { + val rcPadding = rememberRoundedCornerHorizontalPadding(fallback = 16.dp) + val direction = LocalLayoutDirection.current + PaddingValues(start = 16.dp, end = rcPadding.calculateEndPadding(direction)) + } + + isFullscreen -> { + rememberRoundedCornerHorizontalPadding(fallback = 16.dp) + } + + else -> { + PaddingValues(horizontal = 16.dp) + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.85f), + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { onHelperTextHeightChange(it.height) }, + ) { + ExpandableHelperText( + helperText = helperTextState, + modifier = + Modifier + .navigationBarsPadding() + .padding(horizontalPadding) + .padding(vertical = 6.dp), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputCallbacks.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputCallbacks.kt new file mode 100644 index 000000000..3cf6995ed --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputCallbacks.kt @@ -0,0 +1,22 @@ +package com.flxrs.dankchat.ui.main.input + +import com.flxrs.dankchat.preferences.appearance.InputAction +import kotlinx.collections.immutable.ImmutableList + +data class ChatInputCallbacks( + val onSend: () -> Unit, + val onLastMessageClick: () -> Unit, + val onEmoteClick: () -> Unit, + val onOverlayDismiss: () -> Unit, + val onToggleFullscreen: () -> Unit, + val onToggleInput: () -> Unit, + val onToggleStream: () -> Unit, + val onAudioOnly: () -> Unit, + val onModActions: () -> Unit, + val onInputActionsChange: (ImmutableList) -> Unit, + val onSearchClick: () -> Unit = {}, + val onDebugInfoClick: () -> Unit = {}, + val onNewWhisper: (() -> Unit)? = null, + val onRepeatedSendChange: (Boolean) -> Unit = {}, + val onInputMultilineChanged: (Boolean) -> Unit = {}, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt new file mode 100644 index 000000000..884e70d09 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputLayout.kt @@ -0,0 +1,962 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AddComment +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material.icons.outlined.VideocamOff +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipScope +import androidx.compose.material3.TooltipState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.appearance.InputAction +import com.flxrs.dankchat.ui.main.InputState +import com.flxrs.dankchat.ui.main.QuickActionsMenu +import com.flxrs.dankchat.utils.compose.predictiveBackScale +import com.flxrs.dankchat.utils.resolve +import com.materialkolor.ktx.blend +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import sh.calvin.reorderable.ReorderableColumn + +@Composable +fun ChatInputLayout( + textFieldState: TextFieldState, + uiState: ChatInputUiState, + callbacks: ChatInputCallbacks, + isSheetOpen: Boolean, + isUploading: Boolean, + isLoading: Boolean, + isFullscreen: Boolean, + isModerator: Boolean, + isStreamActive: Boolean, + isAudioOnly: Boolean, + hasStreamData: Boolean, + inputActions: ImmutableList, + modifier: Modifier = Modifier, + debugMode: Boolean = false, + overflowExpanded: Boolean = false, + onOverflowExpandedChange: (Boolean) -> Unit = {}, + tourState: TourOverlayState = TourOverlayState(), + isRepeatedSendEnabled: Boolean = false, +) { + val inputState = uiState.inputState + val enabled = uiState.enabled + val hasLastMessage = uiState.hasLastMessage + val canSend = uiState.canSend + val isEmoteMenuOpen = uiState.isEmoteMenuOpen + val helperText = if (isSheetOpen) HelperText() else uiState.helperText + val overlay = uiState.overlay + val characterCounter = uiState.characterCounter + val showQuickActions = !isSheetOpen + val onSend = callbacks.onSend + val onLastMessageClick = callbacks.onLastMessageClick + val onEmoteClick = callbacks.onEmoteClick + val onOverlayDismiss = callbacks.onOverlayDismiss + val onToggleFullscreen = callbacks.onToggleFullscreen + val onToggleInput = callbacks.onToggleInput + val onToggleStream = callbacks.onToggleStream + val onModActions = callbacks.onModActions + val onInputActionsChange = callbacks.onInputActionsChange + val onSearchClick = callbacks.onSearchClick + val onDebugInfoClick = callbacks.onDebugInfoClick + val onNewWhisper = callbacks.onNewWhisper + val onRepeatedSendChange = callbacks.onRepeatedSendChange + + val focusRequester = remember { FocusRequester() } + val hint = + when (inputState) { + InputState.Default -> stringResource(R.string.hint_connected) + InputState.Replying -> stringResource(R.string.hint_replying) + InputState.Announcing -> stringResource(R.string.hint_announcing) + InputState.Whispering -> stringResource(R.string.hint_whispering) + InputState.NotLoggedIn -> stringResource(R.string.hint_not_logged_int) + InputState.Disconnected -> stringResource(R.string.hint_disconnected) + } + + val textFieldColors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ) + val defaultColors = TextFieldDefaults.colors() + val surfaceColor = + if (enabled) { + defaultColors.unfocusedContainerColor + } else { + defaultColors.disabledContainerColor + } + + // Filter to actions that would actually render based on current state + val effectiveActions = + remember(inputActions, isModerator, hasStreamData, isStreamActive, debugMode) { + inputActions + .filter { action -> + when (action) { + InputAction.Stream -> hasStreamData || isStreamActive + InputAction.ModActions -> isModerator + InputAction.Debug -> debugMode + else -> true + } + }.toImmutableList() + } + + val keyboardController = LocalSoftwareKeyboardController.current + var visibleActions by remember { mutableStateOf(effectiveActions) } + val quickActionsExpanded = overflowExpanded || tourState.forceOverflowOpen + var showConfigSheet by remember { mutableStateOf(false) } + val topEndRadius by animateDpAsState( + targetValue = if (quickActionsExpanded) 0.dp else 24.dp, + label = "topEndCornerRadius", + ) + + val inputContent: @Composable () -> Unit = { + Surface( + shape = RoundedCornerShape(topStart = 24.dp, topEnd = topEndRadius), + color = surfaceColor, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding(), + ) { + // Input mode overlay header + AnimatedVisibility( + visible = overlay != InputOverlay.None, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val headerText = + when (overlay) { + is InputOverlay.Reply -> stringResource(R.string.reply_header, overlay.name.value) + is InputOverlay.Whisper -> stringResource(R.string.whisper_header, overlay.target.value) + is InputOverlay.Announce -> stringResource(R.string.mod_actions_announce_header) + InputOverlay.None -> "" + } + val subtitleText = (overlay as? InputOverlay.Reply)?.message + InputOverlayHeader( + text = headerText, + subtitle = subtitleText, + onDismiss = onOverlayDismiss, + ) + } + + // Text Field + val density = LocalDensity.current + var singleLineHeight by remember { mutableIntStateOf(0) } + TextField( + state = textFieldState, + enabled = enabled && !tourState.isTourActive, + modifier = + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = with(density) { singleLineHeight.toDp() }) + .onSizeChanged { size -> + if (textFieldState.text.isEmpty()) { + singleLineHeight = maxOf(singleLineHeight, size.height) + } + callbacks.onInputMultilineChanged(singleLineHeight > 0 && size.height > singleLineHeight) + }.focusRequester(focusRequester), + label = { Text(hint) }, + suffix = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(IntrinsicSize.Min) + .offset(y = (-8).dp), + ) { + when (characterCounter) { + is CharacterCounterState.Hidden -> { + Unit + } + + is CharacterCounterState.Visible -> { + Text( + text = characterCounter.text, + color = + when { + characterCounter.isOverLimit -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + AnimatedVisibility( + visible = enabled && uiState.showClearInputButton && textFieldState.text.isNotEmpty(), + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.dialog_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .padding(start = 4.dp) + .size(20.dp) + .clickable { textFieldState.clearText() }, + ) + } + } + }, + colors = textFieldColors, + shape = RoundedCornerShape(0.dp), + lineLimits = + TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = 5, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + onKeyboardAction = { if (canSend) onSend() }, + ) + + HelperTextRow(helperText = helperText) + + // Progress indicator for uploads and data loading + AnimatedVisibility( + visible = isUploading || isLoading, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // Actions Row — uses BoxWithConstraints to hide actions that don't fit + InputActionsRow( + inputActions = inputActions, + effectiveActions = effectiveActions, + isEmoteMenuOpen = isEmoteMenuOpen, + enabled = enabled, + showQuickActions = showQuickActions, + showSendButton = uiState.showSendButton, + tourState = tourState, + quickActionsExpanded = quickActionsExpanded, + canSend = canSend, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + focusRequester = focusRequester, + onEmoteClick = onEmoteClick, + onOverflowExpandedChange = onOverflowExpandedChange, + onNewWhisper = onNewWhisper, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onModActions = onModActions, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, + onSend = onSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onRepeatedSendChange = onRepeatedSendChange, + onVisibleActionsChange = { visibleActions = it }, + ) + } + } + } + + Box(modifier = modifier.fillMaxWidth()) { + OptionalTourTooltip( + tooltipState = tourState.swipeGestureTooltipState, + text = stringResource(R.string.tour_swipe_gesture), + onAdvance = tourState.onAdvance, + onSkip = tourState.onSkip, + ) { + inputContent() + } + + // Overflow menu — overlays above input, end-aligned + AnimatedVisibility( + visible = quickActionsExpanded, + enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(), + modifier = + Modifier + .align(Alignment.TopEnd) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, 0) { + placeable.placeRelative(0, -placeable.height) + } + }, + ) { + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onOverflowExpandedChange(false) + } catch (_: CancellationException) { + backProgress = 0f + } + } + QuickActionsMenu( + modifier = Modifier.predictiveBackScale(backProgress), + surfaceColor = surfaceColor, + visibleActions = visibleActions, + enabled = enabled, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isAudioOnly = isAudioOnly, + hasStreamData = hasStreamData, + isFullscreen = isFullscreen, + isModerator = isModerator, + tourState = tourState, + onActionClick = { action -> + when (action) { + InputAction.Search -> onSearchClick() + InputAction.LastMessage -> onLastMessageClick() + InputAction.Stream -> onToggleStream() + InputAction.ModActions -> onModActions() + InputAction.Fullscreen -> onToggleFullscreen() + InputAction.HideInput -> onToggleInput() + InputAction.Debug -> onDebugInfoClick() + } + onOverflowExpandedChange(false) + }, + onAudioOnly = { + callbacks.onAudioOnly() + onOverflowExpandedChange(false) + }, + onConfigureClick = { + onOverflowExpandedChange(false) + keyboardController?.hide() + showConfigSheet = true + }, + ) + } + } + + if (showConfigSheet) { + InputActionConfigSheet( + inputActions = inputActions, + debugMode = debugMode, + onInputActionsChange = onInputActionsChange, + onDismiss = { showConfigSheet = false }, + ) + } +} + +@Composable +private fun SendButton( + enabled: Boolean, + isRepeatedSendEnabled: Boolean, + onSend: () -> Unit, + onRepeatedSendChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val contentColor = + when { + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + else -> MaterialTheme.colorScheme.primary + } + + val gestureModifier = + when { + enabled && isRepeatedSendEnabled -> { + Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { onSend() }, + onLongPress = { onRepeatedSendChange(true) }, + onPress = { + tryAwaitRelease() + onRepeatedSendChange(false) + }, + ) + } + } + + enabled -> { + Modifier.clickable( + interactionSource = null, + indication = null, + onClick = onSend, + ) + } + + else -> { + Modifier + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .then(gestureModifier) + .padding(4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.send_hint), + modifier = Modifier.size(28.dp), + tint = contentColor, + ) + } +} + +@Composable +private fun InputActionButton( + action: InputAction, + enabled: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onModActions: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + modifier: Modifier = Modifier, + onDebugInfoClick: () -> Unit = {}, +) { + val primary = MaterialTheme.colorScheme.primary + val contextualTint = when { + !isSystemInDarkTheme() -> primary + else -> primary.blend(to = MaterialTheme.colorScheme.onSurface, amount = 0.2f) + } + + val icon: ImageVector + val contentDescription: Int + val onClick: () -> Unit + val tint: Color? + when (action) { + InputAction.Search -> { + icon = Icons.Default.Search + contentDescription = R.string.message_history + onClick = onSearchClick + tint = null + } + + InputAction.LastMessage -> { + icon = Icons.Default.History + contentDescription = R.string.resume_scroll + onClick = onLastMessageClick + tint = null + } + + InputAction.Stream -> { + icon = if (isStreamActive) Icons.Outlined.VideocamOff else Icons.Outlined.Videocam + contentDescription = R.string.toggle_stream + onClick = onToggleStream + tint = contextualTint + } + + InputAction.ModActions -> { + icon = Icons.Outlined.Shield + contentDescription = R.string.menu_mod_actions + onClick = onModActions + tint = contextualTint + } + + InputAction.Fullscreen -> { + icon = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen + contentDescription = R.string.toggle_fullscreen + onClick = onToggleFullscreen + tint = null + } + + InputAction.HideInput -> { + icon = Icons.Default.VisibilityOff + contentDescription = R.string.menu_hide_input + onClick = onToggleInput + tint = null + } + + InputAction.Debug -> { + icon = Icons.Default.BugReport + contentDescription = R.string.input_action_debug + onClick = onDebugInfoClick + tint = null + } + } + + val actionEnabled = + when (action) { + InputAction.Search, InputAction.Fullscreen, InputAction.HideInput, InputAction.Debug -> true + InputAction.LastMessage -> enabled && hasLastMessage + InputAction.Stream, InputAction.ModActions -> enabled + } + + IconButton( + onClick = onClick, + enabled = actionEnabled, + modifier = modifier, + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(contentDescription), + tint = tint ?: LocalContentColor.current, + ) + } +} + +@Composable +private fun InputOverlayHeader( + text: String, + onDismiss: () -> Unit, + subtitle: String? = null, +) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + modifier = Modifier.size(16.dp), + ) + } + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 4.dp), + ) + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InputActionsRow( + inputActions: ImmutableList, + effectiveActions: ImmutableList, + isEmoteMenuOpen: Boolean, + enabled: Boolean, + showQuickActions: Boolean, + showSendButton: Boolean, + tourState: TourOverlayState, + quickActionsExpanded: Boolean, + canSend: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + focusRequester: FocusRequester, + onEmoteClick: () -> Unit, + onOverflowExpandedChange: (Boolean) -> Unit, + onNewWhisper: (() -> Unit)?, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onModActions: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onSend: () -> Unit, + onVisibleActionsChange: (ImmutableList) -> Unit, + onDebugInfoClick: () -> Unit = {}, + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChange: (Boolean) -> Unit = {}, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + val iconSize = 40.dp + // Fixed slots: emote button + conditionally overflow, whisper, send + val fixedSlots = 1 + listOf(showQuickActions, onNewWhisper != null, showSendButton).count { it } + val availableForActions = maxWidth - iconSize * fixedSlots + val maxVisibleActions = (availableForActions / iconSize).toInt().coerceAtLeast(0) + val allActions = inputActions.take(maxVisibleActions).toImmutableList() + val visibleActions = effectiveActions.take(maxVisibleActions).toImmutableList() + onVisibleActionsChange(visibleActions) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + // Emote/Keyboard Button (start-aligned, always visible) + IconButton( + onClick = { + if (isEmoteMenuOpen) { + focusRequester.requestFocus() + } + onEmoteClick() + }, + enabled = enabled && !tourState.isTourActive, + modifier = Modifier.size(iconSize), + ) { + Icon( + imageVector = if (isEmoteMenuOpen) Icons.Outlined.Keyboard else Icons.Outlined.EmojiEmotions, + contentDescription = + stringResource( + if (isEmoteMenuOpen) R.string.dialog_dismiss else R.string.emote_menu_hint, + ), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // End-aligned group: overflow + actions + whisper + send + Row(verticalAlignment = Alignment.CenterVertically) { + EndAlignedActionGroup( + allActions = allActions, + visibleActions = visibleActions, + iconSize = iconSize, + showQuickActions = showQuickActions, + showSendButton = showSendButton, + tourState = tourState, + quickActionsExpanded = quickActionsExpanded, + canSend = canSend, + enabled = enabled, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onOverflowExpandedChange = onOverflowExpandedChange, + onNewWhisper = onNewWhisper, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onModActions = onModActions, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, + onSend = onSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onRepeatedSendChange = onRepeatedSendChange, + ) + } + } + } +} + +@Suppress("MultipleEmitters") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EndAlignedActionGroup( + allActions: ImmutableList, + visibleActions: ImmutableList, + iconSize: Dp, + showQuickActions: Boolean, + showSendButton: Boolean, + tourState: TourOverlayState, + quickActionsExpanded: Boolean, + canSend: Boolean, + enabled: Boolean, + hasLastMessage: Boolean, + isStreamActive: Boolean, + isFullscreen: Boolean, + onOverflowExpandedChange: (Boolean) -> Unit, + onNewWhisper: (() -> Unit)?, + onSearchClick: () -> Unit, + onLastMessageClick: () -> Unit, + onToggleStream: () -> Unit, + onModActions: () -> Unit, + onToggleFullscreen: () -> Unit, + onToggleInput: () -> Unit, + onSend: () -> Unit, + onDebugInfoClick: () -> Unit = {}, + isRepeatedSendEnabled: Boolean = false, + onRepeatedSendChange: (Boolean) -> Unit = {}, +) { + // Overflow Button (leading the end-aligned group) + if (showQuickActions) { + val overflowButton: @Composable () -> Unit = { + IconButton( + onClick = { + if (tourState.overflowMenuTooltipState != null) { + tourState.onAdvance?.invoke() + } else { + onOverflowExpandedChange(!quickActionsExpanded) + } + }, + modifier = Modifier.size(iconSize), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + OptionalTourTooltip( + tooltipState = tourState.overflowMenuTooltipState, + text = stringResource(R.string.tour_overflow_menu), + onAdvance = tourState.onAdvance, + onSkip = tourState.onSkip, + ) { + overflowButton() + } + } + + // New Whisper Button (only on whisper tab) + if (onNewWhisper != null) { + IconButton( + onClick = onNewWhisper, + modifier = Modifier.size(iconSize), + ) { + Icon( + imageVector = Icons.Default.AddComment, + contentDescription = stringResource(R.string.whisper_new), + ) + } + } + + // Configurable action icons with animated visibility + OptionalTourTooltip( + tooltipState = tourState.inputActionsTooltipState, + text = stringResource(R.string.tour_input_actions), + onAdvance = tourState.onAdvance, + onSkip = tourState.onSkip, + focusable = true, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + for (action in allActions) { + AnimatedVisibility( + visible = action in visibleActions, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + InputActionButton( + action = action, + enabled = enabled, + hasLastMessage = hasLastMessage, + isStreamActive = isStreamActive, + isFullscreen = isFullscreen, + onSearchClick = onSearchClick, + onLastMessageClick = onLastMessageClick, + onToggleStream = onToggleStream, + onModActions = onModActions, + onToggleFullscreen = onToggleFullscreen, + onToggleInput = onToggleInput, + onDebugInfoClick = onDebugInfoClick, + modifier = Modifier.size(iconSize), + ) + } + } + } + } + + // Send Button (Right) + if (showSendButton) { + Spacer(modifier = Modifier.width(4.dp)) + SendButton( + enabled = canSend, + isRepeatedSendEnabled = isRepeatedSendEnabled, + onSend = onSend, + onRepeatedSendChange = onRepeatedSendChange, + modifier = Modifier.size(44.dp), + ) + } +} + +@Composable +private fun HelperTextRow(helperText: HelperText) { + AnimatedVisibility( + visible = !helperText.isEmpty, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + ExpandableHelperText( + helperText = helperText, + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp), + ) + } +} + +@Composable +internal fun ExpandableHelperText( + helperText: HelperText, + modifier: Modifier = Modifier, +) { + val resolvedRoomState = helperText.roomStateParts.map { it.resolve() } + val roomStateText = resolvedRoomState.joinToString(separator = ", ") + val streamInfoText = helperText.streamInfo + val combinedText = listOfNotNull(roomStateText.ifEmpty { null }, streamInfoText).joinToString(separator = " - ") + val textMeasurer = rememberTextMeasurer() + val style = MaterialTheme.typography.labelSmall + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + + BoxWithConstraints( + modifier = + modifier + .fillMaxWidth(), + ) { + val maxWidthPx = with(density) { maxWidth.roundToPx() } + val fitsOnOneLine = + remember(combinedText, style, maxWidthPx) { + textMeasurer.measure(combinedText, style).size.width <= maxWidthPx + } + val canExpand = !fitsOnOneLine && streamInfoText != null && roomStateText.isNotEmpty() + val showTwoLines = expanded && canExpand + val contentModifier = + when { + canExpand -> Modifier.clickable { expanded = !expanded } + else -> Modifier + } + Box(modifier = contentModifier.fillMaxWidth().animateContentSize()) { + when { + showTwoLines -> { + Column { + Text( + text = roomStateText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), + ) + Text( + text = streamInfoText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), + ) + } + } + + else -> { + Text( + text = combinedText, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.fillMaxWidth().basicMarquee(iterations = Int.MAX_VALUE), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt new file mode 100644 index 000000000..bc7d564d6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputUiState.kt @@ -0,0 +1,69 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion +import com.flxrs.dankchat.ui.main.InputState +import com.flxrs.dankchat.utils.TextResource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class ChatInputUiState( + val text: String = "", + val canSend: Boolean = false, + val enabled: Boolean = false, + val hasLastMessage: Boolean = false, + val suggestions: ImmutableList = persistentListOf(), + val activeChannel: UserName? = null, + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val isLoggedIn: Boolean = false, + val inputState: InputState = InputState.Disconnected, + val overlay: InputOverlay = InputOverlay.None, + val replyMessageId: String? = null, + val isEmoteMenuOpen: Boolean = false, + val helperText: HelperText = HelperText(), + val isWhisperTabActive: Boolean = false, + val characterCounter: CharacterCounterState = CharacterCounterState.Hidden, + val showClearInputButton: Boolean = true, + val showSendButton: Boolean = true, + val userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, +) + +@Stable +sealed interface InputOverlay { + data object None : InputOverlay + + data class Reply( + val name: UserName, + val message: String, + ) : InputOverlay + + data class Whisper( + val target: UserName, + ) : InputOverlay + + data object Announce : InputOverlay +} + +@Stable +sealed interface CharacterCounterState { + data object Hidden : CharacterCounterState + + @Immutable + data class Visible( + val text: String, + val isOverLimit: Boolean, + ) : CharacterCounterState +} + +@Immutable +data class HelperText( + val roomStateParts: ImmutableList = persistentListOf(), + val streamInfo: String? = null, +) { + val isEmpty: Boolean get() = roomStateParts.isEmpty() && streamInfo == null +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt new file mode 100644 index 000000000..59b7a09cc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/ChatInputViewModel.kt @@ -0,0 +1,633 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.placeCursorAtEnd +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.chat.ChatConnector +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.command.CommandRepository +import com.flxrs.dankchat.data.repo.command.CommandResult +import com.flxrs.dankchat.data.repo.emote.EmoteRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.data.twitch.chat.ConnectionState +import com.flxrs.dankchat.data.twitch.command.TwitchCommand +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import com.flxrs.dankchat.preferences.chat.SuggestionMode +import com.flxrs.dankchat.preferences.chat.SuggestionType +import com.flxrs.dankchat.preferences.notifications.NotificationsSettingsDataStore +import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion +import com.flxrs.dankchat.ui.chat.suggestion.SuggestionProvider +import com.flxrs.dankchat.ui.main.InputState +import com.flxrs.dankchat.ui.main.MainEvent +import com.flxrs.dankchat.ui.main.MainEventBus +import com.flxrs.dankchat.ui.main.RepeatedSendData +import com.flxrs.dankchat.ui.main.sheet.FullScreenSheetState +import com.flxrs.dankchat.utils.TextResource +import com.flxrs.dankchat.utils.extensions.combine +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@OptIn(FlowPreview::class) +@KoinViewModel +class ChatInputViewModel( + private val chatRepository: ChatRepository, + private val chatChannelProvider: ChatChannelProvider, + private val chatConnector: ChatConnector, + private val commandRepository: CommandRepository, + private val channelRepository: ChannelRepository, + private val userStateRepository: UserStateRepository, + private val suggestionProvider: SuggestionProvider, + private val preferenceStore: DankChatPreferenceStore, + private val chatSettingsDataStore: ChatSettingsDataStore, + private val appearanceSettingsDataStore: AppearanceSettingsDataStore, + private val notificationsSettingsDataStore: NotificationsSettingsDataStore, + private val emoteRepository: EmoteRepository, + private val emoteUsageRepository: EmoteUsageRepository, + private val mainEventBus: MainEventBus, + streamsSettingsDataStore: StreamsSettingsDataStore, + streamDataRepository: StreamDataRepository, +) : ViewModel() { + val textFieldState = TextFieldState() + + private val _isReplying = MutableStateFlow(false) + private val _replyMessageId = MutableStateFlow(null) + private val _replyName = MutableStateFlow(null) + private val _replyMessage = MutableStateFlow(null) + private val repeatedSend = MutableStateFlow(RepeatedSendData(enabled = false, message = "")) + private val fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) + private val mentionSheetTab = MutableStateFlow(0) + private val _isEmoteMenuOpen = MutableStateFlow(false) + + private val _whisperTarget = MutableStateFlow(null) + private var lastWhisperText: String? = null + val whisperTarget: StateFlow = _whisperTarget.asStateFlow() + + private val _isAnnouncing = MutableStateFlow(false) + + private val codePointCount = + snapshotFlow { + val text = textFieldState.text + text.toString().codePointCount(0, text.length) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + private val textFlow = snapshotFlow { textFieldState.text.toString() } + private val textAndCursorFlow = + snapshotFlow { + textFieldState.text.toString() to textFieldState.selection.start + } + + // Debounce text/cursor changes for suggestion lookups + private val debouncedTextAndCursor = textAndCursorFlow.debounce(SUGGESTION_DEBOUNCE_MS) + + // Get suggestions based on current text, cursor position, and active channel + private val suggestions: StateFlow> = + combine( + debouncedTextAndCursor, + chatChannelProvider.activeChannel, + chatSettingsDataStore.suggestionTypes, + chatSettingsDataStore.suggestionMode, + ) { (text, cursorPos), channel, enabledTypes, suggestionMode -> + SuggestionInput(text, cursorPos, channel, enabledTypes, suggestionMode == SuggestionMode.PrefixOnly) + }.flatMapLatest { input -> + suggestionProvider.getSuggestions(input.text, input.cursorPos, input.channel, input.enabledTypes, input.prefixOnly) + }.map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + private val roomStateResources: StateFlow> = + combine( + chatSettingsDataStore.showChatModes, + chatChannelProvider.activeChannel, + ) { showModes, channel -> + showModes to channel + }.flatMapLatest { (showModes, channel) -> + if (!showModes || channel == null) { + flowOf(emptyList()) + } else { + channelRepository.getRoomStateFlow(channel).map { it.toDisplayTextResources() } + } + }.distinctUntilChanged() + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + private val currentStreamInfo: StateFlow = + combine( + streamsSettingsDataStore.showStreamsInfo, + chatChannelProvider.activeChannel, + streamDataRepository.streamData, + ) { streamInfoEnabled, activeChannel, streamData -> + streamData.find { it.channel == activeChannel }?.formattedData?.takeIf { streamInfoEnabled } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val helperText: StateFlow = + combine( + roomStateResources, + currentStreamInfo, + ) { roomState, streamInfo -> + HelperText( + roomStateParts = roomState.toImmutableList(), + streamInfo = streamInfo, + ) + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HelperText()) + + private var _uiState: StateFlow? = null + + init { + viewModelScope.launch { + chatChannelProvider.activeChannel.collect { + repeatedSend.update { data -> data.copy(enabled = false) } + setReplying(false) + _isAnnouncing.value = false + } + } + + // Clear whisper target when sheet closes or tab switches away from whispers + viewModelScope.launch { + combine(fullScreenSheetState, mentionSheetTab) { sheetState, tab -> + sheetState to tab + }.collect { (sheetState, tab) -> + val isWhisperTab = (sheetState is FullScreenSheetState.Mention || sheetState is FullScreenSheetState.Whisper) && tab == 1 + if (!isWhisperTab && _whisperTarget.value != null) { + _whisperTarget.value = null + textFieldState.clearText() + } + } + } + + viewModelScope.launch { + repeatedSend.collectLatest { + if (it.enabled && it.message.isNotBlank()) { + while (isActive) { + val activeChannel = chatChannelProvider.activeChannel.value ?: break + val delay = userStateRepository.getSendDelay(activeChannel) + trySendMessageOrCommand(it.message, skipSuspendingCommands = true) + delay(delay) + } + } + } + } + } + + fun uiState( + externalSheetState: StateFlow, + externalMentionTab: StateFlow, + ): StateFlow { + _uiState?.let { return it } + + // Wire up external sheet state for whisper clearing + viewModelScope.launch { + combine(externalSheetState, externalMentionTab) { sheetState, tab -> + sheetState to tab + }.collect { (sheetState, tab) -> + fullScreenSheetState.value = sheetState + mentionSheetTab.value = tab + } + } + + val baseFlow = + combine( + textFlow, + suggestions, + chatChannelProvider.activeChannel, + chatChannelProvider.activeChannel.flatMapLatest { channel -> + if (channel == null) { + flowOf(ConnectionState.DISCONNECTED) + } else { + chatConnector.getConnectionState(channel) + } + }, + appearanceSettingsDataStore.settings.map { InputSettings(it.autoDisableInput, it.showCharacterCounter, it.showClearInputButton, it.showSendButton) }, + preferenceStore.isLoggedInFlow, + ) { text, suggestions, activeChannel, connectionState, inputSettings, isLoggedIn -> + UiDependencies(text, suggestions, activeChannel, connectionState, isLoggedIn, inputSettings) + } + + val replyStateFlow = + combine( + _isReplying, + _replyName, + _replyMessageId, + _replyMessage, + ) { isReplying, replyName, replyMessageId, replyMessage -> + ReplyState(isReplying, replyName, replyMessageId, replyMessage) + } + + val inputOverlayFlow = + combine( + externalSheetState, + externalMentionTab, + replyStateFlow, + _isEmoteMenuOpen, + _whisperTarget, + _isAnnouncing, + ) { sheetState, tab, replyState, isEmoteMenuOpen, whisperTarget, isAnnouncing -> + InputOverlayState(sheetState, tab, replyState.isReplying, replyState.replyName, replyState.replyMessageId, replyState.replyMessage, isEmoteMenuOpen, whisperTarget, isAnnouncing) + } + + return combine( + baseFlow, + inputOverlayFlow, + helperText, + codePointCount, + chatSettingsDataStore.userLongClickBehavior, + ) { deps, overlayState, helperText, codePoints, userLongClickBehavior -> + val isMentionsTabActive = (overlayState.sheetState is FullScreenSheetState.Mention || overlayState.sheetState is FullScreenSheetState.Whisper) && overlayState.tab == 0 + val isWhisperTabActive = (overlayState.sheetState is FullScreenSheetState.Mention || overlayState.sheetState is FullScreenSheetState.Whisper) && overlayState.tab == 1 + val isInReplyThread = overlayState.sheetState is FullScreenSheetState.Replies + val effectiveIsReplying = overlayState.isReplying || isInReplyThread + val canTypeInConnectionState = deps.connectionState == ConnectionState.CONNECTED || !deps.inputSettings.autoDisableInput + + val inputState = + when (deps.connectionState) { + ConnectionState.CONNECTED -> { + when { + isWhisperTabActive && overlayState.whisperTarget != null -> InputState.Whispering + effectiveIsReplying -> InputState.Replying + overlayState.isAnnouncing -> InputState.Announcing + else -> InputState.Default + } + } + + ConnectionState.CONNECTED_NOT_LOGGED_IN -> { + InputState.NotLoggedIn + } + + ConnectionState.DISCONNECTED -> { + InputState.Disconnected + } + } + + val enabled = + when { + isMentionsTabActive -> false + isWhisperTabActive -> deps.isLoggedIn && canTypeInConnectionState && overlayState.whisperTarget != null + else -> deps.isLoggedIn && canTypeInConnectionState + } + + val canSend = deps.text.isNotBlank() && deps.activeChannel != null && deps.connectionState == ConnectionState.CONNECTED && deps.isLoggedIn && enabled + + val effectiveReplyName = overlayState.replyName ?: (overlayState.sheetState as? FullScreenSheetState.Replies)?.replyName + val effectiveReplyMessage = overlayState.replyMessage.orEmpty() + val overlay = + when { + overlayState.isReplying && !isInReplyThread && effectiveReplyName != null -> InputOverlay.Reply(effectiveReplyName, effectiveReplyMessage) + isWhisperTabActive && overlayState.whisperTarget != null -> InputOverlay.Whisper(overlayState.whisperTarget) + overlayState.isAnnouncing -> InputOverlay.Announce + else -> InputOverlay.None + } + + ChatInputUiState( + text = deps.text, + canSend = canSend, + enabled = enabled, + hasLastMessage = + when { + isWhisperTabActive -> lastWhisperText != null + else -> chatRepository.getLastMessage() != null + }, + suggestions = deps.suggestions.toImmutableList(), + activeChannel = deps.activeChannel, + connectionState = deps.connectionState, + isLoggedIn = deps.isLoggedIn, + inputState = inputState, + overlay = overlay, + replyMessageId = overlayState.replyMessageId ?: (overlayState.sheetState as? FullScreenSheetState.Replies)?.replyMessageId, + isEmoteMenuOpen = overlayState.isEmoteMenuOpen, + helperText = helperText, + isWhisperTabActive = isWhisperTabActive, + characterCounter = + when { + deps.inputSettings.showCharacterCounter -> CharacterCounterState.Visible( + text = "$codePoints/$MESSAGE_CODE_POINT_LIMIT", + isOverLimit = codePoints > MESSAGE_CODE_POINT_LIMIT, + ) + + else -> CharacterCounterState.Hidden + }, + showClearInputButton = deps.inputSettings.showClearInputButton, + showSendButton = deps.inputSettings.showSendButton, + userLongClickBehavior = userLongClickBehavior, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ChatInputUiState()).also { _uiState = it } + } + + fun sendMessage() { + val text = textFieldState.text.toString() + if (text.isNotBlank()) { + val whisperTarget = _whisperTarget.value + val isAnnouncing = _isAnnouncing.value + val messageToSend = + when { + whisperTarget != null -> "/w ${whisperTarget.value} $text" + isAnnouncing -> "/announce $text" + else -> text + } + lastWhisperText = if (whisperTarget != null) text else null + if (isAnnouncing) { + _isAnnouncing.value = false + } + trackEmoteUsagesInMessage(text) + trySendMessageOrCommand(messageToSend) + textFieldState.clearText() + } + } + + private fun trackEmoteUsagesInMessage(message: String) { + val channel = chatChannelProvider.activeChannel.value ?: return + val emoteIds = emoteRepository.findEmoteIdsInMessage(message, channel) + for (id in emoteIds) { + addEmoteUsage(id) + } + } + + fun trySendMessageOrCommand( + message: String, + skipSuspendingCommands: Boolean = false, + ) = viewModelScope.launch { + val channel = chatChannelProvider.activeChannel.value ?: return@launch + val chatState = fullScreenSheetState.value + val replyIdOrNull = + when { + chatState is FullScreenSheetState.Replies -> chatState.replyMessageId + _isReplying.value -> _replyMessageId.value + else -> null + } + + val commandResult = + runCatching { + when (chatState) { + FullScreenSheetState.Whisper -> { + commandRepository.checkForWhisperCommand(message, skipSuspendingCommands) + } + + else -> { + val roomState = channelRepository.getRoomState(channel) ?: return@launch + val userState = userStateRepository.userState.value + val shouldSkip = skipSuspendingCommands || chatState is FullScreenSheetState.Replies + commandRepository.checkForCommands(message, channel, roomState, userState, shouldSkip) + } + } + }.getOrElse { + mainEventBus.emitEvent(MainEvent.Error(it)) + return@launch + } + + when (commandResult) { + is CommandResult.Accepted, + is CommandResult.Blocked, + -> { + Unit + } + + is CommandResult.IrcCommand -> { + chatRepository.sendMessage(message, replyIdOrNull, forceIrc = true) + setReplying(false) + } + + is CommandResult.NotFound -> { + chatRepository.sendMessage(message, replyIdOrNull) + setReplying(false) + } + + is CommandResult.AcceptedTwitchCommand -> { + if (commandResult.command == TwitchCommand.Whisper) { + chatRepository.fakeWhisperIfNecessary(message) + } + val isWhisperContext = + chatState is FullScreenSheetState.Whisper || + (chatState is FullScreenSheetState.Mention && _whisperTarget.value != null) + if (commandResult.response != null && !isWhisperContext) { + chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) + } + } + + is CommandResult.AcceptedWithResponse -> { + chatRepository.makeAndPostCustomSystemMessage(commandResult.response, channel) + } + + is CommandResult.Message -> { + chatRepository.sendMessage(commandResult.message, replyIdOrNull) + setReplying(false) + } + } + + if (commandResult != CommandResult.NotFound && commandResult != CommandResult.IrcCommand) { + chatRepository.appendLastMessage(channel, message) + } + } + + fun getLastMessage() { + val message = + when { + _whisperTarget.value != null -> lastWhisperText + else -> chatRepository.getLastMessage() + } ?: return + textFieldState.edit { + replace(0, length, message) + placeCursorAtEnd() + } + } + + fun setRepeatedSend(enabled: Boolean) { + val message = textFieldState.text.toString() + repeatedSend.update { + RepeatedSendData(enabled, message) + } + } + + fun setReplying( + replying: Boolean, + replyMessageId: String? = null, + replyName: UserName? = null, + replyMessage: String? = null, + ) { + _isReplying.value = replying || replyMessageId != null + _replyMessageId.value = replyMessageId + _replyName.value = replyName + _replyMessage.value = replyMessage + } + + fun setAnnouncing(announcing: Boolean) { + _isAnnouncing.value = announcing + } + + fun setWhisperTarget(target: UserName?) { + _whisperTarget.value = target + if (target == null) { + textFieldState.clearText() + } + } + + fun mentionUser( + user: UserName, + display: DisplayName, + ) { + val template = notificationsSettingsDataStore.current().mentionFormat.template + val mention = "${template.replace("name", user.valueOrDisplayName(display))} " + insertText(mention) + } + + fun insertText(text: String) { + val selection = textFieldState.selection + textFieldState.edit { + replace(selection.min, selection.max, text) + placeCursorBeforeCharAt(selection.min + text.length) + } + } + + fun deleteLastWord() { + val text = textFieldState.text + if (text.isEmpty()) return + var end = text.length + // Skip trailing spaces + while (end > 0 && text[end - 1] == ' ') end-- + // Find start of word + var start = end + while (start > 0 && text[start - 1] != ' ') start-- + textFieldState.edit { + replace(start, length, "") + } + } + + fun postSystemMessage(message: String) { + val channel = chatChannelProvider.activeChannel.value ?: return + chatRepository.makeAndPostCustomSystemMessage(message, channel) + } + + /** + * Apply a suggestion to the current input text. + * Replaces the current word with the suggestion and places cursor at the end. + */ + fun applySuggestion(suggestion: Suggestion) { + val currentText = textFieldState.text.toString() + val cursorPos = textFieldState.selection.start + val result = computeSuggestionReplacement(currentText, cursorPos, suggestion.toString()) + + textFieldState.edit { + replace(result.replaceStart, result.replaceEnd, result.replacement) + selection = TextRange(result.newCursorPos) + } + + if (suggestion is Suggestion.EmoteSuggestion) { + addEmoteUsage(suggestion.emote.id) + } + } + + fun addEmoteUsage(emoteId: String) { + viewModelScope.launch { + emoteUsageRepository.addEmoteUsage(emoteId) + } + } + + fun setEmoteMenuOpen(open: Boolean) { + _isEmoteMenuOpen.value = open + } + + companion object { + private const val SUGGESTION_DEBOUNCE_MS = 20L + private const val MESSAGE_CODE_POINT_LIMIT = 500 + } +} + +internal data class SuggestionReplacementResult( + val replaceStart: Int, + val replaceEnd: Int, + val replacement: String, + val newCursorPos: Int, +) + +internal fun computeSuggestionReplacement( + text: String, + cursorPos: Int, + suggestionText: String, +): SuggestionReplacementResult { + val separator = ' ' + + // Only look backwards from cursor — match what extractCurrentWord does + var start = cursorPos + while (start > 0 && text[start - 1] != separator) start-- + + val replacement = suggestionText + separator + return SuggestionReplacementResult( + replaceStart = start, + replaceEnd = cursorPos, + replacement = replacement, + newCursorPos = start + replacement.length, + ) +} + +private data class SuggestionInput( + val text: String, + val cursorPos: Int, + val channel: UserName?, + val enabledTypes: List, + val prefixOnly: Boolean, +) + +private data class InputSettings( + val autoDisableInput: Boolean, + val showCharacterCounter: Boolean, + val showClearInputButton: Boolean, + val showSendButton: Boolean, +) + +private data class UiDependencies( + val text: String, + val suggestions: List, + val activeChannel: UserName?, + val connectionState: ConnectionState, + val isLoggedIn: Boolean, + val inputSettings: InputSettings, +) + +private data class ReplyState( + val isReplying: Boolean, + val replyName: UserName?, + val replyMessageId: String?, + val replyMessage: String?, +) + +private data class InputOverlayState( + val sheetState: FullScreenSheetState, + val tab: Int, + val isReplying: Boolean, + val replyName: UserName?, + val replyMessageId: String?, + val replyMessage: String?, + val isEmoteMenuOpen: Boolean, + val whisperTarget: UserName?, + val isAnnouncing: Boolean, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/InputActionConfig.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/InputActionConfig.kt new file mode 100644 index 000000000..d67949ca5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/InputActionConfig.kt @@ -0,0 +1,213 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.preferences.appearance.InputAction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import sh.calvin.reorderable.ReorderableColumn + +private const val MAX_INPUT_ACTIONS = 4 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputActionConfigSheet( + inputActions: ImmutableList, + debugMode: Boolean, + onInputActionsChange: (ImmutableList) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val localEnabled = remember { mutableStateListOf(*inputActions.toTypedArray()) } + + val disabledActions = InputAction.entries.filter { it !in localEnabled && (it != InputAction.Debug || debugMode) } + val atLimit = localEnabled.size >= MAX_INPUT_ACTIONS + + ModalBottomSheet( + onDismissRequest = { + onInputActionsChange(localEnabled.toImmutableList()) + onDismiss() + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Text( + text = if (atLimit) pluralStringResource(R.plurals.input_actions_max, MAX_INPUT_ACTIONS, MAX_INPUT_ACTIONS) else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + + // Enabled actions (reorderable, drag constrained to this section) + ReorderableColumn( + list = localEnabled.toList(), + onSettle = { from, to -> + localEnabled.apply { add(to, removeAt(from)) } + }, + modifier = Modifier.fillMaxWidth(), + ) { _, action, isDragging -> + val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) + + Surface( + shadowElevation = elevation, + color = if (isDragging) MaterialTheme.colorScheme.surfaceContainerHighest else Color.Transparent, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .longPressDraggableHandle() + .padding(horizontal = 16.dp, vertical = 8.dp) + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Icon( + imageVector = action.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(action.labelRes), + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = true, + onCheckedChange = { localEnabled.remove(action) }, + ) + } + } + } + + // Divider between enabled and disabled + if (disabledActions.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + } + + // Disabled actions (not reorderable) + for (action in disabledActions) { + val actionEnabled = !atLimit + + Row( + modifier = + Modifier + .fillMaxWidth() + .then( + if (actionEnabled) { + Modifier.clickable { localEnabled.add(action) } + } else { + Modifier + }, + ).padding(horizontal = 16.dp, vertical = 8.dp) + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Icon( + imageVector = action.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (actionEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(action.labelRes), + modifier = Modifier.weight(1f), + color = + if (actionEnabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + Checkbox( + checked = false, + onCheckedChange = { localEnabled.add(action) }, + enabled = actionEnabled, + ) + } + } + } + } +} + +internal val InputAction.labelRes: Int + get() = + when (this) { + InputAction.Search -> R.string.input_action_search + InputAction.LastMessage -> R.string.input_action_last_message + InputAction.Stream -> R.string.input_action_stream + InputAction.ModActions -> R.string.input_action_mod_actions + InputAction.Fullscreen -> R.string.input_action_fullscreen + InputAction.HideInput -> R.string.input_action_hide_input + InputAction.Debug -> R.string.input_action_debug + } + +internal val InputAction.icon: ImageVector + get() = + when (this) { + InputAction.Search -> Icons.Default.Search + InputAction.LastMessage -> Icons.Default.History + InputAction.Stream -> Icons.Outlined.Videocam + InputAction.ModActions -> Icons.Outlined.Shield + InputAction.Fullscreen -> Icons.Default.Fullscreen + InputAction.HideInput -> Icons.Default.VisibilityOff + InputAction.Debug -> Icons.Default.BugReport + } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt new file mode 100644 index 000000000..b304c497a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/SuggestionDropdown.kt @@ -0,0 +1,223 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.flxrs.dankchat.ui.chat.suggestion.Suggestion +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SuggestionDropdown( + suggestions: ImmutableList, + onSuggestionClick: (Suggestion) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = suggestions.isNotEmpty(), + modifier = modifier, + enter = + slideInVertically( + initialOffsetY = { fullHeight -> fullHeight / 4 }, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + + fadeIn( + animationSpec = spring(stiffness = Spring.StiffnessMedium), + ) + + scaleIn( + initialScale = 0.92f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ), + exit = + slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight / 4 }, + ) + fadeOut() + scaleOut(targetScale = 0.92f), + ) { + val listState = rememberLazyListState() + LaunchedEffect(suggestions) { + listState.scrollToItem(0) + } + + OutlinedCard( + modifier = + Modifier + .padding(horizontal = 2.dp) + .fillMaxWidth(0.66f) + .heightIn(max = 250.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + LazyColumn( + state = listState, + contentPadding = PaddingValues(vertical = 5.dp), + modifier = + Modifier + .fillMaxWidth() + .animateContentSize(), + ) { + items( + suggestions, + key = { suggestion -> + when (suggestion) { + is Suggestion.EmoteSuggestion -> "emote-${suggestion.emote.emoteType}-${suggestion.emote.id}-${suggestion.emote.code}" + is Suggestion.UserSuggestion -> "user-${suggestion.name.value}" + is Suggestion.EmojiSuggestion -> "emoji-${suggestion.emoji.code}" + is Suggestion.CommandSuggestion -> "cmd-${suggestion.command}" + is Suggestion.FilterSuggestion -> "filter-${suggestion.keyword}" + } + }, + ) { suggestion -> + SuggestionItem( + suggestion = suggestion, + onClick = { onSuggestionClick(suggestion) }, + ) + } + } + } + } +} + +@Composable +private fun SuggestionItem( + suggestion: Suggestion, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val iconSize = 36.dp + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (suggestion) { + is Suggestion.EmoteSuggestion -> { + AsyncImage( + model = suggestion.emote.url, + contentDescription = suggestion.emote.code, + modifier = Modifier.size(iconSize), + ) + } + + is Suggestion.UserSuggestion -> { + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + } + + is Suggestion.EmojiSuggestion -> { + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Text( + text = suggestion.emoji.unicode, + fontSize = 24.sp, + ) + } + } + + is Suggestion.CommandSuggestion -> { + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + } + + is Suggestion.FilterSuggestion -> { + Box(modifier = Modifier.size(iconSize), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + } + } + + Spacer(Modifier.width(12.dp)) + + when (suggestion) { + is Suggestion.EmoteSuggestion -> { + Text(text = suggestion.emote.code, style = MaterialTheme.typography.bodyLarge) + } + + is Suggestion.UserSuggestion -> { + Text(text = suggestion.name.value, style = MaterialTheme.typography.bodyLarge) + } + + is Suggestion.EmojiSuggestion -> { + Text(text = ":${suggestion.emoji.code}:", style = MaterialTheme.typography.bodyLarge) + } + + is Suggestion.CommandSuggestion -> { + Text(text = suggestion.command, style = MaterialTheme.typography.bodyLarge) + } + + is Suggestion.FilterSuggestion -> { + Column { + Text( + text = suggestion.displayText ?: suggestion.keyword, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(suggestion.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt new file mode 100644 index 000000000..97fff9892 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/input/TourOverlay.kt @@ -0,0 +1,100 @@ +package com.flxrs.dankchat.ui.main.input + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipScope +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Immutable +data class TourOverlayState( + val inputActionsTooltipState: TooltipState? = null, + val overflowMenuTooltipState: TooltipState? = null, + val configureActionsTooltipState: TooltipState? = null, + val swipeGestureTooltipState: TooltipState? = null, + val forceOverflowOpen: Boolean = false, + val isTourActive: Boolean = false, + val onAdvance: (() -> Unit)? = null, + val onSkip: (() -> Unit)? = null, +) + +@Suppress("ContentSlotReused") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun OptionalTourTooltip( + tooltipState: TooltipState?, + text: String, + onAdvance: (() -> Unit)?, + onSkip: (() -> Unit)?, + focusable: Boolean = false, + content: @Composable () -> Unit, +) { + if (tooltipState != null) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + TourTooltip( + text = text, + onAction = { onAdvance?.invoke() }, + onSkip = { onSkip?.invoke() }, + ) + }, + state = tooltipState, + onDismissRequest = {}, + focusable = focusable, + hasAction = true, + ) { + content() + } + } else { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TooltipScope.TourTooltip( + text: String, + onAction: () -> Unit, + onSkip: () -> Unit, + isLast: Boolean = false, + showCaret: Boolean = true, +) { + val tourColors = + TooltipDefaults.richTooltipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + actionContentColor = MaterialTheme.colorScheme.secondary, + ) + RichTooltip( + colors = tourColors, + caretShape = if (showCaret) TooltipDefaults.caretShape(caretSize = DpSize(24.dp, 12.dp)) else null, + action = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onSkip) { + Text(stringResource(R.string.tour_skip)) + } + TextButton(onClick = onAction) { + Text(stringResource(if (isLast) R.string.tour_got_it else R.string.tour_next)) + } + } + }, + ) { + Text(text) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoSheet.kt new file mode 100644 index 000000000..ad5a45826 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoSheet.kt @@ -0,0 +1,147 @@ +package com.flxrs.dankchat.ui.main.sheet + +import android.content.ClipData +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.debug.DebugEntry +import com.flxrs.dankchat.utils.compose.BottomSheetNestedScrollConnection +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugInfoSheet( + viewModel: DebugInfoViewModel, + sheetState: SheetState, + onDismiss: () -> Unit, + onOpenLogViewer: () -> Unit, +) { + val sections by viewModel.sections.collectAsStateWithLifecycle() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + contentWindowInsets = { WindowInsets.statusBars }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .nestedScroll(BottomSheetNestedScrollConnection) + .padding(horizontal = 16.dp), + contentPadding = navBarPadding, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + sections.forEachIndexed { index, section -> + item(key = "header_${section.title}") { + Column { + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + Text( + text = section.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ) + } + } + + items(section.entries, key = { "${section.title}_${it.label}" }) { entry -> + DebugEntryRow(entry) + } + + if (section.title == "Session") { + item(key = "view_logs") { + Text( + text = stringResource(R.string.log_viewer_open), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onOpenLogViewer() + onDismiss() + }.padding(vertical = 4.dp), + ) + } + } + } + } + } +} + +@Composable +private fun DebugEntryRow(entry: DebugEntry) { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + val copyModifier = + when { + entry.copyValue != null -> { + Modifier.clickable { + scope.launch { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText(entry.label, entry.copyValue))) + } + } + } + + else -> { + Modifier + } + } + Row( + modifier = + copyModifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = entry.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + Text( + text = entry.value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoViewModel.kt new file mode 100644 index 000000000..309ed8bb3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/DebugInfoViewModel.kt @@ -0,0 +1,25 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.debug.DebugSectionRegistry +import com.flxrs.dankchat.data.debug.DebugSectionSnapshot +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class DebugInfoViewModel( + debugSectionRegistry: DebugSectionRegistry, +) : ViewModel() { + val sections: StateFlow> = + debugSectionRegistry + .allSections() + .map { it.toImmutableList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt new file mode 100644 index 000000000..b44c96cc5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/EmoteMenuViewModel.kt @@ -0,0 +1,94 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.repo.emote.EmoteUsageRepository +import com.flxrs.dankchat.data.repo.emote.Emotes +import com.flxrs.dankchat.data.twitch.emote.EmoteType +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTab +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteMenuTabItem +import com.flxrs.dankchat.utils.extensions.flatMapLatestOrDefault +import com.flxrs.dankchat.utils.extensions.toEmoteItems +import com.flxrs.dankchat.utils.extensions.toEmoteItemsWithFront +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class EmoteMenuViewModel( + private val dataRepository: DataRepository, + chatChannelProvider: ChatChannelProvider, + emoteUsageRepository: EmoteUsageRepository, + dispatchersProvider: DispatchersProvider, +) : ViewModel() { + private val _selectedTabIndex = MutableStateFlow(0) + val selectedTabIndex: StateFlow = _selectedTabIndex.asStateFlow() + + fun selectTab(index: Int) { + _selectedTabIndex.value = index + } + + private val activeChannel = chatChannelProvider.activeChannel + + private val emotes = + activeChannel + .flatMapLatestOrDefault(Emotes()) { dataRepository.getEmotes(it) } + + private val recentEmotes = + emoteUsageRepository.getRecentUsages().distinctUntilChanged { old, new -> + new.all { newEmote -> old.any { it.emoteId == newEmote.emoteId } } + } + + val emoteTabItems: StateFlow> = + combine(emotes, recentEmotes, activeChannel) { emotes, recentEmotes, channel -> + withContext(dispatchersProvider.default) { + val sortedEmotes = emotes.sorted + val availableRecents = + recentEmotes.mapNotNull { usage -> + sortedEmotes + .firstOrNull { it.id == usage.emoteId } + ?.copy(emoteType = EmoteType.RecentUsageEmote) + } + + val groupedByType = + sortedEmotes.groupBy { + when (it.emoteType) { + is EmoteType.ChannelTwitchEmote, + is EmoteType.ChannelTwitchBitEmote, + is EmoteType.ChannelTwitchFollowerEmote, + -> EmoteMenuTab.SUBS + + is EmoteType.ChannelFFZEmote, + is EmoteType.ChannelBTTVEmote, + is EmoteType.ChannelSevenTVEmote, + -> EmoteMenuTab.CHANNEL + + else -> EmoteMenuTab.GLOBAL + } + } + listOf( + async { EmoteMenuTabItem(EmoteMenuTab.RECENT, availableRecents.toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.SUBS, groupedByType[EmoteMenuTab.SUBS].toEmoteItemsWithFront(channel)) }, + async { EmoteMenuTabItem(EmoteMenuTab.CHANNEL, groupedByType[EmoteMenuTab.CHANNEL].orEmpty().toEmoteItems()) }, + async { EmoteMenuTabItem(EmoteMenuTab.GLOBAL, groupedByType[EmoteMenuTab.GLOBAL].orEmpty().toEmoteItems()) }, + ).awaitAll().toImmutableList() + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + EmoteMenuTab.entries.map { EmoteMenuTabItem(it, emptyList()) }.toImmutableList(), + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt new file mode 100644 index 000000000..84315e89d --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/FullScreenSheetOverlay.kt @@ -0,0 +1,190 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.chat.UserLongClickBehavior +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.history.HistoryChannel +import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel +import com.flxrs.dankchat.ui.chat.mention.MentionViewModel +import com.flxrs.dankchat.ui.chat.message.MessageOptionsParams +import com.flxrs.dankchat.ui.chat.user.UserPopupStateParams +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Suppress("ViewModelForwarding") +@Composable +fun FullScreenSheetOverlay( + sheetState: FullScreenSheetState, + mentionViewModel: MentionViewModel, + onDismiss: () -> Unit, + onDismissReplies: () -> Unit, + onUserClick: (UserPopupStateParams) -> Unit, + onMessageLongClick: (MessageOptionsParams) -> Unit, + onEmoteClick: (List) -> Unit, + modifier: Modifier = Modifier, + userLongClickBehavior: UserLongClickBehavior = UserLongClickBehavior.MentionsUser, + onWhisperReply: (UserName) -> Unit = {}, + onUserMention: (UserName, DisplayName) -> Unit = { _, _ -> }, + bottomContentPadding: Dp = 0.dp, +) { + val isVisible = sheetState !is FullScreenSheetState.Closed + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + modifier = modifier.fillMaxSize(), + ) { + Box(modifier = Modifier.fillMaxSize()) { + when (sheetState) { + is FullScreenSheetState.Closed -> { + Unit + } + + is FullScreenSheetState.Mention -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = false, + onDismiss = onDismiss, + onUserClick = popupOnlyClickHandler(onUserClick), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), + onEmoteClick = onEmoteClick, + onWhisperReply = onWhisperReply, + bottomContentPadding = bottomContentPadding, + ) + } + + is FullScreenSheetState.Whisper -> { + MentionSheet( + mentionViewModel = mentionViewModel, + initialisWhisperTab = true, + onDismiss = onDismiss, + onUserClick = popupOnlyClickHandler(onUserClick), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = false), + onEmoteClick = onEmoteClick, + onWhisperReply = onWhisperReply, + bottomContentPadding = bottomContentPadding, + ) + } + + is FullScreenSheetState.Replies -> { + RepliesSheet( + rootMessageId = sheetState.replyMessageId, + onDismiss = onDismissReplies, + onUserClick = mentionableClickHandler(onUserClick, onUserMention, userLongClickBehavior), + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), + onEmoteClick = onEmoteClick, + bottomContentPadding = bottomContentPadding, + ) + } + + is FullScreenSheetState.History -> { + HistorySheetContent( + historyChannel = sheetState.channel, + initialFilter = sheetState.initialFilter, + onDismiss = onDismiss, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ) + } + } + } + } +} + +@Composable +private fun HistorySheetContent( + historyChannel: HistoryChannel, + initialFilter: String, + onDismiss: () -> Unit, + onUserClick: (UserPopupStateParams) -> Unit, + onMessageLongClick: (MessageOptionsParams) -> Unit, + onEmoteClick: (List) -> Unit, +) { + val viewModel: MessageHistoryViewModel = + koinViewModel( + parameters = { parametersOf(historyChannel) }, + ) + LaunchedEffect(historyChannel) { + viewModel.selectChannel(historyChannel) + } + val clickHandler = popupOnlyClickHandler(onUserClick) + MessageHistorySheet( + viewModel = viewModel, + initialFilter = initialFilter, + onDismiss = onDismiss, + onUserClick = clickHandler, + onMessageLongClick = messageOptionsHandler(onMessageLongClick, canJump = true), + onEmoteClick = onEmoteClick, + ) +} + +private fun popupOnlyClickHandler(onUserClick: (UserPopupStateParams) -> Unit): (String?, String, String, String?, List, Boolean) -> Unit = + { userId, userName, displayName, channel, badges, _ -> + onUserClick(buildUserPopupParams(userId, userName, displayName, channel, badges)) + } + +private fun mentionableClickHandler( + onUserClick: (UserPopupStateParams) -> Unit, + onUserMention: (UserName, DisplayName) -> Unit, + userLongClickBehavior: UserLongClickBehavior, +): (String?, String, String, String?, List, Boolean) -> Unit = { userId, userName, displayName, channel, badges, isLongPress -> + val shouldOpenPopup = + when (userLongClickBehavior) { + UserLongClickBehavior.MentionsUser -> !isLongPress + UserLongClickBehavior.OpensPopup -> isLongPress + } + if (shouldOpenPopup) { + onUserClick(buildUserPopupParams(userId, userName, displayName, channel, badges)) + } else { + onUserMention(UserName(userName), DisplayName(displayName)) + } +} + +private fun messageOptionsHandler( + onMessageLongClick: (MessageOptionsParams) -> Unit, + canJump: Boolean, +): (String, String?, String) -> Unit = { messageId, channel, fullMessage -> + onMessageLongClick( + MessageOptionsParams( + messageId = messageId, + channel = channel?.let { UserName(it) }, + fullMessage = fullMessage, + canModerate = false, + canReply = false, + canCopy = true, + canJump = canJump, + ), + ) +} + +private fun buildUserPopupParams( + userId: String?, + userName: String, + displayName: String, + channel: String?, + badges: List, +) = UserPopupStateParams( + targetUserId = userId?.let { UserId(it) } ?: UserId(""), + targetUserName = UserName(userName), + targetDisplayName = DisplayName(displayName), + channel = channel?.let { UserName(it) }, + badges = badges.map { it.badge }, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt new file mode 100644 index 000000000..c6f6036d6 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MentionSheet.kt @@ -0,0 +1,265 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Badge +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.mention.MentionComposable +import com.flxrs.dankchat.ui.chat.mention.MentionViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@Composable +fun MentionSheet( + mentionViewModel: MentionViewModel, + initialisWhisperTab: Boolean, + onDismiss: () -> Unit, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, + onWhisperReply: ((userName: UserName) -> Unit)? = null, + bottomContentPadding: Dp = 0.dp, +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val pagerState = + rememberPagerState( + initialPage = if (initialisWhisperTab) 1 else 0, + pageCount = { 2 }, + ) + var backProgress by remember { mutableFloatStateOf(0f) } + var toolbarVisible by remember { mutableStateOf(true) } + + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) + + val scrollTracker = + remember { + ScrollDirectionTracker( + hideThresholdPx = with(density) { 100.dp.toPx() }, + showThresholdPx = with(density) { 36.dp.toPx() }, + onHide = { toolbarVisible = false }, + onShow = { toolbarVisible = true }, + ) + } + val scrollModifier = Modifier.nestedScroll(scrollTracker) + + val whisperMentionCount by mentionViewModel.whisperMentionCount.collectAsStateWithLifecycle() + + LaunchedEffect(pagerState.currentPage) { + mentionViewModel.setCurrentTab(pagerState.currentPage) + if (pagerState.currentPage == 1) { + mentionViewModel.clearWhisperMentionCount() + } + } + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (_: CancellationException) { + backProgress = 0f + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + @Suppress("ViewModelForwarding") + MentionComposable( + mentionViewModel = mentionViewModel, + isWhisperTab = page == 1, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onWhisperReply = if (page == 1) onWhisperReply else null, + containerColor = sheetBackgroundColor, + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), + scrollModifier = scrollModifier, + onScrollToBottom = { toolbarVisible = true }, + ) + } + + SheetToolbar( + visible = toolbarVisible, + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + onBack = onDismiss, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row { + val tabs = listOf(R.string.mentions, R.string.whispers) + tabs.forEachIndexed { index, stringRes -> + val isSelected = pagerState.currentPage == index + val textColor = + when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable { scope.launch { pagerState.animateScrollToPage(index) } } + .defaultMinSize(minHeight = 48.dp) + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(stringRes), + color = textColor, + style = MaterialTheme.typography.titleSmall, + ) + if (index == 1 && whisperMentionCount > 0 && !isSelected) { + Spacer(Modifier.width(4.dp)) + Badge() + } + } + } + } + } + } + } +} + +@Composable +internal fun BoxScope.SheetToolbar( + visible: Boolean, + statusBarHeight: Dp, + sheetBackgroundColor: Color, + onBack: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + brush = + Brush.verticalGradient( + 0f to sheetBackgroundColor.copy(alpha = 0.7f), + 0.75f to sheetBackgroundColor.copy(alpha = 0.7f), + 1f to sheetBackgroundColor.copy(alpha = 0f), + ), + ).padding(top = statusBarHeight + 8.dp) + .padding(bottom = 16.dp) + .padding(horizontal = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + + content() + } + } + } + + if (!visible) { + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt new file mode 100644 index 000000000..a7a9379b5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/MessageHistorySheet.kt @@ -0,0 +1,391 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.preferences.components.DankBackground +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ChatScreen +import com.flxrs.dankchat.ui.chat.ChatScreenCallbacks +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.history.HistoryChannel +import com.flxrs.dankchat.ui.chat.history.MessageHistoryViewModel +import com.flxrs.dankchat.ui.main.input.SuggestionDropdown +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException + +@Composable +fun MessageHistorySheet( + viewModel: MessageHistoryViewModel, + initialFilter: String, + onDismiss: () -> Unit, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, +) { + LaunchedEffect(viewModel, initialFilter) { + viewModel.setInitialQuery(initialFilter) + } + + val displaySettings by viewModel.chatDisplaySettings.collectAsStateWithLifecycle() + val messages by viewModel.historyUiStates.collectAsStateWithLifecycle(initialValue = persistentListOf()) + val filterSuggestions by viewModel.filterSuggestions.collectAsStateWithLifecycle() + val availableChannels by viewModel.availableChannels.collectAsStateWithLifecycle() + val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle() + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) + var backProgress by remember { mutableFloatStateOf(0f) } + + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp + + val ime = WindowInsets.ime + val navBars = WindowInsets.navigationBars + val navBarHeightDp = with(density) { navBars.getBottom(density).toDp() } + val currentImeHeight = (ime.getBottom(density) - navBars.getBottom(density)).coerceAtLeast(0) + val currentImeDp = with(density) { currentImeHeight.toDp() } + + var searchBarHeightPx by remember { mutableIntStateOf(0) } + val searchBarHeightDp = with(density) { searchBarHeightPx.toDp() } + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (_: CancellationException) { + backProgress = 0f + } + } + + var toolbarVisible by remember { mutableStateOf(true) } + val scrollTracker = + remember { + ScrollDirectionTracker( + hideThresholdPx = with(density) { 100.dp.toPx() }, + showThresholdPx = with(density) { 36.dp.toPx() }, + onHide = { toolbarVisible = false }, + onShow = { toolbarVisible = true }, + ) + } + + val scrollModifier = Modifier.nestedScroll(scrollTracker) + + Box( + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + scaleX = 1f - (backProgress * 0.1f) + scaleY = 1f - (backProgress * 0.1f) + alpha = 1f - (backProgress * 0.5f) + }, + ) { + AnimatedContent( + targetState = selectedChannel, + transitionSpec = { + (fadeIn(tween(300, delayMillis = 100)) + scaleIn(tween(300, delayMillis = 100), initialScale = 0.92f)) + .togetherWith(fadeOut(tween(200)) + scaleOut(tween(200), targetScale = 0.92f)) + }, + label = "HistoryChannelSwitch", + modifier = Modifier.fillMaxSize(), + ) { channel -> + Box(modifier = Modifier.fillMaxSize()) { + ChatScreen( + messages = messages, + fontSize = displaySettings.fontSize, + callbacks = + ChatScreenCallbacks( + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + ), + animateGifs = displaySettings.animateGifs, + showChannelPrefix = channel is HistoryChannel.Global, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = searchBarHeightDp + navBarHeightDp + currentImeDp), + scrollModifier = scrollModifier, + containerColor = sheetBackgroundColor, + onScrollToBottom = { toolbarVisible = true }, + ) + + if (messages.isEmpty()) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .padding(bottom = searchBarHeightDp), + ) { + DankBackground(visible = true) + Text( + text = stringResource(R.string.history_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } + + var showChannelDropdown by remember { mutableStateOf(false) } + + SheetToolbar( + visible = toolbarVisible, + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + onBack = onDismiss, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable { showChannelDropdown = !showChannelDropdown } + .defaultMinSize(minHeight = 48.dp) + .padding(start = 16.dp, end = 8.dp), + ) { + Text( + text = when (selectedChannel) { + is HistoryChannel.Global -> stringResource(R.string.global_history_title) + is HistoryChannel.Channel -> stringResource(R.string.message_history_title, (selectedChannel as HistoryChannel.Channel).name.value) + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Channel quick switcher dropdown — positioned below toolbar, outside SheetToolbar to avoid growing its gradient + AnimatedVisibility( + visible = showChannelDropdown && toolbarVisible, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = + Modifier + .align(Alignment.TopStart) + .padding(top = statusBarHeight + 8.dp + 48.dp + 4.dp) + .padding(start = 8.dp + 48.dp + 8.dp, end = 8.dp), + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = + Modifier + .width(IntrinsicSize.Max) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + availableChannels.forEach { channel -> + val isSelected = channel == selectedChannel + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + viewModel.selectChannel(channel) + showChannelDropdown = false + }.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = when (channel) { + is HistoryChannel.Global -> stringResource(R.string.global_history_title) + is HistoryChannel.Channel -> channel.name.value + }, + style = MaterialTheme.typography.bodyLarge, + color = when { + isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = when { + isSelected -> FontWeight.Bold + else -> FontWeight.Normal + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } + } + } + } + + // Status bar fill when toolbar is hidden + if (!toolbarVisible) { + Box( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(statusBarHeight) + .background(sheetBackgroundColor.copy(alpha = 0.7f)), + ) + } + + SuggestionDropdown( + suggestions = filterSuggestions.toImmutableList(), + onSuggestionClick = { suggestion -> viewModel.applySuggestion(suggestion) }, + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(bottom = searchBarHeightDp + navBarHeightDp + currentImeDp + 8.dp) + .padding(horizontal = 8.dp), + ) + + // Floating search bar pill + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = currentImeDp) + .navigationBarsPadding() + .onSizeChanged { size -> + searchBarHeightPx = size.height + }.padding(bottom = 8.dp) + .padding(horizontal = 8.dp), + ) { + SearchToolbar( + state = viewModel.searchFieldState, + ) + } + } +} + +@Composable +private fun SearchToolbar(state: TextFieldState) { + val keyboardController = LocalSoftwareKeyboardController.current + + val textFieldColors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ) + + TextField( + state = state, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.search_messages_hint)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (state.text.isNotEmpty()) { + IconButton(onClick = { state.clearText() }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.dialog_dismiss), + ) + } + } + }, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + onKeyboardAction = { keyboardController?.hide() }, + shape = MaterialTheme.shapes.extraLarge, + colors = textFieldColors, + ) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt new file mode 100644 index 000000000..a912d389e --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/RepliesSheet.kt @@ -0,0 +1,147 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote +import com.flxrs.dankchat.ui.chat.BadgeUi +import com.flxrs.dankchat.ui.chat.ScrollDirectionTracker +import com.flxrs.dankchat.ui.chat.replies.RepliesComposable +import com.flxrs.dankchat.ui.chat.replies.RepliesViewModel +import kotlinx.coroutines.CancellationException +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun RepliesSheet( + rootMessageId: String, + onDismiss: () -> Unit, + onUserClick: (userId: String?, userName: String, displayName: String, channel: String?, badges: List, isLongPress: Boolean) -> Unit, + onMessageLongClick: (messageId: String, channel: String?, fullMessage: String) -> Unit, + onEmoteClick: (List) -> Unit, + bottomContentPadding: Dp = 0.dp, +) { + val viewModel: RepliesViewModel = + koinViewModel( + key = rootMessageId, + parameters = { parametersOf(rootMessageId) }, + ) + val density = LocalDensity.current + var backProgress by remember { mutableFloatStateOf(0f) } + var toolbarVisible by remember { mutableStateOf(true) } + + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val toolbarTopPadding = statusBarHeight + 8.dp + 48.dp + 16.dp + val sheetBackgroundColor = + lerp( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.colorScheme.surfaceContainerHigh, + fraction = 0.75f, + ) + + val scrollTracker = + remember { + ScrollDirectionTracker( + hideThresholdPx = with(density) { 100.dp.toPx() }, + showThresholdPx = with(density) { 36.dp.toPx() }, + onHide = { toolbarVisible = false }, + onShow = { toolbarVisible = true }, + ) + } + val scrollModifier = Modifier.nestedScroll(scrollTracker) + + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (_: CancellationException) { + backProgress = 0f + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(sheetBackgroundColor) + .graphicsLayer { + val scale = 1f - (backProgress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - backProgress + translationY = backProgress * 100f + }, + ) { + RepliesComposable( + repliesViewModel = viewModel, + onUserClick = onUserClick, + onMessageLongClick = onMessageLongClick, + onEmoteClick = onEmoteClick, + onMissing = onDismiss, + containerColor = sheetBackgroundColor, + contentPadding = PaddingValues(top = toolbarTopPadding, bottom = bottomContentPadding), + scrollModifier = scrollModifier, + onScrollToBottom = { toolbarVisible = true }, + modifier = Modifier.fillMaxSize(), + ) + + SheetToolbar( + visible = toolbarVisible, + statusBarHeight = statusBarHeight, + sheetBackgroundColor = sheetBackgroundColor, + onBack = onDismiss, + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = stringResource(R.string.replies_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt new file mode 100644 index 000000000..4e3f67509 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationState.kt @@ -0,0 +1,39 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.compose.runtime.Immutable +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.history.HistoryChannel + +@Immutable +sealed interface FullScreenSheetState { + data object Closed : FullScreenSheetState + + data class Replies( + val replyMessageId: String, + val replyName: UserName, + ) : FullScreenSheetState + + data object Mention : FullScreenSheetState + + data object Whisper : FullScreenSheetState + + data class History( + val channel: HistoryChannel = HistoryChannel.Global, + val initialFilter: String = "", + ) : FullScreenSheetState +} + +@Immutable +sealed interface InputSheetState { + data object Closed : InputSheetState + + data object EmoteMenu : InputSheetState + + data object DebugInfo : InputSheetState +} + +@Immutable +data class SheetNavigationState( + val fullScreenSheet: FullScreenSheetState = FullScreenSheetState.Closed, + val inputSheet: InputSheetState = InputSheetState.Closed, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt new file mode 100644 index 000000000..3130a1eba --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/sheet/SheetNavigationViewModel.kt @@ -0,0 +1,63 @@ +package com.flxrs.dankchat.ui.main.sheet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.ui.chat.history.HistoryChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class SheetNavigationViewModel : ViewModel() { + private val _fullScreenSheetState = MutableStateFlow(FullScreenSheetState.Closed) + val fullScreenSheetState: StateFlow = _fullScreenSheetState.asStateFlow() + + private val _inputSheetState = MutableStateFlow(InputSheetState.Closed) + + val sheetState: StateFlow = + combine( + _fullScreenSheetState, + _inputSheetState, + ) { fullScreen, input -> + SheetNavigationState(fullScreenSheet = fullScreen, inputSheet = input) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SheetNavigationState()) + + fun openReplies( + rootMessageId: String, + replyName: UserName, + ) { + _fullScreenSheetState.value = FullScreenSheetState.Replies(rootMessageId, replyName) + } + + fun openMentions() { + _fullScreenSheetState.value = FullScreenSheetState.Mention + } + + fun openWhispers() { + _fullScreenSheetState.value = FullScreenSheetState.Whisper + } + + fun openHistory( + channel: HistoryChannel, + initialFilter: String = "", + ) { + _fullScreenSheetState.value = FullScreenSheetState.History(channel, initialFilter) + } + + fun closeFullScreenSheet() { + _fullScreenSheetState.value = FullScreenSheetState.Closed + } + + fun openDebugInfo() { + _inputSheetState.value = InputSheetState.DebugInfo + } + + fun closeInputSheet() { + _inputSheetState.value = InputSheetState.Closed + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/AudioOnlyBar.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/AudioOnlyBar.kt new file mode 100644 index 000000000..a053c26ae --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/AudioOnlyBar.kt @@ -0,0 +1,71 @@ +package com.flxrs.dankchat.ui.main.stream + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName + +@Composable +fun AudioOnlyBar( + channel: UserName, + onExpandVideo: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier.clickable(onClick = onExpandVideo), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + Icon( + imageVector = Icons.Default.Headphones, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + text = channel.value, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + IconButton(onClick = onExpandVideo) { + Icon( + imageVector = Icons.Outlined.Videocam, + contentDescription = stringResource(R.string.menu_show_stream), + ) + } + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt new file mode 100644 index 000000000..88092a886 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamView.kt @@ -0,0 +1,275 @@ +package com.flxrs.dankchat.ui.main.stream + +import android.view.MotionEvent +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.doOnAttach +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.UserName +import kotlinx.coroutines.delay +import org.koin.compose.viewmodel.koinViewModel + +private const val OVERLAY_AUTO_HIDE_MS = 3_000L + +@Suppress("LambdaParameterEventTrailing") +@Composable +fun StreamView( + channel: UserName, + onClose: () -> Unit, + onAudioOnly: () -> Unit, + modifier: Modifier = Modifier, + isInPipMode: Boolean = false, + fillPane: Boolean = false, +) { + val streamViewModel: StreamViewModel = koinViewModel() + // Track whether the WebView has been attached to a window before. + // First open: load URL while detached, attach after page loads (avoids white SurfaceView flash). + // Subsequent opens: attach immediately, load URL while attached (video surface already initialized). + var hasBeenAttached by remember { mutableStateOf(streamViewModel.hasWebViewBeenAttached) } + var isPageLoaded by remember { mutableStateOf(hasBeenAttached) } + var overlayTapTrigger by remember { mutableIntStateOf(0) } + var showOverlayButtons by remember { mutableStateOf(false) } + + LaunchedEffect(overlayTapTrigger) { + if (overlayTapTrigger > 0) { + showOverlayButtons = true + delay(OVERLAY_AUTO_HIDE_MS) + showOverlayButtons = false + } + } + val webView = + remember { + streamViewModel.getOrCreateWebView().also { wv -> + wv.setBackgroundColor(android.graphics.Color.TRANSPARENT) + wv.webViewClient = + StreamComposeWebViewClient( + onPageFinished = { isPageLoaded = true }, + ) + var blockingGesture = false + @Suppress("ClickableViewAccessibility") + wv.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + overlayTapTrigger++ + if (!showOverlayButtons) { + blockingGesture = true + true + } else { + blockingGesture = false + false + } + } + + else -> { + blockingGesture + } + } + } + } + } + + // For first open: load URL on detached WebView + if (!hasBeenAttached) { + DisposableEffect(channel) { + streamViewModel.setStream(channel, webView) + onDispose { } + } + } + + DisposableEffect(Unit) { + onDispose { + (webView.parent as? ViewGroup)?.removeView(webView) + // Active close (channel set to null) → destroy WebView + // Config change (channel still set) → just detach, keep alive for reuse + if (streamViewModel.streamState.value.currentStream == null) { + streamViewModel.destroyWebView(webView) + } + } + } + + Box( + modifier = + modifier + .then(if (isInPipMode || fillPane) Modifier else Modifier.statusBarsPadding()) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface), + ) { + val webViewModifier = + when { + isInPipMode || fillPane -> { + Modifier.fillMaxSize() + } + + else -> { + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + } + } + + if (isPageLoaded) { + AndroidView( + factory = { _ -> + (webView.parent as? ViewGroup)?.removeView(webView) + webView.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + if (!hasBeenAttached) { + hasBeenAttached = true + streamViewModel.hasWebViewBeenAttached = true + } else { + // Resume playback after config change — the Twitch player pauses + // when the WebView detaches from the old window during Activity recreation. + webView.doOnAttach { view -> + view.postDelayed({ + (view as? WebView)?.evaluateJavascript("document.querySelector('video')?.play()", null) + }, 100) + } + } + webView + }, + update = { _ -> + streamViewModel.setStream(channel, webView) + }, + modifier = + webViewModifier.graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + }, + ) + } else { + Box(modifier = webViewModifier) + } + + AnimatedVisibility( + visible = !isInPipMode && showOverlayButtons, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.TopEnd), + ) { + Row( + modifier = + Modifier + .statusBarsPadding() + .padding(8.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement + .spacedBy(6.dp), + ) { + StreamOverlayButton( + icon = Icons.Default.Headphones, + contentDescription = stringResource(R.string.menu_audio_only), + onClick = onAudioOnly, + ) + StreamOverlayButton( + icon = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_dismiss), + onClick = onClose, + ) + } + } + } +} + +@Composable +private fun StreamOverlayButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + onClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.5f), + shape = CircleShape, + ).clickable(onClick = onClick), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp), + ) + } +} + +private class StreamComposeWebViewClient( + private val onPageFinished: () -> Unit, +) : WebViewClient() { + override fun onPageFinished( + view: WebView?, + url: String?, + ) { + if (url != null && url != BLANK_URL) { + onPageFinished() + } + } + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading( + view: WebView?, + url: String?, + ): Boolean { + if (url.isNullOrBlank()) return true + return ALLOWED_PATHS.none { url.startsWith(it) } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() + if (url.isNullOrBlank()) return true + return ALLOWED_PATHS.none { url.startsWith(it) } + } + + companion object { + private const val BLANK_URL = "about:blank" + private val ALLOWED_PATHS = + listOf( + BLANK_URL, + "https://id.twitch.tv/", + "https://www.twitch.tv/passport-callback", + "https://player.twitch.tv/", + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt new file mode 100644 index 000000000..18d3b539f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamViewModel.kt @@ -0,0 +1,145 @@ +package com.flxrs.dankchat.ui.main.stream + +import android.annotation.SuppressLint +import android.app.Application +import androidx.compose.runtime.Immutable +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.chat.ChatChannelProvider +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.preferences.stream.StreamsSettingsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class StreamViewModel( + application: Application, + private val chatChannelProvider: ChatChannelProvider, + private val streamDataRepository: StreamDataRepository, + private val streamsSettingsDataStore: StreamsSettingsDataStore, +) : AndroidViewModel(application) { + private val _currentStreamedChannel = MutableStateFlow(null) + + private val hasStreamData: StateFlow = + combine( + chatChannelProvider.activeChannel, + streamDataRepository.streamData, + ) { activeChannel, streamData -> + activeChannel != null && streamData.any { it.channel == activeChannel } + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _isAudioOnly = MutableStateFlow(false) + + val streamState: StateFlow = + combine( + _currentStreamedChannel, + hasStreamData, + _isAudioOnly, + ) { currentStream, hasData, audioOnly -> + StreamState(currentStream = currentStream, hasStreamData = hasData, isAudioOnly = audioOnly) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StreamState()) + + val shouldEnablePipAutoMode: StateFlow = + combine( + _currentStreamedChannel, + _isAudioOnly, + streamsSettingsDataStore.pipEnabled, + ) { currentStream, audioOnly, pipEnabled -> + currentStream != null && !audioOnly && pipEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + viewModelScope.launch { + chatChannelProvider.channels.collect { channels -> + if (channels != null) { + streamDataRepository.fetchStreamData(channels) + val current = _currentStreamedChannel.value + if (current != null && current !in channels) { + closeStream() + } + } + } + } + } + + private var lastStreamedChannel: UserName? = null + var hasWebViewBeenAttached: Boolean = false + + @SuppressLint("StaticFieldLeak") + private var cachedWebView: StreamWebView? = null + + fun getOrCreateWebView(): StreamWebView { + val preventReloads = streamsSettingsDataStore.current().preventStreamReloads + return if (preventReloads) { + cachedWebView ?: StreamWebView(getApplication()).also { cachedWebView = it } + } else { + StreamWebView(getApplication()) + } + } + + fun setStream( + channel: UserName, + webView: StreamWebView, + ) { + if (channel == lastStreamedChannel) return + lastStreamedChannel = channel + loadStream(channel, webView) + } + + fun destroyWebView(webView: StreamWebView) { + webView.stopLoading() + webView.destroy() + if (cachedWebView === webView) { + cachedWebView = null + } + lastStreamedChannel = null + hasWebViewBeenAttached = false + } + + private fun loadStream( + channel: UserName, + webView: StreamWebView, + ) { + val url = "https://player.twitch.tv/?channel=$channel&enableExtensions=true&muted=false&parent=twitch.tv" + webView.stopLoading() + webView.loadUrl(url) + } + + fun toggleStream(channel: UserName) { + _currentStreamedChannel.update { if (it == channel) null else channel } + _isAudioOnly.value = false + } + + fun toggleAudioOnly() { + _isAudioOnly.update { !it } + } + + fun closeStream() { + _currentStreamedChannel.value = null + _isAudioOnly.value = false + } + + override fun onCleared() { + streamDataRepository.cancelStreamData() + cachedWebView?.destroy() + cachedWebView = null + lastStreamedChannel = null + super.onCleared() + } +} + +@Immutable +data class StreamState( + val currentStream: UserName? = null, + val hasStreamData: Boolean = false, + val isAudioOnly: Boolean = false, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt new file mode 100644 index 000000000..14bdb4c34 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/main/stream/StreamWebView.kt @@ -0,0 +1,65 @@ +package com.flxrs.dankchat.ui.main.stream + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +@SuppressLint("SetJavaScriptEnabled") +class StreamWebView + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = android.R.attr.webViewStyle, + defStyleRes: Int = 0, + ) : WebView(context, attrs, defStyleAttr, defStyleRes) { + init { + with(settings) { + javaScriptEnabled = true + setSupportZoom(false) + mediaPlaybackRequiresUserGesture = false + domStorageEnabled = true + } + webViewClient = StreamWebViewClient() + } + + private class StreamWebViewClient : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading( + view: WebView?, + url: String?, + ): Boolean { + if (url.isNullOrBlank()) { + return true + } + + return ALLOWED_PATHS.none { url.startsWith(it) } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() + if (url.isNullOrBlank()) { + return true + } + + return ALLOWED_PATHS.none { url.startsWith(it) } + } + } + + companion object { + private const val BLANK_URL = "about:blank" + private val ALLOWED_PATHS = + listOf( + BLANK_URL, + "https://id.twitch.tv/", + "https://www.twitch.tv/passport-callback", + "https://player.twitch.tv/", + ) + } + } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt new file mode 100644 index 000000000..107383514 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingDataStore.kt @@ -0,0 +1,64 @@ +package com.flxrs.dankchat.ui.onboarding + +import android.content.Context +import androidx.datastore.core.DataMigration +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.utils.datastore.createDataStore +import com.flxrs.dankchat.utils.datastore.safeData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking +import org.koin.core.annotation.Single + +@Single +class OnboardingDataStore( + context: Context, + dispatchersProvider: DispatchersProvider, + dankChatPreferenceStore: DankChatPreferenceStore, +) { + // Detect existing users by checking if they already acknowledged the message history disclaimer. + // If so, they've used the app before and should skip onboarding. + private val existingUserMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: OnboardingSettings): Boolean = !currentData.hasRunExistingUserMigration && dankChatPreferenceStore.hasMessageHistoryAcknowledged + + override suspend fun migrate(currentData: OnboardingSettings): OnboardingSettings = currentData.copy( + hasCompletedOnboarding = true, + hasRunExistingUserMigration = true, + hasShownAddChannelHint = true, + hasShownToolbarHint = true, + ) + + override suspend fun cleanUp() = Unit + } + + private val scope = CoroutineScope(dispatchersProvider.io + SupervisorJob()) + + private val dataStore = + createDataStore( + fileName = "onboarding", + context = context, + defaultValue = OnboardingSettings(), + serializer = OnboardingSettings.serializer(), + scope = scope, + migrations = listOf(existingUserMigration), + ) + + val settings = dataStore.safeData(OnboardingSettings()) + val currentSettings = + settings.stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = runBlocking { settings.first() }, + ) + + fun current() = currentSettings.value + + suspend fun update(transform: suspend (OnboardingSettings) -> OnboardingSettings) { + dataStore.updateData(transform) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..54132bce3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,469 @@ +package com.flxrs.dankchat.ui.onboarding + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.flxrs.dankchat.R +import com.flxrs.dankchat.utils.compose.buildLinkAnnotation +import com.flxrs.dankchat.utils.extensions.isAtLeastTiramisu +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel + +private const val PAGE_COUNT = 4 + +@Composable +fun OnboardingScreen( + onNavigateToLogin: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: OnboardingViewModel = koinViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val pagerState = + rememberPagerState( + initialPage = state.initialPage, + pageCount = { PAGE_COUNT }, + ) + LaunchedEffect(pagerState.currentPage) { + viewModel.setCurrentPage(pagerState.currentPage) + } + + // Auto-advance past login page when login is detected by the ViewModel + LaunchedEffect(state.loginCompleted) { + if (state.loginCompleted && pagerState.currentPage == 1) { + pagerState.animateScrollToPage(2) + } + } + + Surface(modifier = modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 24.dp), + ) { + LinearProgressIndicator( + progress = { (pagerState.currentPage + 1).toFloat() / PAGE_COUNT }, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.weight(1f), + ) { page -> + when (page) { + 0 -> { + WelcomePage( + onStart = { scope.launch { pagerState.animateScrollToPage(1) } }, + ) + } + + 1 -> { + LoginPage( + loginCompleted = state.loginCompleted, + onLogin = onNavigateToLogin, + onSkip = { scope.launch { pagerState.animateScrollToPage(2) } }, + onContinue = { scope.launch { pagerState.animateScrollToPage(2) } }, + ) + } + + 2 -> { + MessageHistoryPage( + decided = state.messageHistoryDecided, + onEnable = { + viewModel.onMessageHistoryDecision(enabled = true) + scope.launch { pagerState.animateScrollToPage(3) } + }, + onDisable = { + viewModel.onMessageHistoryDecision(enabled = false) + scope.launch { pagerState.animateScrollToPage(3) } + }, + ) + } + + 3 -> { + NotificationsPage( + onContinue = { + viewModel.completeOnboarding(onComplete) + }, + ) + } + } + } + } + } +} + +@Composable +private fun OnboardingPage( + title: String, + icon: @Composable () -> Unit, + body: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + icon() + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + body() + Spacer(modifier = Modifier.height(32.dp)) + content() + } +} + +@Composable +private fun OnboardingBody(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun WelcomePage( + onStart: () -> Unit, + modifier: Modifier = Modifier, +) { + OnboardingPage( + icon = { + Icon( + painter = painterResource(R.drawable.ic_dank_chat_mono_cropped), + contentDescription = null, + modifier = Modifier.size(128.dp), + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + }, + title = stringResource(R.string.onboarding_welcome_title), + body = { OnboardingBody(stringResource(R.string.onboarding_welcome_body)) }, + modifier = modifier, + ) { + Button(onClick = onStart) { + Text(stringResource(R.string.onboarding_get_started)) + } + } +} + +@Composable +private fun LoginPage( + loginCompleted: Boolean, + onLogin: () -> Unit, + onSkip: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + OnboardingPage( + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = stringResource(R.string.onboarding_login_title), + body = { + OnboardingBody(stringResource(R.string.onboarding_login_body)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.onboarding_login_disclaimer), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = modifier, + ) { + AnimatedContent( + targetState = loginCompleted, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "login_state", + ) { completed -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when { + completed -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onboarding_login_success), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onContinue) { + Text(stringResource(R.string.onboarding_continue)) + } + } + + else -> { + Button(onClick = onLogin) { + Text(stringResource(R.string.onboarding_login_button)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onSkip) { + Text(stringResource(R.string.onboarding_skip)) + } + } + } + } + } + } +} + +@Composable +private fun MessageHistoryPage( + decided: Boolean, + onEnable: () -> Unit, + onDisable: () -> Unit, + modifier: Modifier = Modifier, +) { + OnboardingPage( + icon = { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = stringResource(R.string.onboarding_history_title), + body = { + val bodyText = stringResource(R.string.onboarding_history_body) + val url = "https://recent-messages.robotty.de/" + val linkAnnotation = buildLinkAnnotation(url) + val annotatedBody = + remember(bodyText, linkAnnotation) { + buildAnnotatedString { + val urlStart = bodyText.indexOf(url) + when { + urlStart >= 0 -> { + append(bodyText.substring(0, urlStart)) + withLink(link = linkAnnotation) { + append(url) + } + append(bodyText.substring(urlStart + url.length)) + } + + else -> { + append(bodyText) + } + } + } + } + Text( + text = annotatedBody, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = modifier, + ) { + if (!decided) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onDisable) { + Text(stringResource(R.string.onboarding_history_disable)) + } + Button(onClick = onEnable) { + Text(stringResource(R.string.onboarding_history_enable)) + } + } + } + } +} + +private enum class NotificationPermissionState { Pending, Granted, Denied } + +@SuppressLint("InlinedApi") +@Composable +private fun NotificationsPage( + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var permissionState by remember { mutableStateOf(NotificationPermissionState.Pending) } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + onContinue() + } else { + permissionState = NotificationPermissionState.Denied + } + } + + // Re-check permission when returning from notification settings — auto-advance if granted + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + if (isAtLeastTiramisu && permissionState == NotificationPermissionState.Denied) { + val granted = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + onContinue() + } + } + } + } + + OnboardingPage( + icon = { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = stringResource(R.string.onboarding_notifications_title), + body = { OnboardingBody(stringResource(R.string.onboarding_notifications_body)) }, + modifier = modifier, + ) { + if (isAtLeastTiramisu) { + AnimatedContent( + targetState = permissionState, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "notification_state", + ) { state -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when (state) { + NotificationPermissionState.Granted -> { + Button(onClick = onContinue) { + Text(stringResource(R.string.onboarding_continue)) + } + } + + NotificationPermissionState.Denied -> { + Text( + text = stringResource(R.string.onboarding_notifications_rationale), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + FilledTonalButton( + onClick = { + val intent = + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + }, + ) { + Text(stringResource(R.string.onboarding_notifications_open_settings)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onContinue) { + Text(stringResource(R.string.onboarding_skip)) + } + } + + NotificationPermissionState.Pending -> { + Button( + onClick = { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + ) { + Text(stringResource(R.string.onboarding_notifications_allow)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onContinue) { + Text(stringResource(R.string.onboarding_skip)) + } + } + } + } + } + } else { + // Pre-Tiramisu: no runtime permission needed + Button(onClick = onContinue) { + Text(stringResource(R.string.onboarding_continue)) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt new file mode 100644 index 000000000..7bf2ef223 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingSettings.kt @@ -0,0 +1,14 @@ +package com.flxrs.dankchat.ui.onboarding + +import kotlinx.serialization.Serializable + +@Serializable +data class OnboardingSettings( + val hasCompletedOnboarding: Boolean = false, + val hasRunExistingUserMigration: Boolean = false, + val featureTourVersion: Int = 0, + val featureTourStep: Int = 0, + val hasShownAddChannelHint: Boolean = false, + val hasShownToolbarHint: Boolean = false, + val onboardingPage: Int = 0, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 000000000..e83f52b29 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,83 @@ +package com.flxrs.dankchat.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +data class OnboardingState( + val initialPage: Int = 0, + val currentPage: Int = 0, + val loginCompleted: Boolean = false, + val messageHistoryDecided: Boolean = false, + val messageHistoryEnabled: Boolean = true, +) + +@KoinViewModel +class OnboardingViewModel( + private val onboardingDataStore: OnboardingDataStore, + private val authDataStore: AuthDataStore, + private val dankChatPreferenceStore: DankChatPreferenceStore, + private val chatSettingsDataStore: ChatSettingsDataStore, +) : ViewModel() { + private val _state: MutableStateFlow + val state: StateFlow + + init { + val savedPage = onboardingDataStore.current().onboardingPage + val isLoggedIn = authDataStore.isLoggedIn + _state = + MutableStateFlow( + OnboardingState( + initialPage = savedPage, + currentPage = savedPage, + loginCompleted = isLoggedIn, + // If we're past the history page, the decision was already made in a previous session + messageHistoryDecided = savedPage > 2, + ), + ) + state = _state.asStateFlow() + + // Observe auth state changes so we detect login during onboarding + viewModelScope.launch { + authDataStore.settings + .map { it.isLoggedIn } + .distinctUntilChanged() + .collect { isLoggedIn -> + if (isLoggedIn && !_state.value.loginCompleted) { + _state.update { it.copy(loginCompleted = true) } + } + } + } + } + + fun setCurrentPage(page: Int) { + _state.update { it.copy(currentPage = page) } + viewModelScope.launch { + onboardingDataStore.update { it.copy(onboardingPage = page) } + } + } + + fun onMessageHistoryDecision(enabled: Boolean) { + _state.update { it.copy(messageHistoryDecided = true, messageHistoryEnabled = enabled) } + } + + fun completeOnboarding(onComplete: () -> Unit) { + val historyEnabled = _state.value.messageHistoryEnabled + dankChatPreferenceStore.hasMessageHistoryAcknowledged = true + viewModelScope.launch { + chatSettingsDataStore.update { it.copy(loadMessageHistory = historyEnabled) } + onboardingDataStore.update { it.copy(hasCompletedOnboarding = true, onboardingPage = 0) } + onComplete() + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt new file mode 100644 index 000000000..db4657dc3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/share/ShareUploadActivity.kt @@ -0,0 +1,292 @@ +package com.flxrs.dankchat.ui.share + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.webkit.MimeTypeMap +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import com.flxrs.dankchat.R +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.ui.theme.DankChatTheme +import com.flxrs.dankchat.utils.createMediaFile +import com.flxrs.dankchat.utils.extensions.parcelable +import com.flxrs.dankchat.utils.removeExifAttributes +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import java.io.File + +class ShareUploadActivity : ComponentActivity() { + private val dataRepository: DataRepository by inject() + private val dispatchersProvider: DispatchersProvider by inject() + private var uploadState by mutableStateOf(ShareUploadState.Loading) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + DankChatTheme { + ShareUploadDialog( + state = uploadState, + onRetry = { retryUpload() }, + onDismiss = { finish() }, + ) + } + } + + if (savedInstanceState == null) { + handleShareIntent(intent) + } + } + + private fun handleShareIntent(intent: Intent) { + val uri = intent.parcelable(Intent.EXTRA_STREAM) + if (uri == null) { + uploadState = ShareUploadState.Error(getString(R.string.snackbar_upload_failed)) + return + } + + val mimeType = contentResolver.getType(uri) + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "tmp" + + lifecycleScope.launch { + uploadState = ShareUploadState.Loading + val file = + withContext(dispatchersProvider.io) { + try { + val copy = createMediaFile(this@ShareUploadActivity, extension) + contentResolver.openInputStream(uri)?.use { input -> + copy.outputStream().use { input.copyTo(it) } + } + if (copy.extension == "jpg" || copy.extension == "jpeg") { + copy.removeExifAttributes() + } + copy + } catch (_: Throwable) { + null + } + } + + if (file == null) { + uploadState = ShareUploadState.Error(getString(R.string.snackbar_upload_failed)) + return@launch + } + + performUpload(file) + } + } + + private fun retryUpload() { + handleShareIntent(intent) + } + + private suspend fun performUpload(file: File) { + val result = withContext(dispatchersProvider.io) { dataRepository.uploadMedia(file) } + result.fold( + onSuccess = { url -> uploadState = ShareUploadState.Success(url) }, + onFailure = { error -> + uploadState = + ShareUploadState.Error( + message = error.message ?: getString(R.string.snackbar_upload_failed), + ) + }, + ) + } +} + +@Immutable +sealed interface ShareUploadState { + data object Loading : ShareUploadState + + data class Success( + val url: String, + ) : ShareUploadState + + data class Error( + val message: String, + ) : ShareUploadState +} + +@Composable +private fun ShareUploadDialog( + state: ShareUploadState, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + modifier = Modifier.widthIn(min = 280.dp, max = 400.dp), + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.upload_media), + style = MaterialTheme.typography.headlineSmall, + ) + + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "uploadState", + ) { currentState -> + when (currentState) { + is ShareUploadState.Loading -> LoadingContent() + is ShareUploadState.Success -> SuccessContent(url = currentState.url) + is ShareUploadState.Error -> ErrorContent(message = currentState.message) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is ShareUploadState.Error -> { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_dismiss)) + } + TextButton(onClick = onRetry) { + Text(stringResource(R.string.snackbar_retry)) + } + } + + is ShareUploadState.Success -> { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_ok)) + } + } + + is ShareUploadState.Loading -> { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dialog_cancel)) + } + } + } + } + } + } +} + +@Composable +private fun LoadingContent() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Text( + text = stringResource(R.string.uploading_image), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun SuccessContent(url: String) { + val context = LocalContext.current + val clipboardManager = remember { context.getSystemService(ClipboardManager::class.java) } + + LaunchedEffect(url) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("dankchat_media_url", url)) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = url, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.share_upload_copied), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { + clipboardManager.setPrimaryClip(ClipData.newPlainText("dankchat_media_url", url)) + }) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.share_upload_copy), + ) + } + } +} + +@Composable +private fun ErrorContent(message: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt new file mode 100644 index 000000000..9c73e7e67 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/theme/DankChatTheme.kt @@ -0,0 +1,129 @@ +package com.flxrs.dankchat.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.expressiveLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flxrs.dankchat.preferences.appearance.AppearanceSettings +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.appearance.PaletteStylePreference +import com.flxrs.dankchat.preferences.appearance.ThemePreference +import com.materialkolor.PaletteStyle +import com.materialkolor.rememberDynamicColorScheme +import org.koin.compose.koinInject + +data class AdaptiveColors( + val onSurfaceLight: Color, + val onSurfaceDark: Color, +) + +val LocalAdaptiveColors = + staticCompositionLocalOf { + AdaptiveColors( + onSurfaceLight = lightColorScheme().onSurface, + onSurfaceDark = darkColorScheme().onSurface, + ) + } + +@Composable +fun DankChatTheme(content: @Composable () -> Unit) { + val inspectionMode = LocalInspectionMode.current + val appearanceSettings = if (!inspectionMode) koinInject() else null + val settings by appearanceSettings?.settings?.collectAsStateWithLifecycle( + initialValue = remember { appearanceSettings.current() }, + ) ?: remember { androidx.compose.runtime.mutableStateOf(AppearanceSettings()) } + + val systemDarkTheme = isSystemInDarkTheme() + val darkTheme = when (settings.theme) { + ThemePreference.System -> systemDarkTheme + ThemePreference.Dark -> true + ThemePreference.Light -> false + } + val accentColor = settings.accentColor + val trueDarkTheme = settings.trueDarkTheme && darkTheme + val paletteStyle = settings.paletteStyle.toPaletteStyle() + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + val useSystemColors = accentColor == null && settings.paletteStyle == PaletteStylePreference.SystemDefault + val seedColor = accentColor?.seedColor + ?: if (dynamicColor) dynamicLightColorScheme(LocalContext.current).primary else null + + val lightColorScheme = when { + seedColor != null && !useSystemColors -> rememberDynamicColorScheme( + seedColor = seedColor, + isDark = false, + style = paletteStyle, + ) + + dynamicColor -> dynamicLightColorScheme(LocalContext.current) + + else -> expressiveLightColorScheme() + } + + val darkColorScheme = when { + seedColor != null && !useSystemColors -> rememberDynamicColorScheme( + seedColor = seedColor, + isDark = true, + isAmoled = trueDarkTheme, + style = paletteStyle, + ) + + dynamicColor && trueDarkTheme -> dynamicDarkColorScheme(LocalContext.current).copy( + surface = Color.Black, + surfaceDim = Color.Black, + surfaceBright = Color(0xFF222222), + surfaceContainerLowest = Color.Black, + surfaceContainerLow = Color(0xFF0A0A0A), + surfaceContainer = Color(0xFF0E0E0E), + surfaceContainerHigh = Color(0xFF141414), + surfaceContainerHighest = Color(0xFF1C1C1C), + background = Color.Black, + onSurface = Color.White, + onBackground = Color.White, + ) + + dynamicColor -> dynamicDarkColorScheme(LocalContext.current) + + else -> darkColorScheme() + } + + val adaptiveColors = + AdaptiveColors( + onSurfaceLight = lightColorScheme.onSurface, + onSurfaceDark = darkColorScheme.onSurface, + ) + val colors = if (darkTheme) darkColorScheme else lightColorScheme + MaterialExpressiveTheme( + colorScheme = colors, + ) { + CompositionLocalProvider(LocalAdaptiveColors provides adaptiveColors) { + content() + } + } +} + +private fun PaletteStylePreference.toPaletteStyle(): PaletteStyle = when (this) { + PaletteStylePreference.SystemDefault -> PaletteStyle.TonalSpot + PaletteStylePreference.TonalSpot -> PaletteStyle.TonalSpot + PaletteStylePreference.Neutral -> PaletteStyle.Neutral + PaletteStylePreference.Vibrant -> PaletteStyle.Vibrant + PaletteStylePreference.Expressive -> PaletteStyle.Expressive + PaletteStylePreference.Rainbow -> PaletteStyle.Rainbow + PaletteStylePreference.FruitSalad -> PaletteStyle.FruitSalad + PaletteStylePreference.Monochrome -> PaletteStyle.Monochrome + PaletteStylePreference.Fidelity -> PaletteStyle.Fidelity + PaletteStylePreference.Content -> PaletteStyle.Content +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt new file mode 100644 index 000000000..bff4c052a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModel.kt @@ -0,0 +1,274 @@ +package com.flxrs.dankchat.ui.tour + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flxrs.dankchat.data.auth.StartupValidation +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore +import com.flxrs.dankchat.ui.onboarding.OnboardingSettings +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +const val CURRENT_TOUR_VERSION = 1 + +enum class TourStep { + InputActions, + OverflowMenu, + ConfigureActions, + SwipeGesture, + RecoveryFab, +} + +@Immutable +sealed interface PostOnboardingStep { + /** Waiting for conditions (onboarding not done yet, or no channels). */ + data object Idle : PostOnboardingStep + + /** Show tooltip on the toolbar plus icon. */ + data object ToolbarPlusHint : PostOnboardingStep + + /** Run the feature tour. */ + data object FeatureTour : PostOnboardingStep + + /** Everything done. */ + data object Complete : PostOnboardingStep +} + +@Immutable +data class FeatureTourUiState( + val postOnboardingStep: PostOnboardingStep = PostOnboardingStep.Idle, + val currentTourStep: TourStep? = null, + val isTourActive: Boolean = false, + val forceOverflowOpen: Boolean = false, + val gestureInputHidden: Boolean = false, +) + +@OptIn(ExperimentalMaterial3Api::class) +@KoinViewModel +class FeatureTourViewModel( + private val onboardingDataStore: OnboardingDataStore, + startupValidationHolder: StartupValidationHolder, +) : ViewModel() { + // Material3 tooltip states + val inputActionsTooltipState = TooltipState(isPersistent = true) + val overflowMenuTooltipState = TooltipState(isPersistent = true) + val configureActionsTooltipState = TooltipState(isPersistent = true) + val swipeGestureTooltipState = TooltipState(isPersistent = true) + val recoveryFabTooltipState = TooltipState(isPersistent = true) + val addChannelTooltipState = TooltipState(isPersistent = true) + + private data class TourInternalState( + val isActive: Boolean = false, + val stepIndex: Int = 0, + val forceOverflowOpen: Boolean = false, + val gestureInputHidden: Boolean = false, + val completed: Boolean = false, + ) + + private data class ChannelState( + val ready: Boolean = false, + val empty: Boolean = true, + ) + + private val _tourState = MutableStateFlow(TourInternalState()) + private val _channelState = MutableStateFlow(ChannelState()) + private val _toolbarHintDone = MutableStateFlow(false) + + val uiState: StateFlow = + combine( + onboardingDataStore.settings, + _tourState, + _channelState, + _toolbarHintDone, + startupValidationHolder.state, + ) { settings, tour, channel, hintDone, validation -> + val currentStep = + when { + !tour.isActive -> null + tour.stepIndex >= TourStep.entries.size -> null + else -> TourStep.entries[tour.stepIndex] + } + FeatureTourUiState( + postOnboardingStep = + resolvePostOnboardingStep( + settings = settings, + channelReady = channel.ready, + channelEmpty = channel.empty, + toolbarHintDone = hintDone || settings.hasShownToolbarHint, + tourActive = tour.isActive, + tourCompleted = tour.completed, + authValidated = validation is StartupValidation.Validated, + ), + currentTourStep = currentStep, + isTourActive = tour.isActive, + forceOverflowOpen = tour.forceOverflowOpen, + gestureInputHidden = tour.gestureInputHidden, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FeatureTourUiState()) + + // -- Channel state updates from MainScreen -- + + fun onChannelsChanged( + empty: Boolean, + ready: Boolean, + ) { + _channelState.value = ChannelState(ready = ready, empty = empty) + } + + /** User already used the toolbar + icon, no need to show the hint. */ + fun onAddedChannelFromToolbar() { + if (_toolbarHintDone.value) return + _toolbarHintDone.value = true + viewModelScope.launch { + onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } + } + } + + fun onToolbarHintDismissed() { + if (_toolbarHintDone.value) return + _toolbarHintDone.value = true + viewModelScope.launch { + onboardingDataStore.update { it.copy(hasShownToolbarHint = true) } + } + } + + fun startTour() { + val tour = _tourState.value + if (tour.isActive || tour.completed) return + val settings = onboardingDataStore.current() + // Only resume persisted step if it belongs to the current tour (gap == 1). + // A larger gap means a prior tour was never completed and the step index is stale. + val stepIndex = + when { + CURRENT_TOUR_VERSION - settings.featureTourVersion == 1 -> settings.featureTourStep.coerceIn(0, TourStep.entries.size - 1) + else -> 0 + } + val step = TourStep.entries[stepIndex] + _tourState.value = + TourInternalState( + isActive = true, + stepIndex = stepIndex, + forceOverflowOpen = step == TourStep.ConfigureActions, + gestureInputHidden = step == TourStep.RecoveryFab, + ) + showTooltipForStep(step) + } + + fun advance() { + val tour = _tourState.value + if (!tour.isActive) return + val currentStep = TourStep.entries.getOrNull(tour.stepIndex) ?: return + + // Skip dismiss for ConfigureActions — its tooltip is inside the menu popup, + // so removing the composable (via step change) handles cleanup. + // Explicit dismiss() causes a popup exit animation that flashes. + if (currentStep != TourStep.ConfigureActions) { + tooltipStateForStep(currentStep).dismiss() + } + + val nextIndex = tour.stepIndex + 1 + val nextStep = TourStep.entries.getOrNull(nextIndex) + when { + nextStep == null -> { + completeTour() + } + + else -> { + viewModelScope.launch { + onboardingDataStore.update { it.copy(featureTourStep = nextIndex) } + } + _tourState.update { + it.copy( + stepIndex = nextIndex, + forceOverflowOpen = nextStep == TourStep.ConfigureActions, + gestureInputHidden = nextStep == TourStep.RecoveryFab, + ) + } + // Menu close animation takes ~150ms; show the next tooltip after it finishes + if (currentStep == TourStep.ConfigureActions) { + viewModelScope.launch { + delay(250) + showTooltipForStep(nextStep) + } + } else { + showTooltipForStep(nextStep) + } + } + } + } + + fun skipTour() { + val tour = _tourState.value + if (tour.isActive) { + val currentStep = TourStep.entries.getOrNull(tour.stepIndex) + currentStep?.let { tooltipStateForStep(it).dismiss() } + } + completeTour() + } + + private fun completeTour() { + _tourState.value = TourInternalState(completed = true) + _toolbarHintDone.value = true + viewModelScope.launch { + onboardingDataStore.update { + it.copy( + featureTourVersion = CURRENT_TOUR_VERSION, + featureTourStep = 0, + hasShownToolbarHint = true, + ) + } + } + } + + private fun showTooltipForStep(step: TourStep) { + viewModelScope.launch { tooltipStateForStep(step).show() } + } + + private fun tooltipStateForStep(step: TourStep): TooltipState = when (step) { + TourStep.InputActions -> inputActionsTooltipState + TourStep.OverflowMenu -> overflowMenuTooltipState + TourStep.ConfigureActions -> configureActionsTooltipState + TourStep.SwipeGesture -> swipeGestureTooltipState + TourStep.RecoveryFab -> recoveryFabTooltipState + } + + private fun resolvePostOnboardingStep( + settings: OnboardingSettings, + channelReady: Boolean, + channelEmpty: Boolean, + toolbarHintDone: Boolean, + tourActive: Boolean, + tourCompleted: Boolean, + authValidated: Boolean, + ): PostOnboardingStep = when { + tourCompleted -> PostOnboardingStep.Complete + + settings.featureTourVersion >= CURRENT_TOUR_VERSION && toolbarHintDone -> PostOnboardingStep.Complete + + !settings.hasCompletedOnboarding -> PostOnboardingStep.Idle + + !authValidated -> PostOnboardingStep.Idle + + !channelReady -> PostOnboardingStep.Idle + + channelEmpty -> PostOnboardingStep.Idle + + tourActive -> PostOnboardingStep.FeatureTour + + !toolbarHintDone -> PostOnboardingStep.ToolbarPlusHint + + // At this point: toolbarHintDone=true but (version >= CURRENT && toolbarHintDone) was false, + // so featureTourVersion < CURRENT_TOUR_VERSION is guaranteed. + else -> PostOnboardingStep.FeatureTour + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt index 4347897ea..86b13be2f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/AppLifecycleListener.kt @@ -8,9 +8,12 @@ import kotlinx.coroutines.flow.asStateFlow import org.koin.core.annotation.Single @Single -class AppLifecycleListener(val app: Application) { +class AppLifecycleListener( + val app: Application, +) { sealed interface AppLifecycle { data object Background : AppLifecycle + data object Foreground : AppLifecycle } @@ -21,7 +24,9 @@ class AppLifecycleListener(val app: Application) { app.registerActivityLifecycleCallbacks(LifecycleCallback { _appState.value = it }) } - private class LifecycleCallback(private val action: (AppLifecycle) -> Unit) : Application.ActivityLifecycleCallbacks { + private class LifecycleCallback( + private val action: (AppLifecycle) -> Unit, + ) : Application.ActivityLifecycleCallbacks { var currentForegroundActivity: Activity? = null override fun onActivityPaused(activity: Activity) { @@ -37,9 +42,19 @@ class AppLifecycleListener(val app: Application) { } override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) = Unit + override fun onActivityStopped(activity: Activity) = Unit - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) = Unit } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt index 0f5b2f5e3..9d1fc0c66 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/DateTimeUtils.kt @@ -7,7 +7,10 @@ import java.time.format.DateTimeFormatter import kotlin.time.Duration.Companion.seconds object DateTimeUtils { - fun timestampToLocalTime(ts: Long, formatter: DateTimeFormatter): String = Instant + fun timestampToLocalTime( + ts: Long, + formatter: DateTimeFormatter, + ): String = Instant .ofEpochMilli(ts) .atZone(ZoneId.systemDefault()) .format(formatter) @@ -65,26 +68,55 @@ object DateTimeUtils { } private fun secondsMultiplierForUnit(char: Char): Int? = when (char) { - 's' -> 1 - 'm' -> 60 - 'h' -> 60 * 60 - 'd' -> 60 * 60 * 24 - 'w' -> 60 * 60 * 24 * 7 + 's' -> 1 + 'm' -> 60 + 'h' -> 60 * 60 + 'd' -> 60 * 60 * 24 + 'w' -> 60 * 60 * 24 * 7 else -> null } + enum class DurationUnit { WEEKS, DAYS, HOURS, MINUTES, SECONDS } + + data class DurationPart( + val value: Int, + val unit: DurationUnit, + ) + + fun decomposeMinutes(totalMinutes: Int): List = buildList { + var remaining = totalMinutes + val weeks = remaining / 10080 + remaining %= 10080 + if (weeks > 0) add(DurationPart(weeks, DurationUnit.WEEKS)) + val days = remaining / 1440 + remaining %= 1440 + if (days > 0) add(DurationPart(days, DurationUnit.DAYS)) + val hours = remaining / 60 + remaining %= 60 + if (hours > 0) add(DurationPart(hours, DurationUnit.HOURS)) + if (remaining > 0) add(DurationPart(remaining, DurationUnit.MINUTES)) + } + + fun decomposeSeconds(totalSeconds: Int): List = buildList { + val mins = totalSeconds / 60 + val secs = totalSeconds % 60 + if (mins > 0) add(DurationPart(mins, DurationUnit.MINUTES)) + if (secs > 0) add(DurationPart(secs, DurationUnit.SECONDS)) + } + fun calculateUptime(startedAtString: String): String { val startedAt = Instant.parse(startedAtString).atZone(ZoneId.systemDefault()).toEpochSecond() val now = ZonedDateTime.now().toEpochSecond() val duration = now.seconds - startedAt.seconds - val uptime = duration.toComponents { days, hours, minutes, _, _ -> - buildString { - if (days > 0) append("${days}d ") - if (hours > 0) append("${hours}h ") - append("${minutes}m") + val uptime = + duration.toComponents { days, hours, minutes, _, _ -> + buildString { + if (days > 0) append("${days}d ") + if (hours > 0) append("${hours}h ") + append("${minutes}m") + } } - } return uptime } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/ErrorDialogUtil.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/ErrorDialogUtil.kt deleted file mode 100644 index 78321ac83..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/ErrorDialogUtil.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.flxrs.dankchat.utils - -import android.content.ClipData -import android.content.ClipboardManager -import android.util.Log -import android.view.View -import androidx.core.content.ContextCompat -import com.flxrs.dankchat.R -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar - -fun View.showErrorDialog(throwable: Throwable, stackTraceString: String = Log.getStackTraceString(throwable)) { - val title = context.getString(R.string.error_dialog_title, throwable.javaClass.name) - - MaterialAlertDialogBuilder(context) - .setTitle(title) - .setMessage("${throwable.message}\n$stackTraceString") - .setPositiveButton(R.string.error_dialog_copy) { d, _ -> - ContextCompat.getSystemService(context, ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText("error stacktrace", stackTraceString)) - Snackbar.make(rootView.findViewById(android.R.id.content), R.string.snackbar_error_copied, Snackbar.LENGTH_SHORT).show() - d.dismiss() - } - .setNegativeButton(R.string.dialog_dismiss) { d, _ -> d.dismiss() } - .show() -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt index 8ea6a684f..9b7031b4f 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/GetImageOrVideoContract.kt @@ -7,15 +7,19 @@ import android.net.Uri import androidx.activity.result.contract.ActivityResultContract class GetImageOrVideoContract : ActivityResultContract() { - override fun createIntent(context: Context, input: Unit): Intent { - return Intent(Intent.ACTION_GET_CONTENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("*/*") - .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) - } + override fun createIntent( + context: Context, + input: Unit, + ): Intent = Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*") + .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) - override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when { + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): Uri? = when { intent == null || resultCode != Activity.RESULT_OK -> null - else -> intent.data + else -> intent.data } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt index 9a2048c91..230e5bfad 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/IntRangeParceler.kt @@ -4,10 +4,12 @@ import android.os.Parcel import kotlinx.parcelize.Parceler object IntRangeParceler : Parceler { - override fun create(parcel: Parcel): IntRange = IntRange(parcel.readInt(), parcel.readInt()) - override fun IntRange.write(parcel: Parcel, flags: Int) { + override fun IntRange.write( + parcel: Parcel, + flags: Int, + ) { parcel.writeInt(first) parcel.writeInt(endInclusive) } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt index dc46e0abe..55af2be06 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/MediaUtils.kt @@ -5,47 +5,52 @@ import androidx.exifinterface.media.ExifInterface import java.io.File import java.io.IOException import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds -private val GPS_ATTRIBUTES = listOf( - ExifInterface.TAG_GPS_ALTITUDE, - ExifInterface.TAG_GPS_ALTITUDE_REF, - ExifInterface.TAG_GPS_AREA_INFORMATION, - ExifInterface.TAG_GPS_DATESTAMP, - ExifInterface.TAG_GPS_DEST_BEARING, - ExifInterface.TAG_GPS_DEST_BEARING_REF, - ExifInterface.TAG_GPS_DEST_DISTANCE, - ExifInterface.TAG_GPS_DEST_DISTANCE_REF, - ExifInterface.TAG_GPS_DEST_LATITUDE, - ExifInterface.TAG_GPS_DEST_LATITUDE_REF, - ExifInterface.TAG_GPS_DEST_LONGITUDE, - ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, - ExifInterface.TAG_GPS_DIFFERENTIAL, - ExifInterface.TAG_GPS_DOP, - ExifInterface.TAG_GPS_H_POSITIONING_ERROR, - ExifInterface.TAG_GPS_IMG_DIRECTION, - ExifInterface.TAG_GPS_IMG_DIRECTION_REF, - ExifInterface.TAG_GPS_LATITUDE, - ExifInterface.TAG_GPS_LATITUDE_REF, - ExifInterface.TAG_GPS_LONGITUDE, - ExifInterface.TAG_GPS_LONGITUDE_REF, - ExifInterface.TAG_GPS_MAP_DATUM, - ExifInterface.TAG_GPS_MEASURE_MODE, - ExifInterface.TAG_GPS_PROCESSING_METHOD, - ExifInterface.TAG_GPS_SATELLITES, - ExifInterface.TAG_GPS_SPEED, - ExifInterface.TAG_GPS_SPEED_REF, - ExifInterface.TAG_GPS_STATUS, - ExifInterface.TAG_GPS_TIMESTAMP, - ExifInterface.TAG_GPS_TRACK, - ExifInterface.TAG_GPS_TRACK_REF, - ExifInterface.TAG_GPS_VERSION_ID, -) +private val GPS_ATTRIBUTES = + listOf( + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_VERSION_ID, + ) @Throws(IOException::class) -fun createMediaFile(context: Context, suffix: String = "jpg"): File { +fun createMediaFile( + context: Context, + suffix: String = "jpg", +): File { val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val storageDir = context.getExternalFilesDir("Media") return File.createTempFile(timeStamp, ".$suffix", storageDir) @@ -53,7 +58,8 @@ fun createMediaFile(context: Context, suffix: String = "jpg"): File { fun tryClearEmptyFiles(context: Context) = runCatching { val cutoff = System.currentTimeMillis().milliseconds - 1.days - context.getExternalFilesDir("Media") + context + .getExternalFilesDir("Media") ?.listFiles() ?.filter { it.isFile && it.lastModified().milliseconds < cutoff } ?.onEach { it.delete() } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/MultiCallback.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/MultiCallback.kt deleted file mode 100644 index 360b07985..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/MultiCallback.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.flxrs.dankchat.utils - -import android.graphics.drawable.Drawable -import android.graphics.drawable.Drawable.Callback -import android.view.View -import java.lang.ref.WeakReference -import java.util.concurrent.CopyOnWriteArrayList - -class MultiCallback : Callback { - - private val callbacks = CopyOnWriteArrayList() - - override fun invalidateDrawable(who: Drawable) { - callbacks.forEach { reference -> - when (val callback = reference.get()) { - null -> callbacks.remove(reference) - else -> { - when (callback) { - is View -> callback.invalidate() - else -> callback.invalidateDrawable(who) - } - } - } - } - } - - override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { - callbacks.forEach { reference -> - when (val callback = reference.get()) { - null -> callbacks.remove(reference) - else -> callback.scheduleDrawable(who, what, `when`) - } - } - } - - override fun unscheduleDrawable(who: Drawable, what: Runnable) { - callbacks.forEach { reference -> - when (val callback = reference.get()) { - null -> callbacks.remove(reference) - else -> callback.unscheduleDrawable(who, what) - } - } - } - - fun addView(callback: Callback) { - callbacks.forEach { - val item = it.get() - if (item == null) { - callbacks.remove(it) - } - } - callbacks.addIfAbsent(CallbackReference(callback)) - } - - fun removeView(callback: Callback) { - callbacks.forEach { - val item = it.get() - if (item == null || item == callback) { - callbacks.remove(it) - } - } - } - - private data class CallbackReference(val callback: Callback?) : WeakReference(callback) -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt new file mode 100644 index 000000000..a13bdb194 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/TextResource.kt @@ -0,0 +1,60 @@ +package com.flxrs.dankchat.utils + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +sealed interface TextResource { + @Immutable + data class Plain( + val value: String, + ) : TextResource + + @Immutable + data class Res( + @param:StringRes val id: Int, + val args: ImmutableList = persistentListOf(), + ) : TextResource + + @Immutable + data class PluralRes( + @param:PluralsRes val id: Int, + val quantity: Int, + val args: ImmutableList = persistentListOf(), + ) : TextResource +} + +@Composable +fun TextResource.resolve(): String = when (this) { + is TextResource.Plain -> { + value + } + + is TextResource.Res -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg + } + } + stringResource(id, *resolvedArgs.toTypedArray()) + } + + is TextResource.PluralRes -> { + val resolvedArgs = + args.map { arg -> + when (arg) { + is TextResource -> arg.resolve() + else -> arg + } + } + pluralStringResource(id, quantity, *resolvedArgs.toTypedArray()) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt new file mode 100644 index 000000000..8d0c06f39 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/BottomSheetNestedScroll.kt @@ -0,0 +1,32 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity + +/** + * Consumes leftover fling velocity from child scrollables, preventing it from propagating + * to a parent [androidx.compose.material3.ModalBottomSheet] and prematurely dismissing it. + * + * Unlike consuming all post-scroll, this only intercepts fling overshoots while still allowing + * normal drag gestures to propagate — so the sheet can still be dismissed by dragging down + * when the content is scrolled to the top. + * + * Workaround for https://issuetracker.google.com/issues/353304855 + */ +object BottomSheetNestedScrollConnection : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = when (source) { + NestedScrollSource.SideEffect -> available.copy(x = 0f) + else -> Offset.Zero + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity, + ): Velocity = available.copy(x = 0f) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt new file mode 100644 index 000000000..7506fe792 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ConfirmationBottomSheet.kt @@ -0,0 +1,91 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flxrs.dankchat.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmationBottomSheet( + title: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + message: String? = null, + confirmText: String = stringResource(R.string.dialog_ok), + confirmColors: ButtonColors? = null, + dismissText: String = stringResource(R.string.dialog_cancel), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text(dismissText) + } + Spacer(modifier = Modifier.width(12.dp)) + Button( + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = confirmColors ?: ButtonDefaults.buttonColors(), + ) { + Text(confirmText) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt index c570ceacb..de40b09e3 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/ContentAlpha.kt @@ -37,9 +37,9 @@ object ContentAlpha { val high: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.high, - lowContrastAlpha = LowContrastContentAlpha.high + lowContrastAlpha = LowContrastContentAlpha.high, ) /** @@ -49,9 +49,9 @@ object ContentAlpha { val medium: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.medium, - lowContrastAlpha = LowContrastContentAlpha.medium + lowContrastAlpha = LowContrastContentAlpha.medium, ) /** @@ -61,9 +61,9 @@ object ContentAlpha { val disabled: Float @Composable get() = - contentAlpha( + resolveAlpha( highContrastAlpha = HighContrastContentAlpha.disabled, - lowContrastAlpha = LowContrastContentAlpha.disabled + lowContrastAlpha = LowContrastContentAlpha.disabled, ) /** @@ -75,9 +75,9 @@ object ContentAlpha { * for, and under what circumstances. */ @Composable - private fun contentAlpha( + private fun resolveAlpha( @FloatRange(from = 0.0, to = 1.0) highContrastAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float + @FloatRange(from = 0.0, to = 1.0) lowContrastAlpha: Float, ): Float { val contentColor = LocalContentColor.current val isDarkTheme = isSystemInDarkTheme() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt new file mode 100644 index 000000000..167f630d2 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InfoBottomSheet.kt @@ -0,0 +1,148 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.composables.core.SheetDetent +import com.flxrs.dankchat.R +import com.composables.core.rememberModalBottomSheetState as rememberUnstyledSheetState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InfoBottomSheet( + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismissText: String = stringResource(R.string.dialog_dismiss), + dismissible: Boolean = true, +) { + when { + dismissible -> { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + InfoSheetContent(title, message, confirmText, dismissText, onConfirm, onDismiss) + } + } + + else -> { + val sheetState = + rememberUnstyledSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), + ) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + sheetState.jumpTo(SheetDetent.FullyExpanded) + } + } + com.composables.core.ModalBottomSheet(state = sheetState) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + ) + Surface( + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .navigationBarsPadding(), + ) { + Box( + modifier = + Modifier + .padding(vertical = 12.dp) + .align(Alignment.CenterHorizontally) + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), + ) + InfoSheetContent(title, message, confirmText, dismissText, onConfirm, onDismiss) + } + } + } + } + } + } +} + +@Composable +private fun InfoSheetContent( + title: String, + message: String, + confirmText: String, + dismissText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + TextButton(onClick = onConfirm) { + Text(confirmText) + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt new file mode 100644 index 000000000..bcfb5362a --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/InputBottomSheet.kt @@ -0,0 +1,229 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.composables.core.DragIndication +import com.composables.core.ModalBottomSheet +import com.composables.core.Scrim +import com.composables.core.Sheet +import com.composables.core.SheetDetent +import com.composables.core.rememberModalBottomSheetState +import com.flxrs.dankchat.R +import java.util.concurrent.CancellationException + +@Composable +fun InputBottomSheet( + title: String, + hint: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + confirmText: String = stringResource(R.string.dialog_ok), + defaultValue: String = "", + keyboardType: KeyboardType = KeyboardType.Text, + showClearButton: Boolean = false, + validate: ((String) -> String?)? = null, +) { + var inputValue by remember { mutableStateOf(TextFieldValue(defaultValue, selection = TextRange(defaultValue.length))) } + val focusRequester = remember { FocusRequester() } + val trimmed = inputValue.text.trim() + val errorText = validate?.invoke(trimmed) + val isValid = trimmed.isNotBlank() && errorText == null + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + val sheetState = + rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded, + detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded), + ) + + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + + ModalBottomSheet( + state = sheetState, + onDismiss = onDismiss, + ) { + Scrim() + + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler { progress -> + try { + progress.collect { event -> + backProgress = event.progress + } + onDismiss() + } catch (_: CancellationException) { + backProgress = 0f + } + } + + val scale = 1f - (backProgress * 0.15f) + Sheet( + modifier = + Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationY = size.height * backProgress * 0.3f + alpha = 1f - (backProgress * 0.2f) + }.shadow(8.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding(), + ) { + // Dismiss on keyboard close + val density = LocalDensity.current + val current = WindowInsets.ime.getBottom(density) + val source = WindowInsets.imeAnimationSource.getBottom(density) + val target = WindowInsets.imeAnimationTarget.getBottom(density) + val isClosing = source > 0 && target == 0 + val nearlyDone = current < 200 + + LaunchedEffect(isClosing, nearlyDone) { + if (isClosing && nearlyDone) { + onDismiss() + } + } + + DragIndication( + modifier = + Modifier + .padding(top = 16.dp, bottom = 16.dp) + .align(Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + RoundedCornerShape(50), + ).size(width = 32.dp, height = 4.dp), + ) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 12.dp), + ) + + OutlinedTextField( + value = inputValue, + onValueChange = { inputValue = it }, + label = { Text(hint) }, + singleLine = true, + isError = errorText != null, + trailingIcon = + if (showClearButton && inputValue.text.isNotEmpty()) { + { + IconButton(onClick = { inputValue = TextFieldValue() }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear), + ) + } + } + } else { + null + }, + keyboardOptions = + KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions(onDone = { + if (isValid) { + onConfirm(trimmed) + } + }), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + + AnimatedVisibility( + visible = errorText != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Text( + text = errorText.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Button( + onClick = { onConfirm(trimmed) }, + enabled = isValid, + modifier = + Modifier + .align(Alignment.End) + .padding(top = 8.dp), + ) { + Text(confirmText) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt new file mode 100644 index 000000000..8f3d8cfcc --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/PredictiveBackModifier.kt @@ -0,0 +1,11 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer + +fun Modifier.predictiveBackScale(progress: Float): Modifier = graphicsLayer { + val scale = 1f - (progress * 0.1f) + scaleX = scale + scaleY = scale + alpha = 1f - (progress * 0.3f) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt new file mode 100644 index 000000000..720004dd1 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/RoundedCornerPadding.kt @@ -0,0 +1,258 @@ +package com.flxrs.dankchat.utils.compose + +import android.os.Build +import android.view.RoundedCorner +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import kotlin.math.max +import kotlin.math.sin + +/** + * Adds padding to avoid content being clipped by rounded display corners. + * + * This modifier: + * 1. Gets the component's position in window coordinates + * 2. Checks if the component intersects with any rounded corner boundaries + * 3. Adds padding only where needed to push content into the safe area + * + * Uses the 45-degree boundary method from Android documentation. + */ +@Suppress("ModifierComposed") // TODO: Replace with custom ModifierNodeElement +fun Modifier.avoidRoundedCorners(fallback: PaddingValues): Modifier = composed { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return@composed this.padding(fallback) + } + + val view = LocalView.current + val density = LocalDensity.current + val direction = LocalLayoutDirection.current + + var paddingStart by remember { mutableStateOf(fallback.calculateStartPadding(direction)) } + var paddingTop by remember { mutableStateOf(0.dp) } + var paddingEnd by remember { mutableStateOf(fallback.calculateEndPadding(direction)) } + var paddingBottom by remember { mutableStateOf(0.dp) } + + this + .onGloballyPositioned { coordinates -> + val compatInsets = ViewCompat.getRootWindowInsets(view) ?: return@onGloballyPositioned + val windowInsets = compatInsets.toWindowInsets() ?: return@onGloballyPositioned + + // Get component position and size in window coordinates + val position = coordinates.positionInWindow() + val componentLeft = position.x.toInt() + val componentTop = position.y.toInt() + val componentRight = componentLeft + coordinates.size.width + val componentBottom = componentTop + coordinates.size.height + + // Check all four corners + val topLeft = windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT) + val topRight = windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT) + val bottomLeft = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) + val bottomRight = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) + + // Calculate padding for each side + paddingTop = + with(density) { + maxOf( + topLeft?.calculateTopPaddingForComponent(componentLeft, componentTop) ?: 0, + topRight?.calculateTopPaddingForComponent(componentRight, componentTop) ?: 0, + ).toDp() + } + + paddingBottom = + with(density) { + maxOf( + bottomLeft?.calculateBottomPaddingForComponent(componentLeft, componentBottom) ?: 0, + bottomRight?.calculateBottomPaddingForComponent(componentRight, componentBottom) ?: 0, + ).toDp() + } + + paddingStart = + with(density) { + maxOf( + topLeft?.calculateStartPaddingForComponent(componentLeft, componentTop) ?: 0, + bottomLeft?.calculateStartPaddingForComponent(componentLeft, componentBottom) ?: 0, + ).toDp() + } + + paddingEnd = + with(density) { + maxOf( + topRight?.calculateEndPaddingForComponent(componentRight, componentTop) ?: 0, + bottomRight?.calculateEndPaddingForComponent(componentRight, componentBottom) ?: 0, + ).toDp() + } + }.padding( + start = paddingStart, + top = paddingTop, + end = paddingEnd, + bottom = paddingBottom, + ) +} + +/** + * Returns the bottom padding needed to avoid rounded display corners. + * Uses a 25-degree boundary — a practical middle ground between the strict 45-degree + * safe line (~29% of radius) and the full radius (100%). Gives ~58% of the radius, + * keeping content comfortably clear of rounded corners without excessive spacing. + * + * On API < 31 returns [fallback]. + */ +@Composable +fun rememberRoundedCornerBottomPadding(fallback: Dp = 0.dp): Dp { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return fallback + } + + val view = LocalView.current + val density = LocalDensity.current + val compatInsets = + ViewCompat.getRootWindowInsets(view) + ?: return fallback + val windowInsets = + compatInsets.toWindowInsets() + ?: return fallback + + val bottomLeft = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) + val bottomRight = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) + val screenHeight = view.rootView.height + val safePadding = + maxOf( + bottomLeft?.safeBottomPadding(screenHeight) ?: 0, + bottomRight?.safeBottomPadding(screenHeight) ?: 0, + ) + if (safePadding == 0) return fallback + + return with(density) { safePadding.toDp() } +} + +@RequiresApi(api = 31) +private fun RoundedCorner.safeBottomPadding(screenHeight: Int): Int { + val offset = (radius * sin(Math.toRadians(25.0))).toInt() + val safeBottom = center.y + offset + return max(0, screenHeight - safeBottom) +} + +/** + * Returns horizontal padding needed to avoid the bottom rounded display corners. + * Uses [RoundedCorner.center] to determine where content is safe, matching + * the approach in MainFragment for fullscreenHintText. + * + * On API < 31 or when no rounded corners are present, returns [fallback]. + */ +@Composable +fun rememberRoundedCornerHorizontalPadding(fallback: Dp = 0.dp): PaddingValues { + val fallbackPadding = PaddingValues(horizontal = fallback) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return fallbackPadding + } + + val view = LocalView.current + val density = LocalDensity.current + val compatInsets = + ViewCompat.getRootWindowInsets(view) + ?: return fallbackPadding + val windowInsets = + compatInsets.toWindowInsets() + ?: return fallbackPadding + + val bottomLeft = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT) + val bottomRight = windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) + if (bottomLeft == null || bottomRight == null) { + return fallbackPadding + } + + val screenWidth = view.rootView.width + if (screenWidth <= 0) return fallbackPadding + val start = with(density) { bottomLeft.center.x.toDp() } + val end = with(density) { (screenWidth - bottomRight.center.x).toDp() } + if (start < 0.dp || end < 0.dp) return fallbackPadding + + return PaddingValues(start = start, end = end) +} + +@RequiresApi(api = 31) +private fun RoundedCorner.calculateTopPaddingForComponent( + componentX: Int, + componentTop: Int, +): Int { + val offset = (radius * sin(Math.toRadians(45.0))).toInt() + val topBoundary = center.y - offset + val leftBoundary = center.x - offset + val rightBoundary = center.x + offset + + if (componentX !in leftBoundary..rightBoundary) { + return 0 + } + + return max(0, topBoundary - componentTop) +} + +@RequiresApi(api = 31) +private fun RoundedCorner.calculateBottomPaddingForComponent( + componentX: Int, + componentBottom: Int, +): Int { + val offset = (radius * sin(Math.toRadians(45.0))).toInt() + val bottomBoundary = center.y + offset + val leftBoundary = center.x - offset + val rightBoundary = center.x + offset + + if (componentX !in leftBoundary..rightBoundary) { + return 0 + } + + return max(0, componentBottom - bottomBoundary) +} + +@RequiresApi(api = 31) +private fun RoundedCorner.calculateStartPaddingForComponent( + componentLeft: Int, + componentY: Int, +): Int { + val offset = (radius * sin(Math.toRadians(45.0))).toInt() + val leftBoundary = center.x - offset + val topBoundary = center.y - offset + val bottomBoundary = center.y + offset + + if (componentY !in topBoundary..bottomBoundary) { + return 0 + } + + return max(0, leftBoundary - componentLeft) +} + +@RequiresApi(api = 31) +private fun RoundedCorner.calculateEndPaddingForComponent( + componentRight: Int, + componentY: Int, +): Int { + val offset = (radius * sin(Math.toRadians(45.0))).toInt() + val rightBoundary = center.x + offset + val topBoundary = center.y - offset + val bottomBoundary = center.y + offset + + if (componentY !in topBoundary..bottomBoundary) { + return 0 + } + + return max(0, componentRight - rightBoundary) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StartAlignedTooltipPositionProvider.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StartAlignedTooltipPositionProvider.kt new file mode 100644 index 000000000..69535ed14 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/StartAlignedTooltipPositionProvider.kt @@ -0,0 +1,44 @@ +package com.flxrs.dankchat.utils.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider + +@Composable +fun rememberStartAlignedTooltipPositionProvider(spacingBetweenTooltipAndAnchor: Dp = 4.dp): PopupPositionProvider { + val spacingPx = with(LocalDensity.current) { spacingBetweenTooltipAndAnchor.roundToPx() } + return remember(spacingPx) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val startX = anchorBounds.left - popupContentSize.width - spacingPx + return if (startX >= 0) { + val y = anchorBounds.top + (anchorBounds.height - popupContentSize.height) / 2 + IntOffset( + startX, + y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), + ) + } else { + val x = + (anchorBounds.right - popupContentSize.width) + .coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + val y = + (anchorBounds.top - popupContentSize.height - spacingPx) + .coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)) + IntOffset(x, y) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/SwipeToDelete.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/SwipeToDelete.kt index d9dcb8f66..d0d299e29 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/SwipeToDelete.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/SwipeToDelete.kt @@ -26,14 +26,20 @@ import androidx.compose.ui.unit.dp import com.flxrs.dankchat.R @Composable -fun SwipeToDelete(onDelete: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) { +fun SwipeToDelete( + onDelete: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { val density = LocalDensity.current - val state = remember { - SwipeToDismissBoxState( - positionalThreshold = { with(density) { 84.dp.toPx() } }, - initialValue = SwipeToDismissBoxValue.Settled, - ) - } + val state = + remember { + SwipeToDismissBoxState( + positionalThreshold = { with(density) { 84.dp.toPx() } }, + initialValue = SwipeToDismissBoxValue.Settled, + ) + } SwipeToDismissBox( gesturesEnabled = enabled, enableDismissFromEndToStart = enabled, @@ -43,35 +49,45 @@ fun SwipeToDelete(onDelete: () -> Unit, modifier: Modifier = Modifier, enabled: onDismiss = { onDelete() }, backgroundContent = { val color by animateColorAsState( - targetValue = when (state.dismissDirection) { - SwipeToDismissBoxValue.StartToEnd, SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer - SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainer - } + targetValue = + when (state.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd, SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.surfaceContainer + }, ) Box( - modifier = Modifier - .fillMaxSize() - .background(color, CardDefaults.outlinedShape) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .background(color, CardDefaults.outlinedShape) + .padding(horizontal = 16.dp), ) { when (state.dismissDirection) { - SwipeToDismissBoxValue.StartToEnd -> Icon( - imageVector = Icons.Default.Delete, - modifier = Modifier - .align(Alignment.CenterStart) - .size(32.dp), - contentDescription = stringResource(R.string.remove_command), - ) + SwipeToDismissBoxValue.StartToEnd -> { + Icon( + imageVector = Icons.Default.Delete, + modifier = + Modifier + .align(Alignment.CenterStart) + .size(32.dp), + contentDescription = stringResource(R.string.remove_command), + ) + } - SwipeToDismissBoxValue.EndToStart -> Icon( - imageVector = Icons.Default.Delete, - modifier = Modifier - .align(Alignment.CenterEnd) - .size(32.dp), - contentDescription = stringResource(R.string.remove_command), - ) + SwipeToDismissBoxValue.EndToStart -> { + Icon( + imageVector = Icons.Default.Delete, + modifier = + Modifier + .align(Alignment.CenterEnd) + .size(32.dp), + contentDescription = stringResource(R.string.remove_command), + ) + } - SwipeToDismissBoxValue.Settled -> Unit + SwipeToDismissBoxValue.Settled -> { + Unit + } } } }, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/animatedAppBarColor.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/animatedAppBarColor.kt index 279315d85..3afa1da73 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/animatedAppBarColor.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/animatedAppBarColor.kt @@ -22,7 +22,7 @@ fun animatedAppBarColor(scrollBehavior: TopAppBarScrollBehavior): State { lerp( colors.containerColor, colors.scrolledContainerColor, - FastOutLinearInEasing.transform(if (overlappingFraction > 0.01f) 1f else 0f) + FastOutLinearInEasing.transform(if (overlappingFraction > 0.01f) 1f else 0f), ) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/buildLinkAnnotation.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/buildLinkAnnotation.kt index 8eb1f994e..ce69d009e 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/buildLinkAnnotation.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/compose/buildLinkAnnotation.kt @@ -9,19 +9,19 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextDecoration @Composable -fun textLinkStyles(): TextLinkStyles { - return TextLinkStyles( - style = SpanStyle( +fun textLinkStyles(): TextLinkStyles = TextLinkStyles( + style = + SpanStyle( color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline, ), - pressedStyle = SpanStyle( + pressedStyle = + SpanStyle( color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline, background = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.medium), ), - ) -} +) @Composable fun buildLinkAnnotation(url: String): LinkAnnotation = LinkAnnotation.Url( @@ -30,7 +30,10 @@ fun buildLinkAnnotation(url: String): LinkAnnotation = LinkAnnotation.Url( ) @Composable -fun buildClickableAnnotation(text: String, onClick: LinkInteractionListener): LinkAnnotation = LinkAnnotation.Clickable( +fun buildClickableAnnotation( + text: String, + onClick: LinkInteractionListener, +): LinkAnnotation = LinkAnnotation.Clickable( tag = text, styles = textLinkStyles(), linkInteractionListener = onClick, diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreKotlinxSerializer.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreKotlinxSerializer.kt index 4814729da..2618ed967 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreKotlinxSerializer.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreKotlinxSerializer.kt @@ -14,19 +14,22 @@ class DataStoreKotlinxSerializer( private val serializer: KSerializer, private val customSerializersModule: SerializersModule? = null, ) : OkioSerializer { - - private val json = Json { - ignoreUnknownKeys = true - customSerializersModule?.let { - serializersModule = it + private val json = + Json { + ignoreUnknownKeys = true + customSerializersModule?.let { + serializersModule = it + } } - } override suspend fun readFrom(source: BufferedSource): T = runCatching { json.decodeFromBufferedSource(serializer, source) }.getOrDefault(defaultValue) - override suspend fun writeTo(t: T, sink: BufferedSink) { + override suspend fun writeTo( + t: T, + sink: BufferedSink, + ) { json.encodeToBufferedSink(serializer, t, sink) } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreUtils.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreUtils.kt index 7ac0205cd..fb79f4c66 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreUtils.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/DataStoreUtils.kt @@ -21,14 +21,21 @@ fun createDataStore( scope: CoroutineScope, migrations: List> = emptyList(), ) = DataStoreFactory.create( - storage = OkioStorage( - fileSystem = FileSystem.SYSTEM, - serializer = DataStoreKotlinxSerializer( - defaultValue = defaultValue, - serializer = serializer, + storage = + OkioStorage( + fileSystem = FileSystem.SYSTEM, + serializer = + DataStoreKotlinxSerializer( + defaultValue = defaultValue, + serializer = serializer, + ), + producePath = { + context.filesDir + .resolve(fileName) + .absolutePath + .toPath() + }, ), - producePath = { context.filesDir.resolve(fileName).absolutePath.toPath() }, - ), scope = scope, migrations = migrations, ) @@ -36,6 +43,6 @@ fun createDataStore( inline fun DataStore.safeData(defaultValue: T): Flow = data.catch { e -> when (e) { is IOException -> emit(defaultValue) - else -> throw e + else -> throw e } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/Migration.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/Migration.kt index 123c2f5c7..c21584a65 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/Migration.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/datastore/Migration.kt @@ -31,6 +31,7 @@ inline fun dankChatMigration( crossinline migrateValue: suspend (currentData: T, key: K, value: Any?) -> T, ): DataMigration where K : Enum = object : DataMigration { val map = enumEntries().associateBy(keyMapper) + override suspend fun migrate(currentData: T): T { return runCatching { prefs.all.filterKeys { it in map.keys }.entries.fold(currentData) { acc, (key, value) -> @@ -41,24 +42,38 @@ inline fun dankChatMigration( } override suspend fun shouldMigrate(currentData: T): Boolean = map.keys.any(prefs::contains) + override suspend fun cleanUp() = prefs.edit { map.keys.forEach(::remove) } } fun Any?.booleanOrNull() = this as? Boolean + fun Any?.booleanOrDefault(default: Boolean) = this as? Boolean ?: default + fun Any?.intOrDefault(default: Int) = this as? Int ?: default + fun Any?.intOrNull() = this as? Int fun Any?.stringOrNull() = this as? String + fun Any?.stringOrDefault(default: String) = this as? String ?: default -fun > Any?.mappedStringOrDefault(original: Array, enumEntries: EnumEntries, default: T): T { - return stringOrNull()?.let { enumEntries.getOrNull(original.indexOf(it)) } ?: default -} + +fun > Any?.mappedStringOrDefault( + original: Array, + enumEntries: EnumEntries, + default: T, +): T = stringOrNull()?.let { enumEntries.getOrNull(original.indexOf(it)) } ?: default @Suppress("UNCHECKED_CAST") fun Any?.stringSetOrNull() = this as? Set + @Suppress("UNCHECKED_CAST") fun Any?.stringSetOrDefault(default: Set) = this as? Set ?: default -fun > Any?.mappedStringSetOrDefault(original: Array, enumEntries: EnumEntries, default: List): List { - return stringSetOrNull()?.toList()?.mapNotNull { enumEntries.getOrNull(original.indexOf(it)) } ?: default -} + +fun > Any?.mappedStringSetOrDefault( + original: Array, + enumEntries: EnumEntries, + default: List, +): List = stringSetOrNull()?.toList()?.mapNotNull { + enumEntries.getOrNull(original.indexOf(it)) +} ?: default diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt new file mode 100644 index 000000000..c141bf28f --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ActivityExtensions.kt @@ -0,0 +1,16 @@ +package com.flxrs.dankchat.utils.extensions + +import android.os.Build +import android.view.WindowManager +import androidx.activity.ComponentActivity + +fun ComponentActivity.keepScreenOn(keep: Boolean) { + if (keep) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } +} + +val ComponentActivity.isInSupportedPictureInPictureMode: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isInPictureInPictureMode diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt deleted file mode 100644 index a3c6cc00b..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/BottomSheetExtensions.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.flxrs.dankchat.utils.extensions - -import android.view.View -import com.google.android.material.bottomsheet.BottomSheetBehavior -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -fun BottomSheetBehavior.expand() { - this.state = BottomSheetBehavior.STATE_EXPANDED -} - -fun BottomSheetBehavior.hide() { - this.state = BottomSheetBehavior.STATE_HIDDEN -} - -inline val BottomSheetBehavior.isVisible: Boolean - get() = this.state == BottomSheetBehavior.STATE_EXPANDED || this.state == BottomSheetBehavior.STATE_COLLAPSED - -inline val BottomSheetBehavior.isCollapsed: Boolean - get() = this.state == BottomSheetBehavior.STATE_COLLAPSED - -inline val BottomSheetBehavior.isHidden: Boolean - get() = this.state == BottomSheetBehavior.STATE_HIDDEN - -inline val BottomSheetBehavior.isMoving: Boolean - get() = this.state == BottomSheetBehavior.STATE_DRAGGING || this.state == BottomSheetBehavior.STATE_SETTLING - -suspend fun BottomSheetBehavior.awaitState(targetState: Int) { - if (state == targetState) { - return - } - - return suspendCancellableCoroutine { - val callback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == targetState) { - removeBottomSheetCallback(this) - it.resume(Unit) - } - } - } - addBottomSheetCallback(callback) - it.invokeOnCancellation { removeBottomSheetCallback(callback) } - state = targetState - } -} - diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatItemExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatItemExtensions.kt deleted file mode 100644 index fd7182b37..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatItemExtensions.kt +++ /dev/null @@ -1,167 +0,0 @@ -package com.flxrs.dankchat.utils.extensions - -import com.flxrs.dankchat.chat.ChatImportance -import com.flxrs.dankchat.chat.ChatItem -import com.flxrs.dankchat.data.twitch.message.ModerationMessage -import com.flxrs.dankchat.data.twitch.message.PrivMessage -import com.flxrs.dankchat.data.twitch.message.SystemMessage -import com.flxrs.dankchat.data.twitch.message.SystemMessageType -import com.flxrs.dankchat.data.twitch.message.toChatItem -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -fun MutableList.replaceOrAddHistoryModerationMessage(moderationMessage: ModerationMessage) { - if (!moderationMessage.canClearMessages) { - return - } - - if (checkForStackedTimeouts(moderationMessage)) { - add(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM)) - } -} - -fun List.replaceOrAddModerationMessage(moderationMessage: ModerationMessage, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit): List = toMutableList().apply { - if (!moderationMessage.canClearMessages) { - return addAndLimit(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) - } - - val addSystemMessage = checkForStackedTimeouts(moderationMessage) - for (idx in indices) { - val item = this[idx] - when (moderationMessage.action) { - ModerationMessage.Action.Clear -> { - this[idx] = when (item.message) { - is PrivMessage -> item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) - else -> item.copy(tag = item.tag + 1, importance = ChatImportance.DELETED) - } - } - - ModerationMessage.Action.Timeout, - ModerationMessage.Action.Ban, - ModerationMessage.Action.SharedTimeout, - ModerationMessage.Action.SharedBan -> { - item.message as? PrivMessage ?: continue - if (moderationMessage.targetUser != item.message.name) { - continue - } - - this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) - } - - else -> continue - } - } - - return when { - addSystemMessage -> addAndLimit(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) - else -> this - } -} - -fun List.replaceWithTimeout(moderationMessage: ModerationMessage, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit): List = toMutableList().apply { - val targetMsgId = moderationMessage.targetMsgId ?: return@apply - if (moderationMessage.fromEventSource) { - val end = (lastIndex - 20).coerceAtLeast(0) - for (idx in lastIndex downTo end) { - val item = this[idx] - val message = item.message as? ModerationMessage ?: continue - if ((message.action == ModerationMessage.Action.Delete || message.action == ModerationMessage.Action.SharedDelete) && message.targetMsgId == targetMsgId && !message.fromEventSource) { - this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) - return@apply - } - } - } - - for (idx in indices) { - val item = this[idx] - if (item.message is PrivMessage && item.message.id == targetMsgId) { - this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) - break - } - } - return addAndLimit(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) -} - -fun List.addAndLimit(item: ChatItem, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit): List = toMutableList().apply { - add(item) - while (size > scrollBackLength) { - onMessageRemoved(removeAt(index = 0)) - } -} - -fun List.addAndLimit( - items: Collection, - scrollBackLength: Int, - onMessageRemoved: (ChatItem) -> Unit, - checkForDuplications: Boolean = false -): List = when { - checkForDuplications -> plus(items) - .distinctBy { it.message.id } - .sortedBy { it.message.timestamp } - .also { - it - .take((it.size - scrollBackLength).coerceAtLeast(minimumValue = 0)) - .forEach(onMessageRemoved) - } - .takeLast(scrollBackLength) - - else -> toMutableList().apply { - addAll(items) - while (size > scrollBackLength) { - onMessageRemoved(removeAt(index = 0)) - } - } -} - -fun List.addSystemMessage(type: SystemMessageType, scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit, onReconnect: () -> Unit = {}): List { - return when { - type != SystemMessageType.Connected -> addAndLimit(type.toChatItem(), scrollBackLength, onMessageRemoved) - else -> replaceLastSystemMessageIfNecessary(scrollBackLength, onMessageRemoved, onReconnect) - } -} - -fun List.replaceLastSystemMessageIfNecessary(scrollBackLength: Int, onMessageRemoved: (ChatItem) -> Unit, onReconnect: () -> Unit): List { - val item = lastOrNull() - val message = item?.message - return when ((message as? SystemMessage)?.type) { - SystemMessageType.Disconnected -> { - onReconnect() - dropLast(1) + item.copy(message = SystemMessage(SystemMessageType.Reconnected)) - } - - is SystemMessageType.ChannelNonExistent -> dropLast(1) + SystemMessageType.Connected.toChatItem() - else -> addAndLimit(SystemMessageType.Connected.toChatItem(), scrollBackLength, onMessageRemoved) - } -} - -private fun MutableList.checkForStackedTimeouts(moderationMessage: ModerationMessage): Boolean { - if (moderationMessage.canStack) { - val end = (lastIndex - 20).coerceAtLeast(0) - for (idx in lastIndex downTo end) { - val item = this[idx] - val message = item.message as? ModerationMessage ?: continue - if (message.targetUser != moderationMessage.targetUser || message.action != moderationMessage.action) { - continue - } - - if ((moderationMessage.timestamp - message.timestamp).milliseconds >= 5.seconds) { - return true - } - - when { - !moderationMessage.fromEventSource && message.fromEventSource -> Unit - moderationMessage.fromEventSource && !message.fromEventSource -> { - this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) - } - - moderationMessage.action == ModerationMessage.Action.Timeout || moderationMessage.action == ModerationMessage.Action.SharedTimeout -> { - val stackedMessage = moderationMessage.copy(stackCount = message.stackCount + 1) - this[idx] = item.copy(tag = item.tag + 1, message = stackedMessage) - } - } - return false - } - } - - return true -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt new file mode 100644 index 000000000..1ec338556 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ChatListOperations.kt @@ -0,0 +1,63 @@ +package com.flxrs.dankchat.utils.extensions + +import com.flxrs.dankchat.data.chat.ChatItem + +fun List.addAndLimit( + item: ChatItem, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, +): List = toMutableList().apply { + add(item) + while (size > scrollBackLength) { + onMessageRemoved(removeAt(index = 0)) + } +} + +fun List.addAndLimit( + items: Collection, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, + checkForDuplications: Boolean = false, +): List = when { + checkForDuplications -> { + // Single-pass dedup via LinkedHashMap, then sort and trim. + // putIfAbsent keeps existing (live) messages over history duplicates. + val deduped = LinkedHashMap(size + items.size) + for (item in this) { + deduped[item.message.id] = item + } + for (item in items) { + deduped.putIfAbsent(item.message.id, item) + } + val sorted = deduped.values.sortedBy { it.message.timestamp } + val excess = (sorted.size - scrollBackLength).coerceAtLeast(0) + for (i in 0 until excess) { + onMessageRemoved(sorted[i]) + } + when { + excess > 0 -> sorted.subList(excess, sorted.size) + else -> sorted + } + } + + else -> { + toMutableList().apply { + addAll(items) + while (size > scrollBackLength) { + onMessageRemoved(removeAt(index = 0)) + } + } + } +} + +/** Adds an item and trims the list inline. For use inside `toMutableList().apply { }` blocks to avoid a second mutable copy. */ +internal fun MutableList.addAndTrimInline( + item: ChatItem, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, +) { + add(item) + while (size > scrollBackLength) { + onMessageRemoved(removeAt(index = 0)) + } +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CollectionExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CollectionExtensions.kt index 62aff6cc8..bf051a4c8 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CollectionExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CollectionExtensions.kt @@ -7,7 +7,10 @@ fun MutableCollection.replaceAll(values: Collection) { addAll(values) } -fun MutableList.swap(i: Int, j: Int) = Collections.swap(this, i, j) +fun MutableList.swap( + i: Int, + j: Int, +) = Collections.swap(this, i, j) inline fun Collection

.partitionIsInstance(): Pair, List

> { val first = mutableListOf() @@ -21,11 +24,16 @@ inline fun Collection

.partitionIsInstance(): Pair, return Pair(first, second) } -inline fun Collection.replaceIf(replacement: T, predicate: (T) -> Boolean): List { - return map { if (predicate(it)) replacement else it } -} +inline fun Collection.replaceIf( + replacement: T, + predicate: (T) -> Boolean, +): List = map { if (predicate(it)) replacement else it } -inline fun List.chunkedBy(maxSize: Int, selector: (T) -> Int): List> { +@Suppress("DoubleMutabilityForCollection") +inline fun List.chunkedBy( + maxSize: Int, + selector: (T) -> Int, +): List> { val result = mutableListOf>() var currentChunk = mutableListOf() var currentChunkSize = 0 diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ColorExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ColorExtensions.kt index 11e8e0d15..9f0c22b80 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ColorExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ColorExtensions.kt @@ -2,44 +2,112 @@ package com.flxrs.dankchat.utils.extensions import androidx.annotation.ColorInt import androidx.core.graphics.ColorUtils -import com.google.android.material.color.MaterialColors import kotlin.math.sin +/** + * Adjusts this color to ensure readable contrast against [background]. + * + * Two-phase approach matching Chatterino2's color pipeline: + * 1. Hue-specific HSL correction (clamp lightness, adjust greens on light / blues on dark) + * 2. Contrast-based binary search (3.5:1 target) for any remaining contrast issues + */ @ColorInt -fun Int.normalizeColor(@ColorInt background: Int): Int { +fun Int.normalizeColor( + @ColorInt background: Int, +): Int { + // Phase 1: hue-specific correction (matches C2 / old DankChat) + val opaqueColor = correctColor(this or 0xFF000000.toInt(), background or 0xFF000000.toInt()) + val opaqueBackground = background or 0xFF000000.toInt() + val contrast = ColorUtils.calculateContrast(opaqueColor, opaqueBackground) + if (contrast >= MIN_CONTRAST_RATIO) return opaqueColor - val isLightBackground = MaterialColors.isColorLight(background) val hsl = FloatArray(3) - ColorUtils.colorToHSL(this, hsl) - val huePercentage = hsl[0] / 360f + ColorUtils.colorToHSL(opaqueColor, hsl) - return when { + val bgLuminance = ColorUtils.calculateLuminance(opaqueBackground) + // On dark backgrounds, increase lightness; on light backgrounds, decrease it + val shouldLighten = bgLuminance < 0.5 + + // Binary search for the minimum lightness adjustment that meets contrast. + var low: Float + var high: Float + if (shouldLighten) { + low = hsl[2] // original lightness + high = 1f // max lightness + } else { + low = 0f // min lightness + high = hsl[2] // original lightness + } + + var bestL = hsl[2] + var bestContrast = contrast + + repeat(MAX_ITERATIONS) { + val mid = (low + high) / 2f + hsl[2] = mid + val candidate = ColorUtils.HSLToColor(hsl) + val candidateContrast = ColorUtils.calculateContrast(candidate, opaqueBackground) + + if (candidateContrast >= MIN_CONTRAST_RATIO) { + bestL = mid + bestContrast = candidateContrast + // Try closer to original (less adjustment) + if (shouldLighten) high = mid else low = mid + } else { + if (candidateContrast > bestContrast) { + bestL = mid + bestContrast = candidateContrast + } + // Need more adjustment (further from original) + if (shouldLighten) low = mid else high = mid + } + } + + hsl[2] = bestL + return ColorUtils.HSLToColor(hsl) +} + +/** + * Hue-specific HSL lightness correction matching Chatterino2's algorithm. + * On light backgrounds: clamps lightness to 0.5, darkens greens (hue 0.1–0.33). + * On dark backgrounds: clamps lightness to 0.5, lightens blues (hue 0.54–0.83). + */ +@ColorInt +private fun correctColor( + @ColorInt color: Int, + @ColorInt background: Int, +): Int { + val isLightBackground = ColorUtils.calculateLuminance(background) > 0.5 + val hsl = FloatArray(3) + ColorUtils.colorToHSL(color, hsl) + val hue = hsl[0] / 360f + + when { isLightBackground -> { if (hsl[2] > 0.5f) { hsl[2] = 0.5f } - - if (hsl[2] > 0.4f && huePercentage > 0.1f && huePercentage < 0.33333f) { - hsl[2] = (hsl[2] - sin((huePercentage - 0.1f) / (0.33333f - 0.1f) * 3.14159f) * hsl[1] * 0.4f) + if (hsl[2] > 0.4f && hue > 0.1f && hue < 0.33333f) { + hsl[2] -= (sin((hue - 0.1f) / (0.33333f - 0.1f) * Math.PI.toFloat()) * hsl[1] * 0.4f) } - - ColorUtils.HSLToColor(hsl) } - else -> { + else -> { if (hsl[2] < 0.5f) { hsl[2] = 0.5f } - - if (hsl[2] < 0.6f && huePercentage > 0.54444f && huePercentage < 0.83333f) { - hsl[2] = (hsl[2] + sin((huePercentage - 0.54444f) / (0.83333f - 0.54444f) * 3.14159f) * hsl[1] * 0.4f) + if (hsl[2] < 0.6f && hue > 0.54444f && hue < 0.83333f) { + hsl[2] += (sin((hue - 0.54444f) / (0.83333f - 0.54444f) * Math.PI.toFloat()) * hsl[1] * 0.4f) } - - ColorUtils.HSLToColor(hsl) } } + + return ColorUtils.HSLToColor(hsl) } +private const val MIN_CONTRAST_RATIO = 3.5 +private const val MAX_ITERATIONS = 16 + /** convert int to RGB with zero pad */ val Int.hexCode: String get() = Integer.toHexString(rgb).padStart(6, '0') diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CoroutineExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CoroutineExtensions.kt index 363613179..d1aeb0dd0 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CoroutineExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/CoroutineExtensions.kt @@ -1,6 +1,6 @@ package com.flxrs.dankchat.utils.extensions -import android.util.Log +import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -11,28 +11,31 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.yield import kotlin.time.Duration +private val logger = KotlinLogging.logger("CoroutineExtensions") + suspend fun Collection.concurrentMap(block: suspend (T) -> R): List = coroutineScope { map { async { block(it) } }.awaitAll() } -fun CoroutineScope.timer(interval: Duration, action: suspend TimerScope.() -> Unit): Job { - return launch { - val scope = TimerScope() - - while (true) { - try { - action(scope) - } catch (ex: Exception) { - Log.e("TimerScope", Log.getStackTraceString(ex)) - } - - if (scope.isCancelled) { - break - } +fun CoroutineScope.timer( + interval: Duration, + action: suspend TimerScope.() -> Unit, +): Job = launch { + val scope = TimerScope() + + while (true) { + try { + action(scope) + } catch (ex: Exception) { + logger.error(ex) { "TimerScope error" } + } - delay(interval) - yield() + if (scope.isCancelled) { + break } + + delay(interval) + yield() } } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/Extensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/Extensions.kt index 9c563e57d..8493b761a 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/Extensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/Extensions.kt @@ -3,25 +3,40 @@ package com.flxrs.dankchat.utils.extensions import android.content.Context import android.content.pm.PackageManager import android.os.Build -import android.util.Log -import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.DrawableCompat -import androidx.lifecycle.SavedStateHandle -import com.flxrs.dankchat.R -import com.flxrs.dankchat.chat.emotemenu.EmoteItem import com.flxrs.dankchat.data.UserName import com.flxrs.dankchat.data.twitch.emote.GenericEmote -import com.google.android.material.color.MaterialColors -import kotlinx.serialization.decodeFromString +import com.flxrs.dankchat.ui.chat.emotemenu.EmoteItem +import io.github.oshai.kotlinlogging.KLogger import kotlinx.serialization.json.Json fun List?.toEmoteItems(): List = this ?.groupBy { it.emoteType.title } + ?.toSortedMap(String.CASE_INSENSITIVE_ORDER) ?.mapValues { (title, emotes) -> EmoteItem.Header(title) + emotes.map(EmoteItem::Emote).sorted() } ?.flatMap { it.value } .orEmpty() +fun List?.toEmoteItemsWithFront(channel: UserName?): List { + if (this == null) return emptyList() + val grouped = groupBy { it.emoteType.title } + val frontKey = grouped.keys.find { it.equals(channel?.value, ignoreCase = true) } + val sorted = grouped.toSortedMap(String.CASE_INSENSITIVE_ORDER) + val ordered = + if (frontKey != null) { + val frontEntry = sorted.remove(frontKey) + buildMap { + if (frontEntry != null) put(frontKey, frontEntry) + putAll(sorted) + } + } else { + sorted + } + return ordered + .mapValues { (title, emotes) -> EmoteItem.Header(title) + emotes.map(EmoteItem::Emote).sorted() } + .flatMap { it.value } +} + fun List.moveToFront(channel: UserName?): List = this .partition { it.emoteType.title.equals(channel?.value, ignoreCase = true) } .run { first + second } @@ -31,11 +46,15 @@ inline fun measureTimeValue(block: () -> V): Pair { return block() to System.currentTimeMillis() - start } -inline fun measureTimeAndLog(tag: String, toLoad: String, block: () -> V): V { +inline fun measureTimeAndLog( + logger: KLogger, + toLoad: String, + block: () -> V, +): V { val (result, time) = measureTimeValue(block) when { - result != null -> Log.i(tag, "Loaded $toLoad in $time ms") - else -> Log.i(tag, "Failed to load $toLoad ($time ms)") + result != null -> logger.info { "Loaded $toLoad in $time ms" } + else -> logger.info { "Failed to load $toLoad ($time ms)" } } return result @@ -45,18 +64,6 @@ inline fun Json.decodeOrNull(json: String): T? = runCatching { decodeFromString(json) }.getOrNull() -val Int.isEven get() = (this % 2 == 0) - -fun Context.getDrawableAndSetSurfaceTint(@DrawableRes id: Int) = ContextCompat.getDrawable(this, id)?.apply { - val color = MaterialColors.getColor(this@getDrawableAndSetSurfaceTint, R.attr.colorOnSurface, "DankChat") - DrawableCompat.setTint(this, color) -} - -inline fun SavedStateHandle.withData(key: String, block: (T) -> Unit) { - val data = remove(key) ?: return - block(data) -} - val isAtLeastTiramisu: Boolean by lazy { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU } -fun Context.hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED +fun Context.hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FlowExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FlowExtensions.kt index 952c2b7c8..d61b10812 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FlowExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FlowExtensions.kt @@ -1,42 +1,32 @@ package com.flxrs.dankchat.utils.extensions -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.flxrs.dankchat.data.UserName import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch - -inline fun Fragment.collectFlow(flow: Flow, crossinline action: (T) -> Unit) { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - flow.collect { action(it) } - } - } -} fun mutableSharedFlowOf( defaultValue: T, replayValue: Int = 1, extraBufferCapacity: Int = 0, - onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST + onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST, ): MutableSharedFlow = MutableSharedFlow(replayValue, extraBufferCapacity, onBufferOverflow).apply { tryEmit(defaultValue) } -inline fun Flow.flatMapLatestOrDefault(defaultValue: R, crossinline transform: suspend (value: T) -> Flow): Flow = - transformLatest { - when (it) { - null -> emit(defaultValue) - else -> emitAll(transform(it)) - } +inline fun Flow.flatMapLatestOrDefault( + defaultValue: R, + crossinline transform: suspend (value: T) -> Flow, +): Flow = transformLatest { + when (it) { + null -> emit(defaultValue) + else -> emitAll(transform(it)) } +} inline val SharedFlow.firstValue: T get() = replayCache.first() @@ -44,15 +34,47 @@ inline val SharedFlow.firstValue: T inline val SharedFlow.firstValueOrNull: T? get() = replayCache.firstOrNull() -fun MutableSharedFlow>.increment(key: UserName, amount: Int) = tryEmit(firstValue.apply { - val count = get(key) ?: 0 - put(key, count + amount) -}) +fun MutableSharedFlow>.increment( + key: UserName, + amount: Int, +) = tryEmit( + firstValue.apply { + val count = get(key) ?: 0 + put(key, count + amount) + }, +) + +fun MutableSharedFlow>.clear(key: UserName) = tryEmit( + firstValue.apply { + put(key, 0) + }, +) -fun MutableSharedFlow>.clear(key: UserName) = tryEmit(firstValue.apply { - put(key, 0) -}) +fun combine( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) +} -fun MutableSharedFlow>.assign(key: UserName, value: T) = tryEmit(firstValue.apply { - put(key, value) -}) +fun MutableSharedFlow>.assign( + key: UserName, + value: T, +) = tryEmit( + firstValue.apply { + put(key, value) + }, +) diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FragmentExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FragmentExtensions.kt deleted file mode 100644 index fa23f467d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/FragmentExtensions.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.flxrs.dankchat.utils.extensions - -import android.app.Activity -import android.content.Context -import android.content.res.Configuration -import android.content.res.Resources -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.View -import android.view.WindowManager -import android.view.inputmethod.InputMethodManager -import androidx.annotation.IdRes -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavDirections -import androidx.navigation.NavOptions -import androidx.navigation.Navigator -import androidx.navigation.fragment.findNavController - -private const val TAG = "FragmentExtensions" -const val NAV_DESTINATION_ID = 42420012 - -// From https://medium.com/@ffvanderlaan/fixing-the-dreaded-is-unknown-to-this-navcontroller-68c4003824ce -fun Fragment.navigateSafe( - @IdRes resId: Int, - args: Bundle? = null, - navOptions: NavOptions? = null, - navigatorExtras: Navigator.Extras? = null -) { - if (mayNavigate()) findNavController().navigate( - resId, args, - navOptions, navigatorExtras - ) -} - -fun Fragment.navigateSafe(direction: NavDirections) { - if (mayNavigate()) findNavController().navigate(direction) -} - -fun Fragment.mayNavigate(): Boolean { - - val navController = findNavController() - val destinationIdInNavController = navController.currentDestination?.id - val destinationIdOfThisFragment = view?.getTag(NAV_DESTINATION_ID) ?: destinationIdInNavController - - // check that the navigation graph is still in 'this' fragment, if not then the app already navigated: - return if (destinationIdInNavController == destinationIdOfThisFragment) { - view?.setTag(NAV_DESTINATION_ID, destinationIdOfThisFragment) - true - } else { - Log.d(TAG, "May not navigate: current destination is not the current fragment.") - false - } -} - -fun Fragment.hideKeyboard() { - view?.let { activity?.hideKeyboard(it) } -} - -val Fragment.isLandscape: Boolean - get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - -val Resources.isLandscape: Boolean - get() = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - -val Fragment.isPortrait: Boolean - get() = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT - -val Resources.isPortrait: Boolean - get() = configuration.orientation == Configuration.ORIENTATION_PORTRAIT - -private fun Context.hideKeyboard(view: View) { - val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) -} - -fun AppCompatActivity.keepScreenOn(keep: Boolean) { - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - -val FragmentActivity.isInSupportedPictureInPictureMode: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isInPictureInPictureMode - -val Fragment.isInPictureInPictureMode: Boolean get() = activity?.isInSupportedPictureInPictureMode ?: false diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/GifExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/GifExtensions.kt index b1e3dfea9..edf88dbbf 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/GifExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/GifExtensions.kt @@ -2,4 +2,4 @@ package com.flxrs.dankchat.utils.extensions import android.graphics.drawable.Animatable -fun Animatable.setRunning(running: Boolean) = if (running) start() else stop() \ No newline at end of file +fun Animatable.setRunning(running: Boolean) = if (running) start() else stop() diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt new file mode 100644 index 000000000..7b4c31fb5 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ModerationOperations.kt @@ -0,0 +1,145 @@ +package com.flxrs.dankchat.utils.extensions + +import com.flxrs.dankchat.data.chat.ChatImportance +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.twitch.message.ModerationMessage +import com.flxrs.dankchat.data.twitch.message.PrivMessage +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +fun MutableList.replaceOrAddHistoryModerationMessage(moderationMessage: ModerationMessage) { + if (!moderationMessage.canClearMessages) { + return + } + + if (deduplicateOrStack(moderationMessage)) { + add(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM)) + } +} + +fun List.replaceOrAddModerationMessage( + moderationMessage: ModerationMessage, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, +): List = toMutableList().apply { + if (!moderationMessage.canClearMessages) { + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + return this + } + + val addSystemMessage = deduplicateOrStack(moderationMessage) + for (idx in indices) { + val item = this[idx] + when (moderationMessage.action) { + ModerationMessage.Action.Clear -> { + this[idx] = + when (item.message) { + is PrivMessage -> item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) + else -> item.copy(tag = item.tag + 1, importance = ChatImportance.DELETED) + } + } + + is ModerationMessage.Action.Timeout, + ModerationMessage.Action.Ban, + is ModerationMessage.Action.SharedTimeout, + ModerationMessage.Action.SharedBan, + -> { + item.message as? PrivMessage ?: continue + if (moderationMessage.targetUser != item.message.name) { + continue + } + + this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) + } + + else -> { + continue + } + } + } + + if (addSystemMessage) { + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) + } +} + +fun List.replaceWithTimeout( + moderationMessage: ModerationMessage, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, +): List = toMutableList().apply { + val targetMsgId = moderationMessage.targetMsgId ?: return@apply + if (moderationMessage.fromEventSource) { + val end = (lastIndex - 20).coerceAtLeast(0) + for (idx in lastIndex downTo end) { + val item = this[idx] + val message = item.message as? ModerationMessage ?: continue + if ((message.action is ModerationMessage.Action.Delete || message.action is ModerationMessage.Action.SharedDelete) && message.targetMsgId == targetMsgId && !message.fromEventSource) { + this[idx] = item.copy(tag = item.tag + 1, message = moderationMessage) + return@apply + } + } + } + + for (idx in indices) { + val item = this[idx] + if (item.message is PrivMessage && item.message.id == targetMsgId) { + this[idx] = item.copy(tag = item.tag + 1, message = item.message.copy(timedOut = true), importance = ChatImportance.DELETED) + break + } + } + + addAndTrimInline(ChatItem(moderationMessage, importance = ChatImportance.SYSTEM), scrollBackLength, onMessageRemoved) +} + +/** + * Checks recent messages for an existing moderation message with the same target and action. + * Handles three cases: + * - **Event source dedup**: EventSub replaces IRC; IRC is ignored if EventSub exists + * - **Stacking**: Repeated timeouts on the same user increment a stack counter + * - **New message**: If no recent match, or the match is >5s old, the message should be added + * + * @return `true` if the message should be added as a new system message, `false` if it was merged/replaced + */ +private fun MutableList.deduplicateOrStack(moderationMessage: ModerationMessage): Boolean { + if (!moderationMessage.canStack) { + return true + } + + val end = (lastIndex - 20).coerceAtLeast(0) + for (idx in lastIndex downTo end) { + val item = this[idx] + val existing = item.message as? ModerationMessage ?: continue + if (existing.targetUser != moderationMessage.targetUser || !existing.action.isSameType(moderationMessage.action)) { + continue + } + + // Different moderation action on the same user, treat as new + if ((moderationMessage.timestamp - existing.timestamp).milliseconds >= 5.seconds) { + return true + } + + // Same action within 5 seconds — deduplicate or stack + when { + // IRC arriving after EventSub → keep EventSub, discard IRC + !moderationMessage.fromEventSource && existing.fromEventSource -> { + Unit + } + + // EventSub arriving after IRC → replace IRC with EventSub (has moderator info), preserve stack count + moderationMessage.fromEventSource && !existing.fromEventSource -> { + val merged = moderationMessage.copy(stackCount = maxOf(existing.stackCount, moderationMessage.stackCount)) + this[idx] = item.copy(tag = item.tag + 1, message = merged) + } + + // Same source, stackable action → increment stack count + moderationMessage.action is ModerationMessage.Action.Timeout || moderationMessage.action is ModerationMessage.Action.SharedTimeout -> { + val stackedMessage = moderationMessage.copy(stackCount = existing.stackCount + 1) + this[idx] = item.copy(tag = item.tag + 1, message = stackedMessage) + } + } + return false + } + + return true +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/StringExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/StringExtensions.kt index 133e3579c..75ff87097 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/StringExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/StringExtensions.kt @@ -1,5 +1,6 @@ package com.flxrs.dankchat.utils.extensions +@Suppress("MaxLineLength") private val emojiRegex = """[#*0-9]\x{FE0F}?\x{20E3}|[\xA9\xAE\x{203C}\x{2049}\x{2122}\x{2139}\x{2194}-\x{2199}\x{21A9}\x{21AA}\x{231A}\x{231B}\x{2328}\x{23CF}\x{23ED}-\x{23EF}\x{23F1}\x{23F2}\x{23F8}-\x{23FA}\x{24C2}\x{25AA}\x{25AB}\x{25B6}\x{25C0}\x{25FB}\x{25FC}\x{25FE}\x{2600}-\x{2604}\x{260E}\x{2611}\x{2614}\x{2615}\x{2618}\x{2620}\x{2622}\x{2623}\x{2626}\x{262A}\x{262E}\x{262F}\x{2638}-\x{263A}\x{2640}\x{2642}\x{2648}-\x{2653}\x{265F}\x{2660}\x{2663}\x{2665}\x{2666}\x{2668}\x{267B}\x{267E}\x{267F}\x{2692}\x{2694}-\x{2697}\x{2699}\x{269B}\x{269C}\x{26A0}\x{26A7}\x{26AA}\x{26B0}\x{26B1}\x{26BD}\x{26BE}\x{26C4}\x{26C8}\x{26CF}\x{26D1}\x{26E9}\x{26F0}-\x{26F5}\x{26F7}\x{26F8}\x{26FA}\x{2702}\x{2708}\x{2709}\x{270F}\x{2712}\x{2714}\x{2716}\x{271D}\x{2721}\x{2733}\x{2734}\x{2744}\x{2747}\x{2757}\x{2763}\x{27A1}\x{2934}\x{2935}\x{2B05}-\x{2B07}\x{2B1B}\x{2B1C}\x{2B55}\x{3030}\x{303D}\x{3297}\x{3299}\x{1F004}\x{1F170}\x{1F171}\x{1F17E}\x{1F17F}\x{1F202}\x{1F237}\x{1F321}\x{1F324}-\x{1F32C}\x{1F336}\x{1F37D}\x{1F396}\x{1F397}\x{1F399}-\x{1F39B}\x{1F39E}\x{1F39F}\x{1F3CD}\x{1F3CE}\x{1F3D4}-\x{1F3DF}\x{1F3F5}\x{1F3F7}\x{1F43F}\x{1F4FD}\x{1F549}\x{1F54A}\x{1F56F}\x{1F570}\x{1F573}\x{1F576}-\x{1F579}\x{1F587}\x{1F58A}-\x{1F58D}\x{1F5A5}\x{1F5A8}\x{1F5B1}\x{1F5B2}\x{1F5BC}\x{1F5C2}-\x{1F5C4}\x{1F5D1}-\x{1F5D3}\x{1F5DC}-\x{1F5DE}\x{1F5E1}\x{1F5E3}\x{1F5E8}\x{1F5EF}\x{1F5F3}\x{1F5FA}\x{1F6CB}\x{1F6CD}-\x{1F6CF}\x{1F6E0}-\x{1F6E5}\x{1F6E9}\x{1F6F0}\x{1F6F3}]\x{FE0F}?|[\x{261D}\x{270C}\x{270D}\x{1F574}\x{1F590}][\x{FE0F}\x{1F3FB}-\x{1F3FF}]?|[\x{26F9}\x{1F3CB}\x{1F3CC}\x{1F575}][\x{FE0F}\x{1F3FB}-\x{1F3FF}]?(?:\x{200D}[\x{2640}\x{2642}]\x{FE0F}?)?|[\x{270A}\x{270B}\x{1F385}\x{1F3C2}\x{1F3C7}\x{1F442}\x{1F443}\x{1F446}-\x{1F450}\x{1F466}\x{1F467}\x{1F46B}-\x{1F46D}\x{1F472}\x{1F474}-\x{1F476}\x{1F478}\x{1F47C}\x{1F483}\x{1F485}\x{1F48F}\x{1F491}\x{1F4AA}\x{1F57A}\x{1F595}\x{1F596}\x{1F64C}\x{1F64F}\x{1F6C0}\x{1F6CC}\x{1F90C}\x{1F90F}\x{1F918}-\x{1F91F}\x{1F930}-\x{1F934}\x{1F936}\x{1F977}\x{1F9B5}\x{1F9B6}\x{1F9BB}\x{1F9D2}\x{1F9D3}\x{1F9D5}\x{1FAC3}-\x{1FAC5}\x{1FAF0}\x{1FAF2}-\x{1FAF8}][\x{1F3FB}-\x{1F3FF}]?|[\x{1F3C3}\x{1F6B6}\x{1F9CE}][\x{1F3FB}-\x{1F3FF}]?(?:\x{200D}(?:[\x{2640}\x{2642}]\x{FE0F}?(?:\x{200D}\x{27A1}\x{FE0F}?)?|\x{27A1}\x{FE0F}?))?|[\x{1F3C4}\x{1F3CA}\x{1F46E}\x{1F470}\x{1F471}\x{1F473}\x{1F477}\x{1F481}\x{1F482}\x{1F486}\x{1F487}\x{1F645}-\x{1F647}\x{1F64B}\x{1F64D}\x{1F64E}\x{1F6A3}\x{1F6B4}\x{1F6B5}\x{1F926}\x{1F935}\x{1F937}-\x{1F939}\x{1F93D}\x{1F93E}\x{1F9B8}\x{1F9B9}\x{1F9CD}\x{1F9CF}\x{1F9D4}\x{1F9D6}-\x{1F9DD}][\x{1F3FB}-\x{1F3FF}]?(?:\x{200D}[\x{2640}\x{2642}]\x{FE0F}?)?|[\x{1F46F}\x{1F9DE}\x{1F9DF}](?:\x{200D}[\x{2640}\x{2642}]\x{FE0F}?)?|[\x{23E9}-\x{23EC}\x{23F0}\x{23F3}\x{25FD}\x{2693}\x{26A1}\x{26AB}\x{26C5}\x{26CE}\x{26D4}\x{26EA}\x{26FD}\x{2705}\x{2728}\x{274C}\x{274E}\x{2753}-\x{2755}\x{2795}-\x{2797}\x{27B0}\x{27BF}\x{2B50}\x{1F0CF}\x{1F18E}\x{1F191}-\x{1F19A}\x{1F201}\x{1F21A}\x{1F22F}\x{1F232}-\x{1F236}\x{1F238}-\x{1F23A}\x{1F250}\x{1F251}\x{1F300}-\x{1F320}\x{1F32D}-\x{1F335}\x{1F337}-\x{1F343}\x{1F345}-\x{1F34A}\x{1F34C}-\x{1F37C}\x{1F37E}-\x{1F384}\x{1F386}-\x{1F393}\x{1F3A0}-\x{1F3C1}\x{1F3C5}\x{1F3C6}\x{1F3C8}\x{1F3C9}\x{1F3CF}-\x{1F3D3}\x{1F3E0}-\x{1F3F0}\x{1F3F8}-\x{1F407}\x{1F409}-\x{1F414}\x{1F416}-\x{1F425}\x{1F427}-\x{1F43A}\x{1F43C}-\x{1F43E}\x{1F440}\x{1F444}\x{1F445}\x{1F451}-\x{1F465}\x{1F46A}\x{1F479}-\x{1F47B}\x{1F47D}-\x{1F480}\x{1F484}\x{1F488}-\x{1F48E}\x{1F490}\x{1F492}-\x{1F4A9}\x{1F4AB}-\x{1F4FC}\x{1F4FF}-\x{1F53D}\x{1F54B}-\x{1F54E}\x{1F550}-\x{1F567}\x{1F5A4}\x{1F5FB}-\x{1F62D}\x{1F62F}-\x{1F634}\x{1F637}-\x{1F641}\x{1F643}\x{1F644}\x{1F648}-\x{1F64A}\x{1F680}-\x{1F6A2}\x{1F6A4}-\x{1F6B3}\x{1F6B7}-\x{1F6BF}\x{1F6C1}-\x{1F6C5}\x{1F6D0}-\x{1F6D2}\x{1F6D5}-\x{1F6D7}\x{1F6DC}-\x{1F6DF}\x{1F6EB}\x{1F6EC}\x{1F6F4}-\x{1F6FC}\x{1F7E0}-\x{1F7EB}\x{1F7F0}\x{1F90D}\x{1F90E}\x{1F910}-\x{1F917}\x{1F920}-\x{1F925}\x{1F927}-\x{1F92F}\x{1F93A}\x{1F93F}-\x{1F945}\x{1F947}-\x{1F976}\x{1F978}-\x{1F9B4}\x{1F9B7}\x{1F9BA}\x{1F9BC}-\x{1F9CC}\x{1F9D0}\x{1F9E0}-\x{1F9FF}\x{1FA70}-\x{1FA7C}\x{1FA80}-\x{1FA88}\x{1FA90}-\x{1FABD}\x{1FABF}-\x{1FAC2}\x{1FACE}-\x{1FADB}\x{1FAE0}-\x{1FAE8}]|\x{26D3}\x{FE0F}?(?:\x{200D}\x{1F4A5})?|\x{2764}\x{FE0F}?(?:\x{200D}[\x{1F525}\x{1FA79}])?|\x{1F1E6}[\x{1F1E8}-\x{1F1EC}\x{1F1EE}\x{1F1F1}\x{1F1F2}\x{1F1F4}\x{1F1F6}-\x{1F1FA}\x{1F1FC}\x{1F1FD}\x{1F1FF}]|\x{1F1E7}[\x{1F1E6}\x{1F1E7}\x{1F1E9}-\x{1F1EF}\x{1F1F1}-\x{1F1F4}\x{1F1F6}-\x{1F1F9}\x{1F1FB}\x{1F1FC}\x{1F1FE}\x{1F1FF}]|\x{1F1E8}[\x{1F1E6}\x{1F1E8}\x{1F1E9}\x{1F1EB}-\x{1F1EE}\x{1F1F0}-\x{1F1F5}\x{1F1F7}\x{1F1FA}-\x{1F1FF}]|\x{1F1E9}[\x{1F1EA}\x{1F1EC}\x{1F1EF}\x{1F1F0}\x{1F1F2}\x{1F1F4}\x{1F1FF}]|\x{1F1EA}[\x{1F1E6}\x{1F1E8}\x{1F1EA}\x{1F1EC}\x{1F1ED}\x{1F1F7}-\x{1F1FA}]|\x{1F1EB}[\x{1F1EE}-\x{1F1F0}\x{1F1F2}\x{1F1F4}\x{1F1F7}]|\x{1F1EC}[\x{1F1E6}\x{1F1E7}\x{1F1E9}-\x{1F1EE}\x{1F1F1}-\x{1F1F3}\x{1F1F5}-\x{1F1FA}\x{1F1FC}\x{1F1FE}]|\x{1F1ED}[\x{1F1F0}\x{1F1F2}\x{1F1F3}\x{1F1F7}\x{1F1F9}\x{1F1FA}]|\x{1F1EE}[\x{1F1E8}-\x{1F1EA}\x{1F1F1}-\x{1F1F4}\x{1F1F6}-\x{1F1F9}]|\x{1F1EF}[\x{1F1EA}\x{1F1F2}\x{1F1F4}\x{1F1F5}]|\x{1F1F0}[\x{1F1EA}\x{1F1EC}-\x{1F1EE}\x{1F1F2}\x{1F1F3}\x{1F1F5}\x{1F1F7}\x{1F1FC}\x{1F1FE}\x{1F1FF}]|\x{1F1F1}[\x{1F1E6}-\x{1F1E8}\x{1F1EE}\x{1F1F0}\x{1F1F7}-\x{1F1FB}\x{1F1FE}]|\x{1F1F2}[\x{1F1E6}\x{1F1E8}-\x{1F1ED}\x{1F1F0}-\x{1F1FF}]|\x{1F1F3}[\x{1F1E6}\x{1F1E8}\x{1F1EA}-\x{1F1EC}\x{1F1EE}\x{1F1F1}\x{1F1F4}\x{1F1F5}\x{1F1F7}\x{1F1FA}\x{1F1FF}]|\x{1F1F4}\x{1F1F2}|\x{1F1F5}[\x{1F1E6}\x{1F1EA}-\x{1F1ED}\x{1F1F0}-\x{1F1F3}\x{1F1F7}-\x{1F1F9}\x{1F1FC}\x{1F1FE}]|\x{1F1F6}\x{1F1E6}|\x{1F1F7}[\x{1F1EA}\x{1F1F4}\x{1F1F8}\x{1F1FA}\x{1F1FC}]|\x{1F1F8}[\x{1F1E6}-\x{1F1EA}\x{1F1EC}-\x{1F1F4}\x{1F1F7}-\x{1F1F9}\x{1F1FB}\x{1F1FD}-\x{1F1FF}]|\x{1F1F9}[\x{1F1E6}\x{1F1E8}\x{1F1E9}\x{1F1EB}-\x{1F1ED}\x{1F1EF}-\x{1F1F4}\x{1F1F7}\x{1F1F9}\x{1F1FB}\x{1F1FC}\x{1F1FF}]|\x{1F1FA}[\x{1F1E6}\x{1F1EC}\x{1F1F2}\x{1F1F3}\x{1F1F8}\x{1F1FE}\x{1F1FF}]|\x{1F1FB}[\x{1F1E6}\x{1F1E8}\x{1F1EA}\x{1F1EC}\x{1F1EE}\x{1F1F3}\x{1F1FA}]|\x{1F1FC}[\x{1F1EB}\x{1F1F8}]|\x{1F1FD}\x{1F1F0}|\x{1F1FE}[\x{1F1EA}\x{1F1F9}]|\x{1F1FF}[\x{1F1E6}\x{1F1F2}\x{1F1FC}]|\x{1F344}(?:\x{200D}\x{1F7EB})?|\x{1F34B}(?:\x{200D}\x{1F7E9})?|\x{1F3F3}\x{FE0F}?(?:\x{200D}(?:\x{26A7}\x{FE0F}?|\x{1F308}))?|\x{1F3F4}(?:\x{200D}\x{2620}\x{FE0F}?|\x{E0067}\x{E0062}(?:\x{E0065}\x{E006E}\x{E0067}|\x{E0073}\x{E0063}\x{E0074}|\x{E0077}\x{E006C}\x{E0073})\x{E007F})?|\x{1F408}(?:\x{200D}\x{2B1B})?|\x{1F415}(?:\x{200D}\x{1F9BA})?|\x{1F426}(?:\x{200D}[\x{2B1B}\x{1F525}])?|\x{1F43B}(?:\x{200D}\x{2744}\x{FE0F}?)?|\x{1F441}\x{FE0F}?(?:\x{200D}\x{1F5E8}\x{FE0F}?)?|\x{1F468}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F468}\x{1F469}]\x{200D}(?:\x{1F466}(?:\x{200D}\x{1F466})?|\x{1F467}(?:\x{200D}[\x{1F466}\x{1F467}])?)|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}|\x{1F466}(?:\x{200D}\x{1F466})?|\x{1F467}(?:\x{200D}[\x{1F466}\x{1F467}])?)|\x{1F3FB}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F468}[\x{1F3FC}-\x{1F3FF}]))?|\x{1F3FC}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F468}[\x{1F3FB}\x{1F3FD}-\x{1F3FF}]))?|\x{1F3FD}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F468}[\x{1F3FB}\x{1F3FC}\x{1F3FE}\x{1F3FF}]))?|\x{1F3FE}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F468}[\x{1F3FB}-\x{1F3FD}\x{1F3FF}]))?|\x{1F3FF}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F468}[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F468}[\x{1F3FB}-\x{1F3FE}]))?)?|\x{1F469}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?[\x{1F468}\x{1F469}]|\x{1F466}(?:\x{200D}\x{1F466})?|\x{1F467}(?:\x{200D}[\x{1F466}\x{1F467}])?|\x{1F469}\x{200D}(?:\x{1F466}(?:\x{200D}\x{1F466})?|\x{1F467}(?:\x{200D}[\x{1F466}\x{1F467}])?))|\x{1F3FB}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:[\x{1F468}\x{1F469}]|\x{1F48B}\x{200D}[\x{1F468}\x{1F469}])[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}[\x{1F468}\x{1F469}][\x{1F3FC}-\x{1F3FF}]))?|\x{1F3FC}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:[\x{1F468}\x{1F469}]|\x{1F48B}\x{200D}[\x{1F468}\x{1F469}])[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}[\x{1F468}\x{1F469}][\x{1F3FB}\x{1F3FD}-\x{1F3FF}]))?|\x{1F3FD}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:[\x{1F468}\x{1F469}]|\x{1F48B}\x{200D}[\x{1F468}\x{1F469}])[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}[\x{1F468}\x{1F469}][\x{1F3FB}\x{1F3FC}\x{1F3FE}\x{1F3FF}]))?|\x{1F3FE}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:[\x{1F468}\x{1F469}]|\x{1F48B}\x{200D}[\x{1F468}\x{1F469}])[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}[\x{1F468}\x{1F469}][\x{1F3FB}-\x{1F3FD}\x{1F3FF}]))?|\x{1F3FF}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:[\x{1F468}\x{1F469}]|\x{1F48B}\x{200D}[\x{1F468}\x{1F469}])[\x{1F3FB}-\x{1F3FF}]|\x{1F91D}\x{200D}[\x{1F468}\x{1F469}][\x{1F3FB}-\x{1F3FE}]))?)?|\x{1F62E}(?:\x{200D}\x{1F4A8})?|\x{1F635}(?:\x{200D}\x{1F4AB})?|\x{1F636}(?:\x{200D}\x{1F32B}\x{FE0F}?)?|\x{1F642}(?:\x{200D}[\x{2194}\x{2195}]\x{FE0F}?)?|\x{1F93C}(?:[\x{1F3FB}-\x{1F3FF}]|\x{200D}[\x{2640}\x{2642}]\x{FE0F}?)?|\x{1F9D1}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{1F91D}\x{200D}\x{1F9D1}|\x{1F9D1}\x{200D}\x{1F9D2}(?:\x{200D}\x{1F9D2})?|\x{1F9D2}(?:\x{200D}\x{1F9D2})?)|\x{1F3FB}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F9D1}[\x{1F3FC}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]))?|\x{1F3FC}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F9D1}[\x{1F3FB}\x{1F3FD}-\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]))?|\x{1F3FD}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F9D1}[\x{1F3FB}\x{1F3FC}\x{1F3FE}\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]))?|\x{1F3FE}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F9D1}[\x{1F3FB}-\x{1F3FD}\x{1F3FF}]|\x{1F91D}\x{200D}\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]))?|\x{1F3FF}(?:\x{200D}(?:[\x{2695}\x{2696}\x{2708}]\x{FE0F}?|[\x{1F9AF}\x{1F9BC}\x{1F9BD}](?:\x{200D}\x{27A1}\x{FE0F}?)?|[\x{1F33E}\x{1F373}\x{1F37C}\x{1F384}\x{1F393}\x{1F3A4}\x{1F3A8}\x{1F3EB}\x{1F3ED}\x{1F4BB}\x{1F4BC}\x{1F527}\x{1F52C}\x{1F680}\x{1F692}\x{1F9B0}-\x{1F9B3}]|\x{2764}\x{FE0F}?\x{200D}(?:\x{1F48B}\x{200D})?\x{1F9D1}[\x{1F3FB}-\x{1F3FE}]|\x{1F91D}\x{200D}\x{1F9D1}[\x{1F3FB}-\x{1F3FF}]))?)?|\x{1FAF1}(?:\x{1F3FB}(?:\x{200D}\x{1FAF2}[\x{1F3FC}-\x{1F3FF}])?|\x{1F3FC}(?:\x{200D}\x{1FAF2}[\x{1F3FB}\x{1F3FD}-\x{1F3FF}])?|\x{1F3FD}(?:\x{200D}\x{1FAF2}[\x{1F3FB}\x{1F3FC}\x{1F3FE}\x{1F3FF}])?|\x{1F3FE}(?:\x{200D}\x{1FAF2}[\x{1F3FB}-\x{1F3FD}\x{1F3FF}])?|\x{1F3FF}(?:\x{200D}\x{1FAF2}[\x{1F3FB}-\x{1F3FE}])?)?""" .toRegex() @@ -23,7 +24,7 @@ fun String.removeDuplicateWhitespace(): Pair> { if (codePoint.isWhitespace) { when { previousWhitespace -> removedSpacesPositions += totalCharCount - else -> stringBuilder.appendCodePoint(codePoint) + else -> stringBuilder.appendCodePoint(codePoint) } previousWhitespace = true @@ -38,7 +39,53 @@ fun String.removeDuplicateWhitespace(): Pair> { return stringBuilder.toString() to removedSpacesPositions } +data class CodePointAnalysis( + val supplementaryCodePointPositions: List, + val deduplicatedString: String, + val removedSpacesPositions: List, +) + +// Combined single-pass: finds supplementary codepoint positions AND removes duplicate whitespace +fun String.analyzeCodePoints(): CodePointAnalysis { + val supplementaryPositions = mutableListOf() + val stringBuilder = StringBuilder() + var previousWhitespace = false + val removedSpacesPositions = mutableListOf() + var supplementaryOffset = 0 + var totalCharCount = 0 + var charOffset = 0 + + while (charOffset < length) { + val codePoint = codePointAt(charOffset) + val charCount = Character.charCount(codePoint) + + // Track supplementary codepoint positions (pre-dedup, like the original property) + if (Character.isSupplementaryCodePoint(codePoint)) { + supplementaryPositions += charOffset - supplementaryOffset + supplementaryOffset++ + } + + // Remove duplicate whitespace + if (codePoint.isWhitespace) { + when { + previousWhitespace -> removedSpacesPositions += totalCharCount + else -> stringBuilder.appendCodePoint(codePoint) + } + previousWhitespace = true + } else { + previousWhitespace = false + stringBuilder.appendCodePoint(codePoint) + } + + totalCharCount++ + charOffset += charCount + } + + return CodePointAnalysis(supplementaryPositions, stringBuilder.toString(), removedSpacesPositions) +} + operator fun MatchResult.component1() = value + operator fun MatchResult.component2() = range // Adds extra space between every emoji group to support 3rd party emotes directly before/after emojis @@ -47,6 +94,9 @@ operator fun MatchResult.component2() = range // NaM 🙅🏻‍♂️ NaM Keepo Keepo NaM 🙅🏻‍♂️ NaM Keepo 🙅🏻‍♂️ Keepo NaM 🙅🏻‍♂️ Keepo 🙅🏻‍♂️ NaM Keepo NaM 🙅🏻‍♂️ 🙅🏻‍♂️ 🙅🏻‍♂️NaM // NaM🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️NaM Keepo 🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️🙅🏻‍♂️ Keepo fun String.appendSpacesBetweenEmojiGroup(): Pair> { + // Fast path: if no chars at or above the lowest emoji codepoint (© = 0x00A9), skip regex entirely + if (all { it.code < 0x00A9 }) return this to emptyList() + val matches = emojiRegex.findAll(this).toList() if (matches.isEmpty()) { return this to emptyList() @@ -135,22 +185,27 @@ val String.withoutOAuthPrefix: String get() = removePrefix("oauth:") val String.withTrailingSlash: String - get() = when { - endsWith('/') -> this - else -> "$this/" - } + get() = + when { + endsWith('/') -> this + else -> "$this/" + } val String.withTrailingSpace: String - get() = when { - isNotBlank() && !endsWith(" ") -> "$this " - else -> this - } + get() = + when { + isNotBlank() && !endsWith(" ") -> "$this " + else -> this + } val INVISIBLE_CHAR = 0x034f.codePointAsString val String.withoutInvisibleChar: String get() = trimEnd().removeSuffix(INVISIBLE_CHAR).trimEnd() -inline fun CharSequence.indexOfFirst(startIndex: Int = 0, predicate: (Char) -> Boolean): Int { +inline fun CharSequence.indexOfFirst( + startIndex: Int = 0, + predicate: (Char) -> Boolean, +): Int { for (index in startIndex.coerceAtLeast(0)..lastIndex) { if (predicate(this[index])) { return index @@ -162,5 +217,5 @@ inline fun CharSequence.indexOfFirst(startIndex: Int = 0, predicate: (Char) -> B fun String.truncate(maxLength: Int = 120) = when { length <= maxLength -> this - else -> take(maxLength) + Typography.ellipsis + else -> take(maxLength) + Typography.ellipsis } diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt new file mode 100644 index 000000000..a8cb7cdb3 --- /dev/null +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/SystemMessageOperations.kt @@ -0,0 +1,38 @@ +package com.flxrs.dankchat.utils.extensions + +import com.flxrs.dankchat.data.chat.ChatItem +import com.flxrs.dankchat.data.twitch.message.SystemMessage +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.data.twitch.message.toChatItem + +fun List.addSystemMessage( + type: SystemMessageType, + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, + onReconnect: () -> Unit = {}, +): List = when { + type != SystemMessageType.Connected -> addAndLimit(type.toChatItem(), scrollBackLength, onMessageRemoved) + else -> replaceLastSystemMessageIfNecessary(scrollBackLength, onMessageRemoved, onReconnect) +} + +private fun List.replaceLastSystemMessageIfNecessary( + scrollBackLength: Int, + onMessageRemoved: (ChatItem) -> Unit, + onReconnect: () -> Unit, +): List { + // Scan backwards for a Disconnected message that may be separated from Connected by debug messages + val disconnectedIdx = indexOfLast { (it.message as? SystemMessage)?.type == SystemMessageType.Disconnected } + if (disconnectedIdx >= 0) { + onReconnect() + return toMutableList().apply { + this[disconnectedIdx] = this[disconnectedIdx].copy(message = SystemMessage(SystemMessageType.Reconnected)) + } + } + + val lastType = (lastOrNull()?.message as? SystemMessage)?.type + if (lastType is SystemMessageType.ChannelNonExistent) { + return dropLast(1) + SystemMessageType.Connected.toChatItem() + } + + return addAndLimit(SystemMessageType.Connected.toChatItem(), scrollBackLength, onMessageRemoved) +} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ViewExtensions.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ViewExtensions.kt index 3fbf3b7a6..40f161871 100644 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ViewExtensions.kt +++ b/app/src/main/kotlin/com/flxrs/dankchat/utils/extensions/ViewExtensions.kt @@ -1,64 +1,6 @@ package com.flxrs.dankchat.utils.extensions import android.graphics.drawable.LayerDrawable -import android.text.SpannableString -import android.view.View -import android.widget.ImageView -import android.widget.TextView -import androidx.annotation.DrawableRes -import androidx.core.text.getSpans -import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.widget.ViewPager2 -import coil3.load -import coil3.request.ImageRequest -import coil3.request.error -import coil3.request.placeholder -import com.flxrs.dankchat.R -import com.google.android.material.snackbar.Snackbar - -fun View.showShortSnackbar(text: String, block: Snackbar.() -> Unit = {}) = Snackbar.make(this, text, Snackbar.LENGTH_SHORT) - .apply(block) - .show() - -fun View.showLongSnackbar(text: String, block: Snackbar.() -> Unit = {}) = Snackbar.make(this, text, Snackbar.LENGTH_LONG) - .apply(block) - .show() - -inline fun ImageView.loadImage( - data: Any, - @DrawableRes placeholder: Int? = R.drawable.ic_missing_emote, - noinline afterLoad: (() -> Unit)? = null, - block: ImageRequest.Builder.() -> Unit = {} -) { - load(data) { - error(R.drawable.ic_missing_emote) - placeholder?.let { placeholder(it) } - afterLoad?.let { - listener( - onCancel = { it() }, - onSuccess = { _, _ -> it() }, - onError = { _, _ -> it() } - ) - } - block() - } -} - -inline fun RecyclerView.forEachViewHolder(itemCount: Int, action: (Int, T) -> Unit) { - for (i in 0.. TextView.forEachSpan(action: (T) -> Unit) { - (text as? SpannableString) - ?.getSpans() - .orEmpty() - .forEach(action) -} inline fun LayerDrawable.forEachLayer(action: (T) -> Unit) { for (i in 0.. LayerDrawable.forEachLayer(action: (T) -> Unit) { } } } - -val ViewPager2.recyclerView: RecyclerView? - get() = runCatching { - when (val view = getChildAt(0)) { - is RecyclerView -> view - else -> null - } - }.getOrNull() - -fun ViewPager2.reduceDragSensitivity() = runCatching { - val recyclerView = recyclerView - val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop").apply { isAccessible = true } - val touchSlop = touchSlopField.get(recyclerView) as Int - touchSlopField.set(recyclerView, touchSlop * 2) -} - -fun ViewPager2.disableNestedScrolling() = runCatching { - val recyclerView = recyclerView - recyclerView?.isNestedScrollingEnabled = false - isNestedScrollingEnabled = false -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/ControlFocusInsetsAnimationCallback.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/ControlFocusInsetsAnimationCallback.kt deleted file mode 100644 index b8ff8ce6d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/ControlFocusInsetsAnimationCallback.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.flxrs.dankchat.utils.insets - -import android.view.View -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsAnimationCompat -import androidx.core.view.WindowInsetsCompat - -/** - * A [WindowInsetsAnimationCompat.Callback] which will request and clear focus on the given view, - * depending on the [WindowInsetsCompat.Type.ime] visibility state when an IME - * [WindowInsetsAnimationCompat] has finished. - * - * This is primarily used when animating the [WindowInsetsCompat.Type.ime], so that the - * appropriate view is focused for accepting input from the IME. - * - * @param view the view to request/clear focus - * @param dispatchMode The dispatch mode for this callback. - * - * @see WindowInsetsAnimationCompat.Callback.getDispatchMode - */ -class ControlFocusInsetsAnimationCallback( - private val view: View, - dispatchMode: Int = DISPATCH_MODE_STOP -) : WindowInsetsAnimationCompat.Callback(dispatchMode) { - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: List - ): WindowInsetsCompat { - // no-op and return the insets - return insets - } - - override fun onEnd(animation: WindowInsetsAnimationCompat) { - if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) { - // The animation has now finished, so we can check the view's focus state. - // We post the check because the rootWindowInsets has not yet been updated, but will - // be in the next message traversal - view.post { - checkFocus() - } - } - } - - private fun checkFocus() { - val imeVisible = ViewCompat.getRootWindowInsets(view) - ?.isVisible(WindowInsetsCompat.Type.ime()) == true - if (imeVisible && view.rootView.findFocus() == null) { - // If the IME will be visible, and there is not a currently focused view in - // the hierarchy, request focus on our view - view.requestFocus() - } else if (!imeVisible && view.isFocused) { - // If the IME will not be visible and our view is currently focused, clear the focus - view.clearFocus() - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/RootViewDeferringInsetsCallback.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/RootViewDeferringInsetsCallback.kt deleted file mode 100644 index c7b2fe6a7..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/RootViewDeferringInsetsCallback.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.flxrs.dankchat.utils.insets - -import android.view.View -import androidx.core.view.OnApplyWindowInsetsListener -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsAnimationCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding - -/** - * A class which extends/implements both [WindowInsetsAnimationCompat.Callback] and - * [View.OnApplyWindowInsetsListener], which should be set on the root view in your layout. - * - * This class enables the root view is selectively defer handling any insets which match - * [deferredInsetTypes], to enable better looking [WindowInsetsAnimationCompat]s. - * - * An example is the following: when a [WindowInsetsAnimationCompat] is started, the system will dispatch - * a [WindowInsetsCompat] instance which contains the end state of the animation. For the scenario of - * the IME being animated in, that means that the insets contains the IME height. If the view's - * [View.OnApplyWindowInsetsListener] simply always applied the combination of - * [WindowInsetsCompat.Type.ime] and [WindowInsetsCompat.Type.systemBars] using padding, the viewport of any - * child views would then be smaller. This results in us animating a smaller (padded-in) view into - * a larger viewport. Visually, this results in the views looking clipped. - * - * This class allows us to implement a different strategy for the above scenario, by selectively - * deferring the [WindowInsetsCompat.Type.ime] insets until the [WindowInsetsAnimationCompat] is ended. - * For the above example, you would create a [RootViewDeferringInsetsCallback] like so: - * - * ``` - * val callback = RootViewDeferringInsetsCallback( - * persistentInsetTypes = WindowInsetsCompat.Type.systemBars(), - * deferredInsetTypes = WindowInsetsCompat.Type.ime() - * ) - * ``` - * - * This class is not limited to just IME animations, and can work with any [WindowInsetsCompat.Type]s. - * - * @param persistentInsetTypes the bitmask of any inset types which should always be handled - * through padding the attached view - * @param deferredInsetTypes the bitmask of insets types which should be deferred until after - * any related [WindowInsetsAnimationCompat]s have ended - */ -class RootViewDeferringInsetsCallback( - val persistentInsetTypes: Int, - val deferredInsetTypes: Int, - val ignorePersistentInsetTypes: () -> Boolean = { false }, -) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE), - OnApplyWindowInsetsListener { - init { - require(persistentInsetTypes and deferredInsetTypes == 0) { - "persistentInsetTypes and deferredInsetTypes can not contain any of " + - " same WindowInsetsCompat.Type values" - } - } - - private var view: View? = null - private var lastWindowInsets: WindowInsetsCompat? = null - - private var deferredInsets = false - - override fun onApplyWindowInsets( - v: View, - windowInsets: WindowInsetsCompat - ): WindowInsetsCompat { - // Store the view and insets for us in onEnd() below - view = v - lastWindowInsets = windowInsets - - val ignorePersistentTypes = ignorePersistentInsetTypes() - val persistentOrZero = persistentInsetTypes.takeIf { deferredInsets || !ignorePersistentTypes } ?: 0 - val deferredOrZero = deferredInsetTypes.takeUnless { deferredInsets } ?: 0 - val types = persistentOrZero or deferredOrZero - - // Finally we apply the resolved insets by setting them as padding - val typeInsets = windowInsets.getInsets(types) - v.updatePadding( - left = typeInsets.left.takeUnless { deferredInsets && ignorePersistentTypes } ?: v.paddingLeft, - right = typeInsets.right.takeUnless { deferredInsets && ignorePersistentTypes } ?: v.paddingRight, - top = typeInsets.top.takeUnless { deferredInsets && ignorePersistentTypes } ?: v.paddingTop, - bottom = typeInsets.bottom - ) - - return ViewCompat.onApplyWindowInsets(v, windowInsets) - } - - override fun onPrepare(animation: WindowInsetsAnimationCompat) { - if (animation.typeMask and deferredInsetTypes != 0) { - // We defer the WindowInsetsCompat.Type.ime() insets if the IME is currently not visible. - // This results in only the WindowInsetsCompat.Type.systemBars() being applied, allowing - // the scrolling view to remain at it's larger size. - deferredInsets = true - if (lastWindowInsets != null && view != null) { - ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!) - } - } - } - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnims: List - ): WindowInsetsCompat { - // This is a no-op. We don't actually want to handle any WindowInsetsAnimations - return insets - } - - override fun onEnd(animation: WindowInsetsAnimationCompat) { - if (deferredInsets && (animation.typeMask and deferredInsetTypes) != 0) { - // If we deferred the IME insets and an IME animation has finished, we need to reset - // the flag - deferredInsets = false - - // And finally dispatch the deferred insets to the view now. - // Ideally we would just call view.requestApplyInsets() and let the normal dispatch - // cycle happen, but this happens too late resulting in a visual flicker. - // Instead we manually dispatch the most recent WindowInsets to the view. - if (lastWindowInsets != null && view != null) { - ViewCompat.dispatchApplyWindowInsets(view!!, lastWindowInsets!!) - } - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/TranslateDeferringInsetsAnimationCallback.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/TranslateDeferringInsetsAnimationCallback.kt deleted file mode 100644 index 22a5e798d..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/insets/TranslateDeferringInsetsAnimationCallback.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.flxrs.dankchat.utils.insets - -import android.view.View -import androidx.core.graphics.Insets -import androidx.core.view.WindowInsetsAnimationCompat -import androidx.core.view.WindowInsetsCompat - -/** - * A [WindowInsetsAnimationCompat.Callback] which will translate/move the given view during any - * inset animations of the given inset type. - * - * This class works in tandem with [RootViewDeferringInsetsCallback] to support the deferring of - * certain [WindowInsetsCompat.Type] values during a [WindowInsetsAnimationCompat], provided in - * [deferredInsetTypes]. The values passed into this constructor should match those which - * the [RootViewDeferringInsetsCallback] is created with. - * - * @param view the view to translate from it's start to end state - * @param persistentInsetTypes the bitmask of any inset types which were handled as part of the - * layout - * @param deferredInsetTypes the bitmask of insets types which should be deferred until after - * any [WindowInsetsAnimationCompat]s have ended - * @param dispatchMode The dispatch mode for this callback. - * See [WindowInsetsAnimationCompat.Callback.getDispatchMode]. - */ -class TranslateDeferringInsetsAnimationCallback( - private val view: View, - val persistentInsetTypes: Int, - val deferredInsetTypes: Int, - dispatchMode: Int = DISPATCH_MODE_STOP -) : WindowInsetsAnimationCompat.Callback(dispatchMode) { - init { - require(persistentInsetTypes and deferredInsetTypes == 0) { - "persistentInsetTypes and deferredInsetTypes can not contain any of " + - " same WindowInsetsCompat.Type values" - } - } - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: List - ): WindowInsetsCompat { - // onProgress() is called when any of the running animations progress... - - // First we get the insets which are potentially deferred - val typesInset = insets.getInsets(deferredInsetTypes) - // Then we get the persistent inset types which are applied as padding during layout - val otherInset = insets.getInsets(persistentInsetTypes) - - // Now that we subtract the two insets, to calculate the difference. We also coerce - // the insets to be >= 0, to make sure we don't use negative insets. - val diff = Insets.subtract(typesInset, otherInset).let { - Insets.max(it, Insets.NONE) - } - - // The resulting `diff` insets contain the values for us to apply as a translation - // to the view - view.translationX = (diff.left - diff.right).toFloat() - view.translationY = (diff.top - diff.bottom).toFloat() - - return insets - } - - override fun onEnd(animation: WindowInsetsAnimationCompat) { - // Once the animation has ended, reset the translation values - view.translationX = 0f - view.translationY = 0f - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/ImprovedBulletSpan.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/span/ImprovedBulletSpan.kt deleted file mode 100644 index deed0e0c4..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/ImprovedBulletSpan.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.flxrs.dankchat.utils.span - -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import android.graphics.Path.Direction -import android.text.Layout -import android.text.Spanned -import android.text.style.LeadingMarginSpan -import androidx.annotation.Px - -class ImprovedBulletSpan( - @param:Px private val bulletRadius: Int = STANDARD_BULLET_RADIUS, - @param:Px private val gapWidth: Int = STANDARD_GAP_WIDTH, - private val color: Int = STANDARD_COLOR -) : LeadingMarginSpan { - - companion object { - // Bullet is slightly bigger to avoid aliasing artifacts on mdpi devices. - private const val STANDARD_BULLET_RADIUS = 4 - private const val STANDARD_GAP_WIDTH = 2 - private const val STANDARD_COLOR = 0 - } - - private var mBulletPath: Path? = null - - override fun getLeadingMargin(first: Boolean): Int { - return 2 * bulletRadius + gapWidth - } - - override fun drawLeadingMargin(canvas: Canvas, paint: Paint, x: Int, dir: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence, start: Int, end: Int, first: Boolean, layout: Layout?) { - if ((text as Spanned).getSpanStart(this) == start) { - val style = paint.style - val oldColor = paint.color - - paint.style = Paint.Style.FILL - if (color != STANDARD_COLOR) { - paint.color = color - } - - val yPosition = when { - layout != null -> layout.getLineBaseline(layout.getLineForOffset(start)).toFloat() - bulletRadius * 2f - else -> (top + bottom) / 2f - } - - val xPosition = (x + dir * bulletRadius).toFloat() - - if (canvas.isHardwareAccelerated) { - if (mBulletPath == null) { - mBulletPath = Path() - mBulletPath!!.addCircle(0.0f, 0.0f, bulletRadius.toFloat(), Direction.CW) - } - - with(canvas) { - save() - translate(xPosition, yPosition) - drawPath(mBulletPath!!, paint) - restore() - } - } else { - canvas.drawCircle(xPosition, yPosition, bulletRadius.toFloat(), paint) - } - - paint.style = style - paint.color = oldColor - } - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickLinkMovementMethod.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickLinkMovementMethod.kt deleted file mode 100644 index 5c647c3ba..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickLinkMovementMethod.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.flxrs.dankchat.utils.span - -import android.os.Handler -import android.os.Looper -import android.text.Selection -import android.text.Spannable -import android.text.method.LinkMovementMethod -import android.view.MotionEvent -import android.widget.TextView -import androidx.core.os.postDelayed - -object LongClickLinkMovementMethod : LinkMovementMethod() { - private const val LONG_CLICK_TIME = 500L - private const val CLICKABLE_OFFSET = 10 - private var isLongPressed = false - private val longClickHandler = Handler(Looper.getMainLooper()) - - override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { - when (val action = event.action) { - MotionEvent.ACTION_CANCEL -> longClickHandler.removeCallbacksAndMessages(null) - MotionEvent.ACTION_UP, MotionEvent.ACTION_DOWN -> { - var x = event.x.toInt() - var y = event.y.toInt() - x -= widget.totalPaddingLeft - y -= widget.totalPaddingTop - x += widget.scrollX - y += widget.scrollY - - val layout = widget.layout - val line = layout.getLineForVertical(y) - val offset = layout.getOffsetForHorizontal(line, x.toFloat()) - - val linkSpans = buffer.getSpans(offset, offset, LongClickableSpan::class.java) - if (linkSpans.isEmpty()) { - return super.onTouchEvent(widget, buffer, event) - } - - val span = linkSpans.find { span -> - if (!span.checkBounds) { - return@find true - } - - val start = buffer.getSpanStart(span) - val end = buffer.getSpanEnd(span) - var startPos = layout.getPrimaryHorizontal(start).toInt() - var endPos = layout.getPrimaryHorizontal(end).toInt() - - val lineStart = layout.getLineForOffset(start) - val lineEnd = layout.getLineForOffset(end) - - if (lineStart != lineEnd) { - val multiLineStart = layout.getLineStart(line) - val multiLineEnd = layout.getLineEnd(line) - val multiLineStartPos = layout.getPrimaryHorizontal(multiLineStart).toInt() - val multiLineEndPos = layout.getPrimaryHorizontal(multiLineEnd).toInt() - .takeIf { it != 0 } - ?: layout.getPrimaryHorizontal(multiLineEnd - 1).toInt() - - when (line) { - lineStart -> endPos = multiLineEndPos - lineEnd -> startPos = multiLineStartPos - else -> { - startPos = multiLineStartPos - endPos = multiLineEndPos - } - } - } - - val range = when { - startPos <= endPos -> startPos - CLICKABLE_OFFSET..endPos + CLICKABLE_OFFSET - else -> endPos - CLICKABLE_OFFSET..startPos + CLICKABLE_OFFSET - } - x in range - } ?: return true - - if (action == MotionEvent.ACTION_UP) { - longClickHandler.removeCallbacksAndMessages(null) - if (!isLongPressed) { - span.onClick(widget) - } - isLongPressed = false - } else { - Selection.setSelection(buffer, buffer.getSpanStart(span), buffer.getSpanEnd(span)) - longClickHandler.postDelayed(LONG_CLICK_TIME) { - span.onLongClick(widget) - isLongPressed = true - } - } - - return true - } - } - return super.onTouchEvent(widget, buffer, event) - } -} diff --git a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickableSpan.kt b/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickableSpan.kt deleted file mode 100644 index 208b2f65f..000000000 --- a/app/src/main/kotlin/com/flxrs/dankchat/utils/span/LongClickableSpan.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flxrs.dankchat.utils.span - -import android.text.style.ClickableSpan -import android.view.View - -abstract class LongClickableSpan(val checkBounds: Boolean = true) : ClickableSpan() { - abstract fun onLongClick(view: View) -} diff --git a/app/src/main/res/anim/close_exit.xml b/app/src/main/res/anim/close_exit.xml deleted file mode 100644 index 696f0585c..000000000 --- a/app/src/main/res/anim/close_exit.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/fast_out_extra_slow_in.xml b/app/src/main/res/anim/fast_out_extra_slow_in.xml deleted file mode 100644 index c16e16ce6..000000000 --- a/app/src/main/res/anim/fast_out_extra_slow_in.xml +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/anim/open_enter.xml b/app/src/main/res/anim/open_enter.xml deleted file mode 100644 index 6c23d076a..000000000 --- a/app/src/main/res/anim/open_enter.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/ic_automod_badge.png b/app/src/main/res/drawable-nodpi/ic_automod_badge.png new file mode 100644 index 000000000..e1f468b74 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_automod_badge.png differ diff --git a/app/src/main/res/drawable/ic_3p.xml b/app/src/main/res/drawable/ic_3p.xml deleted file mode 100644 index d6dea4241..000000000 --- a/app/src/main/res/drawable/ic_3p.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add_box.xml b/app/src/main/res/drawable/ic_add_box.xml deleted file mode 100644 index 1671e13f7..000000000 --- a/app/src/main/res/drawable/ic_add_box.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_alternate_email.xml b/app/src/main/res/drawable/ic_alternate_email.xml deleted file mode 100644 index 44e73a7eb..000000000 --- a/app/src/main/res/drawable/ic_alternate_email.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_android.xml b/app/src/main/res/drawable/ic_android.xml deleted file mode 100644 index 5c647bbb8..000000000 --- a/app/src/main/res/drawable/ic_android.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_block.xml b/app/src/main/res/drawable/ic_block.xml deleted file mode 100644 index 63f329f30..000000000 --- a/app/src/main/res/drawable/ic_block.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml deleted file mode 100644 index 89f03149c..000000000 --- a/app/src/main/res/drawable/ic_copy.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_dank_chat_mono_cropped.xml b/app/src/main/res/drawable/ic_dank_chat_mono_cropped.xml new file mode 100644 index 000000000..b15e309a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_dank_chat_mono_cropped.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_drag_indicator.xml b/app/src/main/res/drawable/ic_drag_indicator.xml deleted file mode 100644 index fa858771a..000000000 --- a/app/src/main/res/drawable/ic_drag_indicator.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_forum.xml b/app/src/main/res/drawable/ic_forum.xml deleted file mode 100644 index 2f776ebb5..000000000 --- a/app/src/main/res/drawable/ic_forum.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_gavel.xml b/app/src/main/res/drawable/ic_gavel.xml deleted file mode 100644 index c83353e96..000000000 --- a/app/src/main/res/drawable/ic_gavel.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_insert_emoticon.xml b/app/src/main/res/drawable/ic_insert_emoticon.xml deleted file mode 100644 index cb1fa12ff..000000000 --- a/app/src/main/res/drawable/ic_insert_emoticon.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml b/app/src/main/res/drawable/ic_keyboard_arrow_down.xml deleted file mode 100644 index 68fd4058e..000000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_up.xml b/app/src/main/res/drawable/ic_keyboard_arrow_up.xml deleted file mode 100644 index ef2e9825c..000000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_missing_emote.xml b/app/src/main/res/drawable/ic_missing_emote.xml deleted file mode 100644 index 9a3e666d9..000000000 --- a/app/src/main/res/drawable/ic_missing_emote.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml deleted file mode 100644 index d85cdfadd..000000000 --- a/app/src/main/res/drawable/ic_more_vert.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_notifications_active.xml b/app/src/main/res/drawable/ic_notifications_active.xml deleted file mode 100644 index 70d2420be..000000000 --- a/app/src/main/res/drawable/ic_notifications_active.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_open_browser.xml b/app/src/main/res/drawable/ic_open_browser.xml deleted file mode 100644 index 3aeed3c7d..000000000 --- a/app/src/main/res/drawable/ic_open_browser.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index 07eeb5ad8..000000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_reply.xml b/app/src/main/res/drawable/ic_reply.xml deleted file mode 100644 index bd35ee9e7..000000000 --- a/app/src/main/res/drawable/ic_reply.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_reply_small.xml b/app/src/main/res/drawable/ic_reply_small.xml deleted file mode 100644 index 230193cc5..000000000 --- a/app/src/main/res/drawable/ic_reply_small.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_report.xml b/app/src/main/res/drawable/ic_report.xml deleted file mode 100644 index 57f69c9a8..000000000 --- a/app/src/main/res/drawable/ic_report.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml deleted file mode 100644 index c54d3aa8d..000000000 --- a/app/src/main/res/drawable/ic_send.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_time.xml b/app/src/main/res/drawable/ic_time.xml deleted file mode 100644 index 968a42eb3..000000000 --- a/app/src/main/res/drawable/ic_time.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_undo.xml b/app/src/main/res/drawable/ic_undo.xml deleted file mode 100644 index 1a76d048e..000000000 --- a/app/src/main/res/drawable/ic_undo.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_zoom_in.xml b/app/src/main/res/drawable/ic_zoom_in.xml deleted file mode 100644 index c6b683829..000000000 --- a/app/src/main/res/drawable/ic_zoom_in.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_zoom_out.xml b/app/src/main/res/drawable/ic_zoom_out.xml deleted file mode 100644 index 5039394d4..000000000 --- a/app/src/main/res/drawable/ic_zoom_out.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ripple_with_background.xml b/app/src/main/res/drawable/ripple_with_background.xml deleted file mode 100644 index 689989b96..000000000 --- a/app/src/main/res/drawable/ripple_with_background.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/shared_chat.png b/app/src/main/res/drawable/shared_chat.png deleted file mode 100644 index f9a66b17c..000000000 Binary files a/app/src/main/res/drawable/shared_chat.png and /dev/null differ diff --git a/app/src/main/res/layout-land/main_fragment.xml b/app/src/main/res/layout-land/main_fragment.xml deleted file mode 100644 index 75190a158..000000000 --- a/app/src/main/res/layout-land/main_fragment.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-v23/chat_item.xml b/app/src/main/res/layout-v23/chat_item.xml deleted file mode 100644 index adcfd7900..000000000 --- a/app/src/main/res/layout-v23/chat_item.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/add_channel_dialog.xml b/app/src/main/res/layout/add_channel_dialog.xml deleted file mode 100644 index b1f279e44..000000000 --- a/app/src/main/res/layout/add_channel_dialog.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/changelog_bottomsheet.xml b/app/src/main/res/layout/changelog_bottomsheet.xml deleted file mode 100644 index 39d84a242..000000000 --- a/app/src/main/res/layout/changelog_bottomsheet.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/changelog_item.xml b/app/src/main/res/layout/changelog_item.xml deleted file mode 100644 index e78091f98..000000000 --- a/app/src/main/res/layout/changelog_item.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/app/src/main/res/layout/channels_fragment.xml b/app/src/main/res/layout/channels_fragment.xml deleted file mode 100644 index 1f50dd28f..000000000 --- a/app/src/main/res/layout/channels_fragment.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/channels_item.xml b/app/src/main/res/layout/channels_item.xml deleted file mode 100644 index 143ecc0de..000000000 --- a/app/src/main/res/layout/channels_item.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/chat_fragment.xml b/app/src/main/res/layout/chat_fragment.xml deleted file mode 100644 index af3b91c4d..000000000 --- a/app/src/main/res/layout/chat_fragment.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/chat_item.xml b/app/src/main/res/layout/chat_item.xml deleted file mode 100644 index b0e57b918..000000000 --- a/app/src/main/res/layout/chat_item.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/edit_dialog.xml b/app/src/main/res/layout/edit_dialog.xml deleted file mode 100644 index 2ca683f72..000000000 --- a/app/src/main/res/layout/edit_dialog.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/layout/emote_bottomsheet.xml b/app/src/main/res/layout/emote_bottomsheet.xml deleted file mode 100644 index 0f5621a91..000000000 --- a/app/src/main/res/layout/emote_bottomsheet.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/emote_bottomsheet_item.xml b/app/src/main/res/layout/emote_bottomsheet_item.xml deleted file mode 100644 index 597aa09c2..000000000 --- a/app/src/main/res/layout/emote_bottomsheet_item.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/emote_header_item.xml b/app/src/main/res/layout/emote_header_item.xml deleted file mode 100644 index c445ef793..000000000 --- a/app/src/main/res/layout/emote_header_item.xml +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/app/src/main/res/layout/emote_item.xml b/app/src/main/res/layout/emote_item.xml deleted file mode 100644 index 1faf94e16..000000000 --- a/app/src/main/res/layout/emote_item.xml +++ /dev/null @@ -1,10 +0,0 @@ - - diff --git a/app/src/main/res/layout/emote_menu_fragment.xml b/app/src/main/res/layout/emote_menu_fragment.xml deleted file mode 100644 index 3a41be5ed..000000000 --- a/app/src/main/res/layout/emote_menu_fragment.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/emote_suggestion_item.xml b/app/src/main/res/layout/emote_suggestion_item.xml deleted file mode 100644 index 3940d943b..000000000 --- a/app/src/main/res/layout/emote_suggestion_item.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/fragment_stream_web_view_wrapper.xml b/app/src/main/res/layout/fragment_stream_web_view_wrapper.xml deleted file mode 100644 index ab740e3cb..000000000 --- a/app/src/main/res/layout/fragment_stream_web_view_wrapper.xml +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/app/src/main/res/layout/login_fragment.xml b/app/src/main/res/layout/login_fragment.xml deleted file mode 100644 index a2327d201..000000000 --- a/app/src/main/res/layout/login_fragment.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml deleted file mode 100644 index b616de456..000000000 --- a/app/src/main/res/layout/main_activity.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/main_fragment.xml b/app/src/main/res/layout/main_fragment.xml deleted file mode 100644 index 0dfec977f..000000000 --- a/app/src/main/res/layout/main_fragment.xml +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/mention_fragment.xml b/app/src/main/res/layout/mention_fragment.xml deleted file mode 100644 index 2509ee8d3..000000000 --- a/app/src/main/res/layout/mention_fragment.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/menu_tab_list.xml b/app/src/main/res/layout/menu_tab_list.xml deleted file mode 100644 index 1d78ed877..000000000 --- a/app/src/main/res/layout/menu_tab_list.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/app/src/main/res/layout/message_bottomsheet.xml b/app/src/main/res/layout/message_bottomsheet.xml deleted file mode 100644 index 1bb54ec46..000000000 --- a/app/src/main/res/layout/message_bottomsheet.xml +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/more_actions_message_bottomsheet.xml b/app/src/main/res/layout/more_actions_message_bottomsheet.xml deleted file mode 100644 index 6d59f0b83..000000000 --- a/app/src/main/res/layout/more_actions_message_bottomsheet.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/replies_fragment.xml b/app/src/main/res/layout/replies_fragment.xml deleted file mode 100644 index 6f00045f6..000000000 --- a/app/src/main/res/layout/replies_fragment.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/reply_sheet_fragment.xml b/app/src/main/res/layout/reply_sheet_fragment.xml deleted file mode 100644 index 3dd6b341b..000000000 --- a/app/src/main/res/layout/reply_sheet_fragment.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/layout/timeout_dialog.xml b/app/src/main/res/layout/timeout_dialog.xml deleted file mode 100644 index f46a9e59b..000000000 --- a/app/src/main/res/layout/timeout_dialog.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/user_popup_badge_item.xml b/app/src/main/res/layout/user_popup_badge_item.xml deleted file mode 100644 index cb0ce38f5..000000000 --- a/app/src/main/res/layout/user_popup_badge_item.xml +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/app/src/main/res/layout/user_popup_bottomsheet.xml b/app/src/main/res/layout/user_popup_bottomsheet.xml deleted file mode 100644 index 637163979..000000000 --- a/app/src/main/res/layout/user_popup_bottomsheet.xml +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/login_menu.xml b/app/src/main/res/menu/login_menu.xml deleted file mode 100644 index 243b622ce..000000000 --- a/app/src/main/res/menu/login_menu.xml +++ /dev/null @@ -1,19 +0,0 @@ - -

- - - - diff --git a/app/src/main/res/menu/menu.xml b/app/src/main/res/menu/menu.xml deleted file mode 100644 index def08a131..000000000 --- a/app/src/main/res/menu/menu.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml deleted file mode 100644 index 5cf1a1801..000000000 --- a/app/src/main/res/navigation/nav_graph.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/raw/emoji_data.json b/app/src/main/res/raw/emoji_data.json new file mode 100644 index 000000000..d1ee4ae29 --- /dev/null +++ b/app/src/main/res/raw/emoji_data.json @@ -0,0 +1 @@ +[{"code":"+1","unicode":"👍"},{"code":"-1","unicode":"👎"},{"code":"100","unicode":"💯"},{"code":"1234","unicode":"🔢"},{"code":"8ball","unicode":"🎱"},{"code":"a","unicode":"🅰️"},{"code":"ab","unicode":"🆎"},{"code":"abacus","unicode":"🧮"},{"code":"abc","unicode":"🔤"},{"code":"abcd","unicode":"🔡"},{"code":"accept","unicode":"🉑"},{"code":"accordion","unicode":"🪗"},{"code":"adhesive_bandage","unicode":"🩹"},{"code":"admission_tickets","unicode":"🎟️"},{"code":"adult","unicode":"🧑"},{"code":"aerial_tramway","unicode":"🚡"},{"code":"airplane","unicode":"✈️"},{"code":"airplane_arriving","unicode":"🛬"},{"code":"airplane_departure","unicode":"🛫"},{"code":"alarm_clock","unicode":"⏰"},{"code":"alembic","unicode":"⚗️"},{"code":"alien","unicode":"👽"},{"code":"ambulance","unicode":"🚑"},{"code":"amphora","unicode":"🏺"},{"code":"anatomical_heart","unicode":"🫀"},{"code":"anchor","unicode":"⚓"},{"code":"angel","unicode":"👼"},{"code":"anger","unicode":"💢"},{"code":"angry","unicode":"😠"},{"code":"anguished","unicode":"😧"},{"code":"ant","unicode":"🐜"},{"code":"apple","unicode":"🍎"},{"code":"aquarius","unicode":"♒"},{"code":"aries","unicode":"♈"},{"code":"arrow_backward","unicode":"◀️"},{"code":"arrow_double_down","unicode":"⏬"},{"code":"arrow_double_up","unicode":"⏫"},{"code":"arrow_down","unicode":"⬇️"},{"code":"arrow_down_small","unicode":"🔽"},{"code":"arrow_forward","unicode":"▶️"},{"code":"arrow_heading_down","unicode":"⤵️"},{"code":"arrow_heading_up","unicode":"⤴️"},{"code":"arrow_left","unicode":"⬅️"},{"code":"arrow_lower_left","unicode":"↙️"},{"code":"arrow_lower_right","unicode":"↘️"},{"code":"arrow_right","unicode":"➡️"},{"code":"arrow_right_hook","unicode":"↪️"},{"code":"arrow_up","unicode":"⬆️"},{"code":"arrow_up_down","unicode":"↕️"},{"code":"arrow_up_small","unicode":"🔼"},{"code":"arrow_upper_left","unicode":"↖️"},{"code":"arrow_upper_right","unicode":"↗️"},{"code":"arrows_clockwise","unicode":"🔃"},{"code":"arrows_counterclockwise","unicode":"🔄"},{"code":"art","unicode":"🎨"},{"code":"articulated_lorry","unicode":"🚛"},{"code":"artist","unicode":"🧑‍🎨"},{"code":"astonished","unicode":"😲"},{"code":"astronaut","unicode":"🧑‍🚀"},{"code":"athletic_shoe","unicode":"👟"},{"code":"atm","unicode":"🏧"},{"code":"atom_symbol","unicode":"⚛️"},{"code":"auto_rickshaw","unicode":"🛺"},{"code":"avocado","unicode":"🥑"},{"code":"axe","unicode":"🪓"},{"code":"b","unicode":"🅱️"},{"code":"baby","unicode":"👶"},{"code":"baby_bottle","unicode":"🍼"},{"code":"baby_chick","unicode":"🐤"},{"code":"baby_symbol","unicode":"🚼"},{"code":"back","unicode":"🔙"},{"code":"bacon","unicode":"🥓"},{"code":"badger","unicode":"🦡"},{"code":"badminton_racquet_and_shuttlecock","unicode":"🏸"},{"code":"bagel","unicode":"🥯"},{"code":"baggage_claim","unicode":"🛄"},{"code":"baguette_bread","unicode":"🥖"},{"code":"bald_man","unicode":"👨‍🦲"},{"code":"bald_person","unicode":"🧑‍🦲"},{"code":"bald_woman","unicode":"👩‍🦲"},{"code":"ballet_dancer","unicode":"🧑‍🩰"},{"code":"ballet_shoes","unicode":"🩰"},{"code":"balloon","unicode":"🎈"},{"code":"ballot_box_with_ballot","unicode":"🗳️"},{"code":"ballot_box_with_check","unicode":"☑️"},{"code":"bamboo","unicode":"🎍"},{"code":"banana","unicode":"🍌"},{"code":"bangbang","unicode":"‼️"},{"code":"banjo","unicode":"🪕"},{"code":"bank","unicode":"🏦"},{"code":"bar_chart","unicode":"📊"},{"code":"barber","unicode":"💈"},{"code":"barely_sunny","unicode":"🌥️"},{"code":"baseball","unicode":"⚾"},{"code":"basket","unicode":"🧺"},{"code":"basketball","unicode":"🏀"},{"code":"bat","unicode":"🦇"},{"code":"bath","unicode":"🛀"},{"code":"bathtub","unicode":"🛁"},{"code":"battery","unicode":"🔋"},{"code":"beach_with_umbrella","unicode":"🏖️"},{"code":"beans","unicode":"🫘"},{"code":"bear","unicode":"🐻"},{"code":"bearded_person","unicode":"🧔"},{"code":"beaver","unicode":"🦫"},{"code":"bed","unicode":"🛏️"},{"code":"bee","unicode":"🐝"},{"code":"beer","unicode":"🍺"},{"code":"beers","unicode":"🍻"},{"code":"beetle","unicode":"🪲"},{"code":"beginner","unicode":"🔰"},{"code":"bell","unicode":"🔔"},{"code":"bell_pepper","unicode":"🫑"},{"code":"bellhop_bell","unicode":"🛎️"},{"code":"bento","unicode":"🍱"},{"code":"beverage_box","unicode":"🧃"},{"code":"bicyclist","unicode":"🚴"},{"code":"bike","unicode":"🚲"},{"code":"bikini","unicode":"👙"},{"code":"billed_cap","unicode":"🧢"},{"code":"biohazard_sign","unicode":"☣️"},{"code":"bird","unicode":"🐦"},{"code":"birthday","unicode":"🎂"},{"code":"bison","unicode":"🦬"},{"code":"biting_lip","unicode":"🫦"},{"code":"black_bird","unicode":"🐦‍⬛"},{"code":"black_cat","unicode":"🐈‍⬛"},{"code":"black_circle","unicode":"⚫"},{"code":"black_circle_for_record","unicode":"⏺️"},{"code":"black_heart","unicode":"🖤"},{"code":"black_joker","unicode":"🃏"},{"code":"black_large_square","unicode":"⬛"},{"code":"black_left_pointing_double_triangle_with_vertical_bar","unicode":"⏮️"},{"code":"black_medium_small_square","unicode":"◾"},{"code":"black_medium_square","unicode":"◼️"},{"code":"black_nib","unicode":"✒️"},{"code":"black_right_pointing_double_triangle_with_vertical_bar","unicode":"⏭️"},{"code":"black_right_pointing_triangle_with_double_vertical_bar","unicode":"⏯️"},{"code":"black_small_square","unicode":"▪️"},{"code":"black_square_button","unicode":"🔲"},{"code":"black_square_for_stop","unicode":"⏹️"},{"code":"blond-haired-man","unicode":"👱‍♂️"},{"code":"blond-haired-woman","unicode":"👱‍♀️"},{"code":"blossom","unicode":"🌼"},{"code":"blowfish","unicode":"🐡"},{"code":"blue_book","unicode":"📘"},{"code":"blue_car","unicode":"🚙"},{"code":"blue_heart","unicode":"💙"},{"code":"blueberries","unicode":"🫐"},{"code":"blush","unicode":"😊"},{"code":"boar","unicode":"🐗"},{"code":"boat","unicode":"⛵"},{"code":"bomb","unicode":"💣"},{"code":"bone","unicode":"🦴"},{"code":"book","unicode":"📖"},{"code":"bookmark","unicode":"🔖"},{"code":"bookmark_tabs","unicode":"📑"},{"code":"books","unicode":"📚"},{"code":"boom","unicode":"💥"},{"code":"boomerang","unicode":"🪃"},{"code":"boot","unicode":"👢"},{"code":"bouquet","unicode":"💐"},{"code":"bow","unicode":"🙇"},{"code":"bow_and_arrow","unicode":"🏹"},{"code":"bowl_with_spoon","unicode":"🥣"},{"code":"bowling","unicode":"🎳"},{"code":"boxing_glove","unicode":"🥊"},{"code":"boy","unicode":"👦"},{"code":"brain","unicode":"🧠"},{"code":"bread","unicode":"🍞"},{"code":"breast-feeding","unicode":"🤱"},{"code":"bricks","unicode":"🧱"},{"code":"bride_with_veil","unicode":"👰"},{"code":"bridge_at_night","unicode":"🌉"},{"code":"briefcase","unicode":"💼"},{"code":"briefs","unicode":"🩲"},{"code":"broccoli","unicode":"🥦"},{"code":"broken_chain","unicode":"⛓️‍💥"},{"code":"broken_heart","unicode":"💔"},{"code":"broom","unicode":"🧹"},{"code":"brown_heart","unicode":"🤎"},{"code":"brown_mushroom","unicode":"🍄‍🟫"},{"code":"bubble_tea","unicode":"🧋"},{"code":"bubbles","unicode":"🫧"},{"code":"bucket","unicode":"🪣"},{"code":"bug","unicode":"🐛"},{"code":"building_construction","unicode":"🏗️"},{"code":"bulb","unicode":"💡"},{"code":"bullettrain_front","unicode":"🚅"},{"code":"bullettrain_side","unicode":"🚄"},{"code":"burrito","unicode":"🌯"},{"code":"bus","unicode":"🚌"},{"code":"busstop","unicode":"🚏"},{"code":"bust_in_silhouette","unicode":"👤"},{"code":"busts_in_silhouette","unicode":"👥"},{"code":"butter","unicode":"🧈"},{"code":"butterfly","unicode":"🦋"},{"code":"cactus","unicode":"🌵"},{"code":"cake","unicode":"🍰"},{"code":"calendar","unicode":"📆"},{"code":"call_me_hand","unicode":"🤙"},{"code":"calling","unicode":"📲"},{"code":"camel","unicode":"🐫"},{"code":"camera","unicode":"📷"},{"code":"camera_with_flash","unicode":"📸"},{"code":"camping","unicode":"🏕️"},{"code":"cancer","unicode":"♋"},{"code":"candle","unicode":"🕯️"},{"code":"candy","unicode":"🍬"},{"code":"canned_food","unicode":"🥫"},{"code":"canoe","unicode":"🛶"},{"code":"capital_abcd","unicode":"🔠"},{"code":"capricorn","unicode":"♑"},{"code":"car","unicode":"🚗"},{"code":"card_file_box","unicode":"🗃️"},{"code":"card_index","unicode":"📇"},{"code":"card_index_dividers","unicode":"🗂️"},{"code":"carousel_horse","unicode":"🎠"},{"code":"carpentry_saw","unicode":"🪚"},{"code":"carrot","unicode":"🥕"},{"code":"cat","unicode":"🐱"},{"code":"cat2","unicode":"🐈"},{"code":"cd","unicode":"💿"},{"code":"chains","unicode":"⛓️"},{"code":"chair","unicode":"🪑"},{"code":"champagne","unicode":"🍾"},{"code":"chart","unicode":"💹"},{"code":"chart_with_downwards_trend","unicode":"📉"},{"code":"chart_with_upwards_trend","unicode":"📈"},{"code":"checkered_flag","unicode":"🏁"},{"code":"cheese_wedge","unicode":"🧀"},{"code":"cherries","unicode":"🍒"},{"code":"cherry_blossom","unicode":"🌸"},{"code":"chess_pawn","unicode":"♟️"},{"code":"chestnut","unicode":"🌰"},{"code":"chicken","unicode":"🐔"},{"code":"child","unicode":"🧒"},{"code":"children_crossing","unicode":"🚸"},{"code":"chipmunk","unicode":"🐿️"},{"code":"chocolate_bar","unicode":"🍫"},{"code":"chopsticks","unicode":"🥢"},{"code":"christmas_tree","unicode":"🎄"},{"code":"church","unicode":"⛪"},{"code":"cinema","unicode":"🎦"},{"code":"circus_tent","unicode":"🎪"},{"code":"city_sunrise","unicode":"🌇"},{"code":"city_sunset","unicode":"🌆"},{"code":"cityscape","unicode":"🏙️"},{"code":"cl","unicode":"🆑"},{"code":"clap","unicode":"👏"},{"code":"clapper","unicode":"🎬"},{"code":"classical_building","unicode":"🏛️"},{"code":"clinking_glasses","unicode":"🥂"},{"code":"clipboard","unicode":"📋"},{"code":"clock1","unicode":"🕐"},{"code":"clock10","unicode":"🕙"},{"code":"clock1030","unicode":"🕥"},{"code":"clock11","unicode":"🕚"},{"code":"clock1130","unicode":"🕦"},{"code":"clock12","unicode":"🕛"},{"code":"clock1230","unicode":"🕧"},{"code":"clock130","unicode":"🕜"},{"code":"clock2","unicode":"🕑"},{"code":"clock230","unicode":"🕝"},{"code":"clock3","unicode":"🕒"},{"code":"clock330","unicode":"🕞"},{"code":"clock4","unicode":"🕓"},{"code":"clock430","unicode":"🕟"},{"code":"clock5","unicode":"🕔"},{"code":"clock530","unicode":"🕠"},{"code":"clock6","unicode":"🕕"},{"code":"clock630","unicode":"🕡"},{"code":"clock7","unicode":"🕖"},{"code":"clock730","unicode":"🕢"},{"code":"clock8","unicode":"🕗"},{"code":"clock830","unicode":"🕣"},{"code":"clock9","unicode":"🕘"},{"code":"clock930","unicode":"🕤"},{"code":"closed_book","unicode":"📕"},{"code":"closed_lock_with_key","unicode":"🔐"},{"code":"closed_umbrella","unicode":"🌂"},{"code":"cloud","unicode":"☁️"},{"code":"clown_face","unicode":"🤡"},{"code":"clubs","unicode":"♣️"},{"code":"cn","unicode":"🇨🇳"},{"code":"coat","unicode":"🧥"},{"code":"cockroach","unicode":"🪳"},{"code":"cocktail","unicode":"🍸"},{"code":"coconut","unicode":"🥥"},{"code":"coffee","unicode":"☕"},{"code":"coffin","unicode":"⚰️"},{"code":"coin","unicode":"🪙"},{"code":"cold_face","unicode":"🥶"},{"code":"cold_sweat","unicode":"😰"},{"code":"collision","unicode":"💥"},{"code":"comet","unicode":"☄️"},{"code":"compass","unicode":"🧭"},{"code":"compression","unicode":"🗜️"},{"code":"computer","unicode":"💻"},{"code":"confetti_ball","unicode":"🎊"},{"code":"confounded","unicode":"😖"},{"code":"confused","unicode":"😕"},{"code":"congratulations","unicode":"㊗️"},{"code":"construction","unicode":"🚧"},{"code":"construction_worker","unicode":"👷"},{"code":"control_knobs","unicode":"🎛️"},{"code":"convenience_store","unicode":"🏪"},{"code":"cook","unicode":"🧑‍🍳"},{"code":"cookie","unicode":"🍪"},{"code":"cooking","unicode":"🍳"},{"code":"cool","unicode":"🆒"},{"code":"cop","unicode":"👮"},{"code":"copyright","unicode":"©️"},{"code":"coral","unicode":"🪸"},{"code":"corn","unicode":"🌽"},{"code":"couch_and_lamp","unicode":"🛋️"},{"code":"couple","unicode":"👫"},{"code":"couple_with_heart","unicode":"💑"},{"code":"couplekiss","unicode":"💏"},{"code":"cow","unicode":"🐮"},{"code":"cow2","unicode":"🐄"},{"code":"crab","unicode":"🦀"},{"code":"credit_card","unicode":"💳"},{"code":"crescent_moon","unicode":"🌙"},{"code":"cricket","unicode":"🦗"},{"code":"cricket_bat_and_ball","unicode":"🏏"},{"code":"crocodile","unicode":"🐊"},{"code":"croissant","unicode":"🥐"},{"code":"crossed_fingers","unicode":"🤞"},{"code":"crossed_flags","unicode":"🎌"},{"code":"crossed_swords","unicode":"⚔️"},{"code":"crown","unicode":"👑"},{"code":"crutch","unicode":"🩼"},{"code":"cry","unicode":"😢"},{"code":"crying_cat_face","unicode":"😿"},{"code":"crystal_ball","unicode":"🔮"},{"code":"cucumber","unicode":"🥒"},{"code":"cup_with_straw","unicode":"🥤"},{"code":"cupcake","unicode":"🧁"},{"code":"cupid","unicode":"💘"},{"code":"curling_stone","unicode":"🥌"},{"code":"curly_haired_man","unicode":"👨‍🦱"},{"code":"curly_haired_person","unicode":"🧑‍🦱"},{"code":"curly_haired_woman","unicode":"👩‍🦱"},{"code":"curly_loop","unicode":"➰"},{"code":"currency_exchange","unicode":"💱"},{"code":"curry","unicode":"🍛"},{"code":"custard","unicode":"🍮"},{"code":"customs","unicode":"🛃"},{"code":"cut_of_meat","unicode":"🥩"},{"code":"cyclone","unicode":"🌀"},{"code":"dagger_knife","unicode":"🗡️"},{"code":"dancer","unicode":"💃"},{"code":"dancers","unicode":"👯"},{"code":"dango","unicode":"🍡"},{"code":"dark_sunglasses","unicode":"🕶️"},{"code":"dart","unicode":"🎯"},{"code":"dash","unicode":"💨"},{"code":"date","unicode":"📅"},{"code":"de","unicode":"🇩🇪"},{"code":"deaf_man","unicode":"🧏‍♂️"},{"code":"deaf_person","unicode":"🧏"},{"code":"deaf_woman","unicode":"🧏‍♀️"},{"code":"deciduous_tree","unicode":"🌳"},{"code":"deer","unicode":"🦌"},{"code":"department_store","unicode":"🏬"},{"code":"derelict_house_building","unicode":"🏚️"},{"code":"desert","unicode":"🏜️"},{"code":"desert_island","unicode":"🏝️"},{"code":"desktop_computer","unicode":"🖥️"},{"code":"diamond_shape_with_a_dot_inside","unicode":"💠"},{"code":"diamonds","unicode":"♦️"},{"code":"disappointed","unicode":"😞"},{"code":"disappointed_relieved","unicode":"😥"},{"code":"disguised_face","unicode":"🥸"},{"code":"distorted_face","unicode":"🫪"},{"code":"diving_mask","unicode":"🤿"},{"code":"diya_lamp","unicode":"🪔"},{"code":"dizzy","unicode":"💫"},{"code":"dizzy_face","unicode":"😵"},{"code":"dna","unicode":"🧬"},{"code":"do_not_litter","unicode":"🚯"},{"code":"dodo","unicode":"🦤"},{"code":"dog","unicode":"🐶"},{"code":"dog2","unicode":"🐕"},{"code":"dollar","unicode":"💵"},{"code":"dolls","unicode":"🎎"},{"code":"dolphin","unicode":"🐬"},{"code":"donkey","unicode":"🫏"},{"code":"door","unicode":"🚪"},{"code":"dotted_line_face","unicode":"🫥"},{"code":"double_vertical_bar","unicode":"⏸️"},{"code":"doughnut","unicode":"🍩"},{"code":"dove_of_peace","unicode":"🕊️"},{"code":"dragon","unicode":"🐉"},{"code":"dragon_face","unicode":"🐲"},{"code":"dress","unicode":"👗"},{"code":"dromedary_camel","unicode":"🐪"},{"code":"drooling_face","unicode":"🤤"},{"code":"drop_of_blood","unicode":"🩸"},{"code":"droplet","unicode":"💧"},{"code":"drum_with_drumsticks","unicode":"🥁"},{"code":"duck","unicode":"🦆"},{"code":"dumpling","unicode":"🥟"},{"code":"dvd","unicode":"📀"},{"code":"e-mail","unicode":"📧"},{"code":"eagle","unicode":"🦅"},{"code":"ear","unicode":"👂"},{"code":"ear_of_rice","unicode":"🌾"},{"code":"ear_with_hearing_aid","unicode":"🦻"},{"code":"earth_africa","unicode":"🌍"},{"code":"earth_americas","unicode":"🌎"},{"code":"earth_asia","unicode":"🌏"},{"code":"egg","unicode":"🥚"},{"code":"eggplant","unicode":"🍆"},{"code":"eight","unicode":"8️⃣"},{"code":"eight_pointed_black_star","unicode":"✴️"},{"code":"eight_spoked_asterisk","unicode":"✳️"},{"code":"eject","unicode":"⏏️"},{"code":"electric_plug","unicode":"🔌"},{"code":"elephant","unicode":"🐘"},{"code":"elevator","unicode":"🛗"},{"code":"elf","unicode":"🧝"},{"code":"email","unicode":"✉️"},{"code":"empty_nest","unicode":"🪹"},{"code":"end","unicode":"🔚"},{"code":"envelope","unicode":"✉️"},{"code":"envelope_with_arrow","unicode":"📩"},{"code":"es","unicode":"🇪🇸"},{"code":"euro","unicode":"💶"},{"code":"european_castle","unicode":"🏰"},{"code":"european_post_office","unicode":"🏤"},{"code":"evergreen_tree","unicode":"🌲"},{"code":"exclamation","unicode":"❗"},{"code":"exploding_head","unicode":"🤯"},{"code":"expressionless","unicode":"😑"},{"code":"eye","unicode":"👁️"},{"code":"eye-in-speech-bubble","unicode":"👁️‍🗨️"},{"code":"eyeglasses","unicode":"👓"},{"code":"eyes","unicode":"👀"},{"code":"face_exhaling","unicode":"😮‍💨"},{"code":"face_holding_back_tears","unicode":"🥹"},{"code":"face_in_clouds","unicode":"😶‍🌫️"},{"code":"face_palm","unicode":"🤦"},{"code":"face_vomiting","unicode":"🤮"},{"code":"face_with_bags_under_eyes","unicode":"🫩"},{"code":"face_with_cowboy_hat","unicode":"🤠"},{"code":"face_with_diagonal_mouth","unicode":"🫤"},{"code":"face_with_finger_covering_closed_lips","unicode":"🤫"},{"code":"face_with_hand_over_mouth","unicode":"🤭"},{"code":"face_with_head_bandage","unicode":"🤕"},{"code":"face_with_monocle","unicode":"🧐"},{"code":"face_with_one_eyebrow_raised","unicode":"🤨"},{"code":"face_with_open_eyes_and_hand_over_mouth","unicode":"🫢"},{"code":"face_with_open_mouth_vomiting","unicode":"🤮"},{"code":"face_with_peeking_eye","unicode":"🫣"},{"code":"face_with_raised_eyebrow","unicode":"🤨"},{"code":"face_with_rolling_eyes","unicode":"🙄"},{"code":"face_with_spiral_eyes","unicode":"😵‍💫"},{"code":"face_with_symbols_on_mouth","unicode":"🤬"},{"code":"face_with_thermometer","unicode":"🤒"},{"code":"facepunch","unicode":"👊"},{"code":"factory","unicode":"🏭"},{"code":"factory_worker","unicode":"🧑‍🏭"},{"code":"fairy","unicode":"🧚"},{"code":"falafel","unicode":"🧆"},{"code":"fallen_leaf","unicode":"🍂"},{"code":"family","unicode":"👪"},{"code":"family_adult_adult_child","unicode":"🧑‍🧑‍🧒"},{"code":"family_adult_adult_child_child","unicode":"🧑‍🧑‍🧒‍🧒"},{"code":"family_adult_child","unicode":"🧑‍🧒"},{"code":"family_adult_child_child","unicode":"🧑‍🧒‍🧒"},{"code":"farmer","unicode":"🧑‍🌾"},{"code":"fast_forward","unicode":"⏩"},{"code":"fax","unicode":"📠"},{"code":"fearful","unicode":"😨"},{"code":"feather","unicode":"🪶"},{"code":"feet","unicode":"🐾"},{"code":"female-artist","unicode":"👩‍🎨"},{"code":"female-astronaut","unicode":"👩‍🚀"},{"code":"female-construction-worker","unicode":"👷‍♀️"},{"code":"female-cook","unicode":"👩‍🍳"},{"code":"female-detective","unicode":"🕵️‍♀️"},{"code":"female-doctor","unicode":"👩‍⚕️"},{"code":"female-factory-worker","unicode":"👩‍🏭"},{"code":"female-farmer","unicode":"👩‍🌾"},{"code":"female-firefighter","unicode":"👩‍🚒"},{"code":"female-guard","unicode":"💂‍♀️"},{"code":"female-judge","unicode":"👩‍⚖️"},{"code":"female-mechanic","unicode":"👩‍🔧"},{"code":"female-office-worker","unicode":"👩‍💼"},{"code":"female-pilot","unicode":"👩‍✈️"},{"code":"female-police-officer","unicode":"👮‍♀️"},{"code":"female-scientist","unicode":"👩‍🔬"},{"code":"female-singer","unicode":"👩‍🎤"},{"code":"female-student","unicode":"👩‍🎓"},{"code":"female-teacher","unicode":"👩‍🏫"},{"code":"female-technologist","unicode":"👩‍💻"},{"code":"female_elf","unicode":"🧝‍♀️"},{"code":"female_fairy","unicode":"🧚‍♀️"},{"code":"female_genie","unicode":"🧞‍♀️"},{"code":"female_mage","unicode":"🧙‍♀️"},{"code":"female_sign","unicode":"♀️"},{"code":"female_superhero","unicode":"🦸‍♀️"},{"code":"female_supervillain","unicode":"🦹‍♀️"},{"code":"female_vampire","unicode":"🧛‍♀️"},{"code":"female_zombie","unicode":"🧟‍♀️"},{"code":"fencer","unicode":"🤺"},{"code":"ferris_wheel","unicode":"🎡"},{"code":"ferry","unicode":"⛴️"},{"code":"field_hockey_stick_and_ball","unicode":"🏑"},{"code":"fight_cloud","unicode":"🫯"},{"code":"file_cabinet","unicode":"🗄️"},{"code":"file_folder","unicode":"📁"},{"code":"film_frames","unicode":"🎞️"},{"code":"film_projector","unicode":"📽️"},{"code":"fingerprint","unicode":"🫆"},{"code":"fire","unicode":"🔥"},{"code":"fire_engine","unicode":"🚒"},{"code":"fire_extinguisher","unicode":"🧯"},{"code":"firecracker","unicode":"🧨"},{"code":"firefighter","unicode":"🧑‍🚒"},{"code":"fireworks","unicode":"🎆"},{"code":"first_place_medal","unicode":"🥇"},{"code":"first_quarter_moon","unicode":"🌓"},{"code":"first_quarter_moon_with_face","unicode":"🌛"},{"code":"fish","unicode":"🐟"},{"code":"fish_cake","unicode":"🍥"},{"code":"fishing_pole_and_fish","unicode":"🎣"},{"code":"fist","unicode":"✊"},{"code":"five","unicode":"5️⃣"},{"code":"flag-ac","unicode":"🇦🇨"},{"code":"flag-ad","unicode":"🇦🇩"},{"code":"flag-ae","unicode":"🇦🇪"},{"code":"flag-af","unicode":"🇦🇫"},{"code":"flag-ag","unicode":"🇦🇬"},{"code":"flag-ai","unicode":"🇦🇮"},{"code":"flag-al","unicode":"🇦🇱"},{"code":"flag-am","unicode":"🇦🇲"},{"code":"flag-ao","unicode":"🇦🇴"},{"code":"flag-aq","unicode":"🇦🇶"},{"code":"flag-ar","unicode":"🇦🇷"},{"code":"flag-as","unicode":"🇦🇸"},{"code":"flag-at","unicode":"🇦🇹"},{"code":"flag-au","unicode":"🇦🇺"},{"code":"flag-aw","unicode":"🇦🇼"},{"code":"flag-ax","unicode":"🇦🇽"},{"code":"flag-az","unicode":"🇦🇿"},{"code":"flag-ba","unicode":"🇧🇦"},{"code":"flag-bb","unicode":"🇧🇧"},{"code":"flag-bd","unicode":"🇧🇩"},{"code":"flag-be","unicode":"🇧🇪"},{"code":"flag-bf","unicode":"🇧🇫"},{"code":"flag-bg","unicode":"🇧🇬"},{"code":"flag-bh","unicode":"🇧🇭"},{"code":"flag-bi","unicode":"🇧🇮"},{"code":"flag-bj","unicode":"🇧🇯"},{"code":"flag-bl","unicode":"🇧🇱"},{"code":"flag-bm","unicode":"🇧🇲"},{"code":"flag-bn","unicode":"🇧🇳"},{"code":"flag-bo","unicode":"🇧🇴"},{"code":"flag-bq","unicode":"🇧🇶"},{"code":"flag-br","unicode":"🇧🇷"},{"code":"flag-bs","unicode":"🇧🇸"},{"code":"flag-bt","unicode":"🇧🇹"},{"code":"flag-bv","unicode":"🇧🇻"},{"code":"flag-bw","unicode":"🇧🇼"},{"code":"flag-by","unicode":"🇧🇾"},{"code":"flag-bz","unicode":"🇧🇿"},{"code":"flag-ca","unicode":"🇨🇦"},{"code":"flag-cc","unicode":"🇨🇨"},{"code":"flag-cd","unicode":"🇨🇩"},{"code":"flag-cf","unicode":"🇨🇫"},{"code":"flag-cg","unicode":"🇨🇬"},{"code":"flag-ch","unicode":"🇨🇭"},{"code":"flag-ci","unicode":"🇨🇮"},{"code":"flag-ck","unicode":"🇨🇰"},{"code":"flag-cl","unicode":"🇨🇱"},{"code":"flag-cm","unicode":"🇨🇲"},{"code":"flag-cn","unicode":"🇨🇳"},{"code":"flag-co","unicode":"🇨🇴"},{"code":"flag-cp","unicode":"🇨🇵"},{"code":"flag-cr","unicode":"🇨🇷"},{"code":"flag-cu","unicode":"🇨🇺"},{"code":"flag-cv","unicode":"🇨🇻"},{"code":"flag-cw","unicode":"🇨🇼"},{"code":"flag-cx","unicode":"🇨🇽"},{"code":"flag-cy","unicode":"🇨🇾"},{"code":"flag-cz","unicode":"🇨🇿"},{"code":"flag-de","unicode":"🇩🇪"},{"code":"flag-dg","unicode":"🇩🇬"},{"code":"flag-dj","unicode":"🇩🇯"},{"code":"flag-dk","unicode":"🇩🇰"},{"code":"flag-dm","unicode":"🇩🇲"},{"code":"flag-do","unicode":"🇩🇴"},{"code":"flag-dz","unicode":"🇩🇿"},{"code":"flag-ea","unicode":"🇪🇦"},{"code":"flag-ec","unicode":"🇪🇨"},{"code":"flag-ee","unicode":"🇪🇪"},{"code":"flag-eg","unicode":"🇪🇬"},{"code":"flag-eh","unicode":"🇪🇭"},{"code":"flag-england","unicode":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},{"code":"flag-er","unicode":"🇪🇷"},{"code":"flag-es","unicode":"🇪🇸"},{"code":"flag-et","unicode":"🇪🇹"},{"code":"flag-eu","unicode":"🇪🇺"},{"code":"flag-fi","unicode":"🇫🇮"},{"code":"flag-fj","unicode":"🇫🇯"},{"code":"flag-fk","unicode":"🇫🇰"},{"code":"flag-fm","unicode":"🇫🇲"},{"code":"flag-fo","unicode":"🇫🇴"},{"code":"flag-fr","unicode":"🇫🇷"},{"code":"flag-ga","unicode":"🇬🇦"},{"code":"flag-gb","unicode":"🇬🇧"},{"code":"flag-gd","unicode":"🇬🇩"},{"code":"flag-ge","unicode":"🇬🇪"},{"code":"flag-gf","unicode":"🇬🇫"},{"code":"flag-gg","unicode":"🇬🇬"},{"code":"flag-gh","unicode":"🇬🇭"},{"code":"flag-gi","unicode":"🇬🇮"},{"code":"flag-gl","unicode":"🇬🇱"},{"code":"flag-gm","unicode":"🇬🇲"},{"code":"flag-gn","unicode":"🇬🇳"},{"code":"flag-gp","unicode":"🇬🇵"},{"code":"flag-gq","unicode":"🇬🇶"},{"code":"flag-gr","unicode":"🇬🇷"},{"code":"flag-gs","unicode":"🇬🇸"},{"code":"flag-gt","unicode":"🇬🇹"},{"code":"flag-gu","unicode":"🇬🇺"},{"code":"flag-gw","unicode":"🇬🇼"},{"code":"flag-gy","unicode":"🇬🇾"},{"code":"flag-hk","unicode":"🇭🇰"},{"code":"flag-hm","unicode":"🇭🇲"},{"code":"flag-hn","unicode":"🇭🇳"},{"code":"flag-hr","unicode":"🇭🇷"},{"code":"flag-ht","unicode":"🇭🇹"},{"code":"flag-hu","unicode":"🇭🇺"},{"code":"flag-ic","unicode":"🇮🇨"},{"code":"flag-id","unicode":"🇮🇩"},{"code":"flag-ie","unicode":"🇮🇪"},{"code":"flag-il","unicode":"🇮🇱"},{"code":"flag-im","unicode":"🇮🇲"},{"code":"flag-in","unicode":"🇮🇳"},{"code":"flag-io","unicode":"🇮🇴"},{"code":"flag-iq","unicode":"🇮🇶"},{"code":"flag-ir","unicode":"🇮🇷"},{"code":"flag-is","unicode":"🇮🇸"},{"code":"flag-it","unicode":"🇮🇹"},{"code":"flag-je","unicode":"🇯🇪"},{"code":"flag-jm","unicode":"🇯🇲"},{"code":"flag-jo","unicode":"🇯🇴"},{"code":"flag-jp","unicode":"🇯🇵"},{"code":"flag-ke","unicode":"🇰🇪"},{"code":"flag-kg","unicode":"🇰🇬"},{"code":"flag-kh","unicode":"🇰🇭"},{"code":"flag-ki","unicode":"🇰🇮"},{"code":"flag-km","unicode":"🇰🇲"},{"code":"flag-kn","unicode":"🇰🇳"},{"code":"flag-kp","unicode":"🇰🇵"},{"code":"flag-kr","unicode":"🇰🇷"},{"code":"flag-kw","unicode":"🇰🇼"},{"code":"flag-ky","unicode":"🇰🇾"},{"code":"flag-kz","unicode":"🇰🇿"},{"code":"flag-la","unicode":"🇱🇦"},{"code":"flag-lb","unicode":"🇱🇧"},{"code":"flag-lc","unicode":"🇱🇨"},{"code":"flag-li","unicode":"🇱🇮"},{"code":"flag-lk","unicode":"🇱🇰"},{"code":"flag-lr","unicode":"🇱🇷"},{"code":"flag-ls","unicode":"🇱🇸"},{"code":"flag-lt","unicode":"🇱🇹"},{"code":"flag-lu","unicode":"🇱🇺"},{"code":"flag-lv","unicode":"🇱🇻"},{"code":"flag-ly","unicode":"🇱🇾"},{"code":"flag-ma","unicode":"🇲🇦"},{"code":"flag-mc","unicode":"🇲🇨"},{"code":"flag-md","unicode":"🇲🇩"},{"code":"flag-me","unicode":"🇲🇪"},{"code":"flag-mf","unicode":"🇲🇫"},{"code":"flag-mg","unicode":"🇲🇬"},{"code":"flag-mh","unicode":"🇲🇭"},{"code":"flag-mk","unicode":"🇲🇰"},{"code":"flag-ml","unicode":"🇲🇱"},{"code":"flag-mm","unicode":"🇲🇲"},{"code":"flag-mn","unicode":"🇲🇳"},{"code":"flag-mo","unicode":"🇲🇴"},{"code":"flag-mp","unicode":"🇲🇵"},{"code":"flag-mq","unicode":"🇲🇶"},{"code":"flag-mr","unicode":"🇲🇷"},{"code":"flag-ms","unicode":"🇲🇸"},{"code":"flag-mt","unicode":"🇲🇹"},{"code":"flag-mu","unicode":"🇲🇺"},{"code":"flag-mv","unicode":"🇲🇻"},{"code":"flag-mw","unicode":"🇲🇼"},{"code":"flag-mx","unicode":"🇲🇽"},{"code":"flag-my","unicode":"🇲🇾"},{"code":"flag-mz","unicode":"🇲🇿"},{"code":"flag-na","unicode":"🇳🇦"},{"code":"flag-nc","unicode":"🇳🇨"},{"code":"flag-ne","unicode":"🇳🇪"},{"code":"flag-nf","unicode":"🇳🇫"},{"code":"flag-ng","unicode":"🇳🇬"},{"code":"flag-ni","unicode":"🇳🇮"},{"code":"flag-nl","unicode":"🇳🇱"},{"code":"flag-no","unicode":"🇳🇴"},{"code":"flag-np","unicode":"🇳🇵"},{"code":"flag-nr","unicode":"🇳🇷"},{"code":"flag-nu","unicode":"🇳🇺"},{"code":"flag-nz","unicode":"🇳🇿"},{"code":"flag-om","unicode":"🇴🇲"},{"code":"flag-pa","unicode":"🇵🇦"},{"code":"flag-pe","unicode":"🇵🇪"},{"code":"flag-pf","unicode":"🇵🇫"},{"code":"flag-pg","unicode":"🇵🇬"},{"code":"flag-ph","unicode":"🇵🇭"},{"code":"flag-pk","unicode":"🇵🇰"},{"code":"flag-pl","unicode":"🇵🇱"},{"code":"flag-pm","unicode":"🇵🇲"},{"code":"flag-pn","unicode":"🇵🇳"},{"code":"flag-pr","unicode":"🇵🇷"},{"code":"flag-ps","unicode":"🇵🇸"},{"code":"flag-pt","unicode":"🇵🇹"},{"code":"flag-pw","unicode":"🇵🇼"},{"code":"flag-py","unicode":"🇵🇾"},{"code":"flag-qa","unicode":"🇶🇦"},{"code":"flag-re","unicode":"🇷🇪"},{"code":"flag-ro","unicode":"🇷🇴"},{"code":"flag-rs","unicode":"🇷🇸"},{"code":"flag-ru","unicode":"🇷🇺"},{"code":"flag-rw","unicode":"🇷🇼"},{"code":"flag-sa","unicode":"🇸🇦"},{"code":"flag-sark","unicode":"🇨🇶"},{"code":"flag-sb","unicode":"🇸🇧"},{"code":"flag-sc","unicode":"🇸🇨"},{"code":"flag-scotland","unicode":"🏴󠁧󠁢󠁳󠁣󠁴󠁿"},{"code":"flag-sd","unicode":"🇸🇩"},{"code":"flag-se","unicode":"🇸🇪"},{"code":"flag-sg","unicode":"🇸🇬"},{"code":"flag-sh","unicode":"🇸🇭"},{"code":"flag-si","unicode":"🇸🇮"},{"code":"flag-sj","unicode":"🇸🇯"},{"code":"flag-sk","unicode":"🇸🇰"},{"code":"flag-sl","unicode":"🇸🇱"},{"code":"flag-sm","unicode":"🇸🇲"},{"code":"flag-sn","unicode":"🇸🇳"},{"code":"flag-so","unicode":"🇸🇴"},{"code":"flag-sr","unicode":"🇸🇷"},{"code":"flag-ss","unicode":"🇸🇸"},{"code":"flag-st","unicode":"🇸🇹"},{"code":"flag-sv","unicode":"🇸🇻"},{"code":"flag-sx","unicode":"🇸🇽"},{"code":"flag-sy","unicode":"🇸🇾"},{"code":"flag-sz","unicode":"🇸🇿"},{"code":"flag-ta","unicode":"🇹🇦"},{"code":"flag-tc","unicode":"🇹🇨"},{"code":"flag-td","unicode":"🇹🇩"},{"code":"flag-tf","unicode":"🇹🇫"},{"code":"flag-tg","unicode":"🇹🇬"},{"code":"flag-th","unicode":"🇹🇭"},{"code":"flag-tj","unicode":"🇹🇯"},{"code":"flag-tk","unicode":"🇹🇰"},{"code":"flag-tl","unicode":"🇹🇱"},{"code":"flag-tm","unicode":"🇹🇲"},{"code":"flag-tn","unicode":"🇹🇳"},{"code":"flag-to","unicode":"🇹🇴"},{"code":"flag-tr","unicode":"🇹🇷"},{"code":"flag-tt","unicode":"🇹🇹"},{"code":"flag-tv","unicode":"🇹🇻"},{"code":"flag-tw","unicode":"🇹🇼"},{"code":"flag-tz","unicode":"🇹🇿"},{"code":"flag-ua","unicode":"🇺🇦"},{"code":"flag-ug","unicode":"🇺🇬"},{"code":"flag-um","unicode":"🇺🇲"},{"code":"flag-un","unicode":"🇺🇳"},{"code":"flag-us","unicode":"🇺🇸"},{"code":"flag-uy","unicode":"🇺🇾"},{"code":"flag-uz","unicode":"🇺🇿"},{"code":"flag-va","unicode":"🇻🇦"},{"code":"flag-vc","unicode":"🇻🇨"},{"code":"flag-ve","unicode":"🇻🇪"},{"code":"flag-vg","unicode":"🇻🇬"},{"code":"flag-vi","unicode":"🇻🇮"},{"code":"flag-vn","unicode":"🇻🇳"},{"code":"flag-vu","unicode":"🇻🇺"},{"code":"flag-wales","unicode":"🏴󠁧󠁢󠁷󠁬󠁳󠁿"},{"code":"flag-wf","unicode":"🇼🇫"},{"code":"flag-ws","unicode":"🇼🇸"},{"code":"flag-xk","unicode":"🇽🇰"},{"code":"flag-ye","unicode":"🇾🇪"},{"code":"flag-yt","unicode":"🇾🇹"},{"code":"flag-za","unicode":"🇿🇦"},{"code":"flag-zm","unicode":"🇿🇲"},{"code":"flag-zw","unicode":"🇿🇼"},{"code":"flags","unicode":"🎏"},{"code":"flamingo","unicode":"🦩"},{"code":"flashlight","unicode":"🔦"},{"code":"flatbread","unicode":"🫓"},{"code":"fleur_de_lis","unicode":"⚜️"},{"code":"flipper","unicode":"🐬"},{"code":"floppy_disk","unicode":"💾"},{"code":"flower_playing_cards","unicode":"🎴"},{"code":"flushed","unicode":"😳"},{"code":"flute","unicode":"🪈"},{"code":"fly","unicode":"🪰"},{"code":"flying_disc","unicode":"🥏"},{"code":"flying_saucer","unicode":"🛸"},{"code":"fog","unicode":"🌫️"},{"code":"foggy","unicode":"🌁"},{"code":"folding_hand_fan","unicode":"🪭"},{"code":"fondue","unicode":"🫕"},{"code":"foot","unicode":"🦶"},{"code":"football","unicode":"🏈"},{"code":"footprints","unicode":"👣"},{"code":"fork_and_knife","unicode":"🍴"},{"code":"fortune_cookie","unicode":"🥠"},{"code":"fountain","unicode":"⛲"},{"code":"four","unicode":"4️⃣"},{"code":"four_leaf_clover","unicode":"🍀"},{"code":"fox_face","unicode":"🦊"},{"code":"fr","unicode":"🇫🇷"},{"code":"frame_with_picture","unicode":"🖼️"},{"code":"free","unicode":"🆓"},{"code":"fried_egg","unicode":"🍳"},{"code":"fried_shrimp","unicode":"🍤"},{"code":"fries","unicode":"🍟"},{"code":"frog","unicode":"🐸"},{"code":"frowning","unicode":"😦"},{"code":"fuelpump","unicode":"⛽"},{"code":"full_moon","unicode":"🌕"},{"code":"full_moon_with_face","unicode":"🌝"},{"code":"funeral_urn","unicode":"⚱️"},{"code":"game_die","unicode":"🎲"},{"code":"garlic","unicode":"🧄"},{"code":"gb","unicode":"🇬🇧"},{"code":"gear","unicode":"⚙️"},{"code":"gem","unicode":"💎"},{"code":"gemini","unicode":"♊"},{"code":"genie","unicode":"🧞"},{"code":"ghost","unicode":"👻"},{"code":"gift","unicode":"🎁"},{"code":"gift_heart","unicode":"💝"},{"code":"ginger_root","unicode":"🫚"},{"code":"giraffe_face","unicode":"🦒"},{"code":"girl","unicode":"👧"},{"code":"glass_of_milk","unicode":"🥛"},{"code":"globe_with_meridians","unicode":"🌐"},{"code":"gloves","unicode":"🧤"},{"code":"goal_net","unicode":"🥅"},{"code":"goat","unicode":"🐐"},{"code":"goggles","unicode":"🥽"},{"code":"golf","unicode":"⛳"},{"code":"golfer","unicode":"🏌️"},{"code":"goose","unicode":"🪿"},{"code":"gorilla","unicode":"🦍"},{"code":"grapes","unicode":"🍇"},{"code":"green_apple","unicode":"🍏"},{"code":"green_book","unicode":"📗"},{"code":"green_heart","unicode":"💚"},{"code":"green_salad","unicode":"🥗"},{"code":"grey_exclamation","unicode":"❕"},{"code":"grey_heart","unicode":"🩶"},{"code":"grey_question","unicode":"❔"},{"code":"grimacing","unicode":"😬"},{"code":"grin","unicode":"😁"},{"code":"grinning","unicode":"😀"},{"code":"grinning_face_with_one_large_and_one_small_eye","unicode":"🤪"},{"code":"grinning_face_with_star_eyes","unicode":"🤩"},{"code":"guardsman","unicode":"💂"},{"code":"guide_dog","unicode":"🦮"},{"code":"guitar","unicode":"🎸"},{"code":"gun","unicode":"🔫"},{"code":"hair_pick","unicode":"🪮"},{"code":"haircut","unicode":"💇"},{"code":"hairy_creature","unicode":"🫈"},{"code":"hamburger","unicode":"🍔"},{"code":"hammer","unicode":"🔨"},{"code":"hammer_and_pick","unicode":"⚒️"},{"code":"hammer_and_wrench","unicode":"🛠️"},{"code":"hamsa","unicode":"🪬"},{"code":"hamster","unicode":"🐹"},{"code":"hand","unicode":"✋"},{"code":"hand_with_index_and_middle_fingers_crossed","unicode":"🤞"},{"code":"hand_with_index_finger_and_thumb_crossed","unicode":"🫰"},{"code":"handbag","unicode":"👜"},{"code":"handball","unicode":"🤾"},{"code":"handshake","unicode":"🤝"},{"code":"hankey","unicode":"💩"},{"code":"harp","unicode":"🪉"},{"code":"hash","unicode":"#️⃣"},{"code":"hatched_chick","unicode":"🐥"},{"code":"hatching_chick","unicode":"🐣"},{"code":"head_shaking_horizontally","unicode":"🙂‍↔️"},{"code":"head_shaking_vertically","unicode":"🙂‍↕️"},{"code":"headphones","unicode":"🎧"},{"code":"headstone","unicode":"🪦"},{"code":"health_worker","unicode":"🧑‍⚕️"},{"code":"hear_no_evil","unicode":"🙉"},{"code":"heart","unicode":"❤️"},{"code":"heart_decoration","unicode":"💟"},{"code":"heart_eyes","unicode":"😍"},{"code":"heart_eyes_cat","unicode":"😻"},{"code":"heart_hands","unicode":"🫶"},{"code":"heart_on_fire","unicode":"❤️‍🔥"},{"code":"heartbeat","unicode":"💓"},{"code":"heartpulse","unicode":"💗"},{"code":"hearts","unicode":"♥️"},{"code":"heavy_check_mark","unicode":"✔️"},{"code":"heavy_division_sign","unicode":"➗"},{"code":"heavy_dollar_sign","unicode":"💲"},{"code":"heavy_equals_sign","unicode":"🟰"},{"code":"heavy_exclamation_mark","unicode":"❗"},{"code":"heavy_heart_exclamation_mark_ornament","unicode":"❣️"},{"code":"heavy_minus_sign","unicode":"➖"},{"code":"heavy_multiplication_x","unicode":"✖️"},{"code":"heavy_plus_sign","unicode":"➕"},{"code":"hedgehog","unicode":"🦔"},{"code":"helicopter","unicode":"🚁"},{"code":"helmet_with_white_cross","unicode":"⛑️"},{"code":"herb","unicode":"🌿"},{"code":"hibiscus","unicode":"🌺"},{"code":"high_brightness","unicode":"🔆"},{"code":"high_heel","unicode":"👠"},{"code":"hiking_boot","unicode":"🥾"},{"code":"hindu_temple","unicode":"🛕"},{"code":"hippopotamus","unicode":"🦛"},{"code":"hocho","unicode":"🔪"},{"code":"hole","unicode":"🕳️"},{"code":"honey_pot","unicode":"🍯"},{"code":"honeybee","unicode":"🐝"},{"code":"hook","unicode":"🪝"},{"code":"horse","unicode":"🐴"},{"code":"horse_racing","unicode":"🏇"},{"code":"hospital","unicode":"🏥"},{"code":"hot_face","unicode":"🥵"},{"code":"hot_pepper","unicode":"🌶️"},{"code":"hotdog","unicode":"🌭"},{"code":"hotel","unicode":"🏨"},{"code":"hotsprings","unicode":"♨️"},{"code":"hourglass","unicode":"⌛"},{"code":"hourglass_flowing_sand","unicode":"⏳"},{"code":"house","unicode":"🏠"},{"code":"house_buildings","unicode":"🏘️"},{"code":"house_with_garden","unicode":"🏡"},{"code":"hugging_face","unicode":"🤗"},{"code":"hushed","unicode":"😯"},{"code":"hut","unicode":"🛖"},{"code":"hyacinth","unicode":"🪻"},{"code":"i_love_you_hand_sign","unicode":"🤟"},{"code":"ice_cream","unicode":"🍨"},{"code":"ice_cube","unicode":"🧊"},{"code":"ice_hockey_stick_and_puck","unicode":"🏒"},{"code":"ice_skate","unicode":"⛸️"},{"code":"icecream","unicode":"🍦"},{"code":"id","unicode":"🆔"},{"code":"identification_card","unicode":"🪪"},{"code":"ideograph_advantage","unicode":"🉐"},{"code":"imp","unicode":"👿"},{"code":"inbox_tray","unicode":"📥"},{"code":"incoming_envelope","unicode":"📨"},{"code":"index_pointing_at_the_viewer","unicode":"🫵"},{"code":"infinity","unicode":"♾️"},{"code":"information_desk_person","unicode":"💁"},{"code":"information_source","unicode":"ℹ️"},{"code":"innocent","unicode":"😇"},{"code":"interrobang","unicode":"⁉️"},{"code":"iphone","unicode":"📱"},{"code":"it","unicode":"🇮🇹"},{"code":"izakaya_lantern","unicode":"🏮"},{"code":"jack_o_lantern","unicode":"🎃"},{"code":"japan","unicode":"🗾"},{"code":"japanese_castle","unicode":"🏯"},{"code":"japanese_goblin","unicode":"👺"},{"code":"japanese_ogre","unicode":"👹"},{"code":"jar","unicode":"🫙"},{"code":"jeans","unicode":"👖"},{"code":"jellyfish","unicode":"🪼"},{"code":"jigsaw","unicode":"🧩"},{"code":"joy","unicode":"😂"},{"code":"joy_cat","unicode":"😹"},{"code":"joystick","unicode":"🕹️"},{"code":"jp","unicode":"🇯🇵"},{"code":"judge","unicode":"🧑‍⚖️"},{"code":"juggling","unicode":"🤹"},{"code":"kaaba","unicode":"🕋"},{"code":"kangaroo","unicode":"🦘"},{"code":"key","unicode":"🔑"},{"code":"keyboard","unicode":"⌨️"},{"code":"keycap_star","unicode":"*️⃣"},{"code":"keycap_ten","unicode":"🔟"},{"code":"khanda","unicode":"🪯"},{"code":"kimono","unicode":"👘"},{"code":"kiss","unicode":"💋"},{"code":"kissing","unicode":"😗"},{"code":"kissing_cat","unicode":"😽"},{"code":"kissing_closed_eyes","unicode":"😚"},{"code":"kissing_heart","unicode":"😘"},{"code":"kissing_smiling_eyes","unicode":"😙"},{"code":"kite","unicode":"🪁"},{"code":"kiwifruit","unicode":"🥝"},{"code":"kneeling_person","unicode":"🧎"},{"code":"knife","unicode":"🔪"},{"code":"knife_fork_plate","unicode":"🍽️"},{"code":"knot","unicode":"🪢"},{"code":"koala","unicode":"🐨"},{"code":"koko","unicode":"🈁"},{"code":"kr","unicode":"🇰🇷"},{"code":"lab_coat","unicode":"🥼"},{"code":"label","unicode":"🏷️"},{"code":"lacrosse","unicode":"🥍"},{"code":"ladder","unicode":"🪜"},{"code":"lady_beetle","unicode":"🐞"},{"code":"ladybug","unicode":"🐞"},{"code":"landslide","unicode":"🛘"},{"code":"lantern","unicode":"🏮"},{"code":"large_blue_circle","unicode":"🔵"},{"code":"large_blue_diamond","unicode":"🔷"},{"code":"large_blue_square","unicode":"🟦"},{"code":"large_brown_circle","unicode":"🟤"},{"code":"large_brown_square","unicode":"🟫"},{"code":"large_green_circle","unicode":"🟢"},{"code":"large_green_square","unicode":"🟩"},{"code":"large_orange_circle","unicode":"🟠"},{"code":"large_orange_diamond","unicode":"🔶"},{"code":"large_orange_square","unicode":"🟧"},{"code":"large_purple_circle","unicode":"🟣"},{"code":"large_purple_square","unicode":"🟪"},{"code":"large_red_square","unicode":"🟥"},{"code":"large_yellow_circle","unicode":"🟡"},{"code":"large_yellow_square","unicode":"🟨"},{"code":"last_quarter_moon","unicode":"🌗"},{"code":"last_quarter_moon_with_face","unicode":"🌜"},{"code":"latin_cross","unicode":"✝️"},{"code":"laughing","unicode":"😆"},{"code":"leafless_tree","unicode":"🪾"},{"code":"leafy_green","unicode":"🥬"},{"code":"leaves","unicode":"🍃"},{"code":"ledger","unicode":"📒"},{"code":"left-facing_fist","unicode":"🤛"},{"code":"left_luggage","unicode":"🛅"},{"code":"left_right_arrow","unicode":"↔️"},{"code":"left_speech_bubble","unicode":"🗨️"},{"code":"leftwards_arrow_with_hook","unicode":"↩️"},{"code":"leftwards_hand","unicode":"🫲"},{"code":"leftwards_pushing_hand","unicode":"🫷"},{"code":"leg","unicode":"🦵"},{"code":"lemon","unicode":"🍋"},{"code":"leo","unicode":"♌"},{"code":"leopard","unicode":"🐆"},{"code":"level_slider","unicode":"🎚️"},{"code":"libra","unicode":"♎"},{"code":"light_blue_heart","unicode":"🩵"},{"code":"light_rail","unicode":"🚈"},{"code":"lightning","unicode":"🌩️"},{"code":"lightning_cloud","unicode":"🌩️"},{"code":"lime","unicode":"🍋‍🟩"},{"code":"link","unicode":"🔗"},{"code":"linked_paperclips","unicode":"🖇️"},{"code":"lion_face","unicode":"🦁"},{"code":"lips","unicode":"👄"},{"code":"lipstick","unicode":"💄"},{"code":"lizard","unicode":"🦎"},{"code":"llama","unicode":"🦙"},{"code":"lobster","unicode":"🦞"},{"code":"lock","unicode":"🔒"},{"code":"lock_with_ink_pen","unicode":"🔏"},{"code":"lollipop","unicode":"🍭"},{"code":"long_drum","unicode":"🪘"},{"code":"loop","unicode":"➿"},{"code":"lotion_bottle","unicode":"🧴"},{"code":"lotus","unicode":"🪷"},{"code":"loud_sound","unicode":"🔊"},{"code":"loudspeaker","unicode":"📢"},{"code":"love_hotel","unicode":"🏩"},{"code":"love_letter","unicode":"💌"},{"code":"low_battery","unicode":"🪫"},{"code":"low_brightness","unicode":"🔅"},{"code":"lower_left_ballpoint_pen","unicode":"🖊️"},{"code":"lower_left_crayon","unicode":"🖍️"},{"code":"lower_left_fountain_pen","unicode":"🖋️"},{"code":"lower_left_paintbrush","unicode":"🖌️"},{"code":"luggage","unicode":"🧳"},{"code":"lungs","unicode":"🫁"},{"code":"lying_face","unicode":"🤥"},{"code":"m","unicode":"Ⓜ️"},{"code":"mag","unicode":"🔍"},{"code":"mag_right","unicode":"🔎"},{"code":"mage","unicode":"🧙"},{"code":"magic_wand","unicode":"🪄"},{"code":"magnet","unicode":"🧲"},{"code":"mahjong","unicode":"🀄"},{"code":"mailbox","unicode":"📫"},{"code":"mailbox_closed","unicode":"📪"},{"code":"mailbox_with_mail","unicode":"📬"},{"code":"mailbox_with_no_mail","unicode":"📭"},{"code":"male-artist","unicode":"👨‍🎨"},{"code":"male-astronaut","unicode":"👨‍🚀"},{"code":"male-construction-worker","unicode":"👷‍♂️"},{"code":"male-cook","unicode":"👨‍🍳"},{"code":"male-detective","unicode":"🕵️‍♂️"},{"code":"male-doctor","unicode":"👨‍⚕️"},{"code":"male-factory-worker","unicode":"👨‍🏭"},{"code":"male-farmer","unicode":"👨‍🌾"},{"code":"male-firefighter","unicode":"👨‍🚒"},{"code":"male-guard","unicode":"💂‍♂️"},{"code":"male-judge","unicode":"👨‍⚖️"},{"code":"male-mechanic","unicode":"👨‍🔧"},{"code":"male-office-worker","unicode":"👨‍💼"},{"code":"male-pilot","unicode":"👨‍✈️"},{"code":"male-police-officer","unicode":"👮‍♂️"},{"code":"male-scientist","unicode":"👨‍🔬"},{"code":"male-singer","unicode":"👨‍🎤"},{"code":"male-student","unicode":"👨‍🎓"},{"code":"male-teacher","unicode":"👨‍🏫"},{"code":"male-technologist","unicode":"👨‍💻"},{"code":"male_elf","unicode":"🧝‍♂️"},{"code":"male_fairy","unicode":"🧚‍♂️"},{"code":"male_genie","unicode":"🧞‍♂️"},{"code":"male_mage","unicode":"🧙‍♂️"},{"code":"male_sign","unicode":"♂️"},{"code":"male_superhero","unicode":"🦸‍♂️"},{"code":"male_supervillain","unicode":"🦹‍♂️"},{"code":"male_vampire","unicode":"🧛‍♂️"},{"code":"male_zombie","unicode":"🧟‍♂️"},{"code":"mammoth","unicode":"🦣"},{"code":"man","unicode":"👨"},{"code":"man-biking","unicode":"🚴‍♂️"},{"code":"man-bouncing-ball","unicode":"⛹️‍♂️"},{"code":"man-bowing","unicode":"🙇‍♂️"},{"code":"man-boy","unicode":"👨‍👦"},{"code":"man-boy-boy","unicode":"👨‍👦‍👦"},{"code":"man-cartwheeling","unicode":"🤸‍♂️"},{"code":"man-facepalming","unicode":"🤦‍♂️"},{"code":"man-frowning","unicode":"🙍‍♂️"},{"code":"man-gesturing-no","unicode":"🙅‍♂️"},{"code":"man-gesturing-ok","unicode":"🙆‍♂️"},{"code":"man-getting-haircut","unicode":"💇‍♂️"},{"code":"man-getting-massage","unicode":"💆‍♂️"},{"code":"man-girl","unicode":"👨‍👧"},{"code":"man-girl-boy","unicode":"👨‍👧‍👦"},{"code":"man-girl-girl","unicode":"👨‍👧‍👧"},{"code":"man-golfing","unicode":"🏌️‍♂️"},{"code":"man-heart-man","unicode":"👨‍❤️‍👨"},{"code":"man-juggling","unicode":"🤹‍♂️"},{"code":"man-kiss-man","unicode":"👨‍❤️‍💋‍👨"},{"code":"man-lifting-weights","unicode":"🏋️‍♂️"},{"code":"man-man-boy","unicode":"👨‍👨‍👦"},{"code":"man-man-boy-boy","unicode":"👨‍👨‍👦‍👦"},{"code":"man-man-girl","unicode":"👨‍👨‍👧"},{"code":"man-man-girl-boy","unicode":"👨‍👨‍👧‍👦"},{"code":"man-man-girl-girl","unicode":"👨‍👨‍👧‍👧"},{"code":"man-mountain-biking","unicode":"🚵‍♂️"},{"code":"man-playing-handball","unicode":"🤾‍♂️"},{"code":"man-playing-water-polo","unicode":"🤽‍♂️"},{"code":"man-pouting","unicode":"🙎‍♂️"},{"code":"man-raising-hand","unicode":"🙋‍♂️"},{"code":"man-rowing-boat","unicode":"🚣‍♂️"},{"code":"man-running","unicode":"🏃‍♂️"},{"code":"man-shrugging","unicode":"🤷‍♂️"},{"code":"man-surfing","unicode":"🏄‍♂️"},{"code":"man-swimming","unicode":"🏊‍♂️"},{"code":"man-tipping-hand","unicode":"💁‍♂️"},{"code":"man-walking","unicode":"🚶‍♂️"},{"code":"man-wearing-turban","unicode":"👳‍♂️"},{"code":"man-with-bunny-ears-partying","unicode":"👯‍♂️"},{"code":"man-woman-boy","unicode":"👨‍👩‍👦"},{"code":"man-woman-boy-boy","unicode":"👨‍👩‍👦‍👦"},{"code":"man-woman-girl","unicode":"👨‍👩‍👧"},{"code":"man-woman-girl-boy","unicode":"👨‍👩‍👧‍👦"},{"code":"man-woman-girl-girl","unicode":"👨‍👩‍👧‍👧"},{"code":"man-wrestling","unicode":"🤼‍♂️"},{"code":"man_and_woman_holding_hands","unicode":"👫"},{"code":"man_climbing","unicode":"🧗‍♂️"},{"code":"man_dancing","unicode":"🕺"},{"code":"man_feeding_baby","unicode":"👨‍🍼"},{"code":"man_in_business_suit_levitating","unicode":"🕴️"},{"code":"man_in_lotus_position","unicode":"🧘‍♂️"},{"code":"man_in_manual_wheelchair","unicode":"👨‍🦽"},{"code":"man_in_manual_wheelchair_facing_right","unicode":"👨‍🦽‍➡️"},{"code":"man_in_motorized_wheelchair","unicode":"👨‍🦼"},{"code":"man_in_motorized_wheelchair_facing_right","unicode":"👨‍🦼‍➡️"},{"code":"man_in_steamy_room","unicode":"🧖‍♂️"},{"code":"man_in_tuxedo","unicode":"🤵‍♂️"},{"code":"man_kneeling","unicode":"🧎‍♂️"},{"code":"man_kneeling_facing_right","unicode":"🧎‍♂️‍➡️"},{"code":"man_running_facing_right","unicode":"🏃‍♂️‍➡️"},{"code":"man_standing","unicode":"🧍‍♂️"},{"code":"man_walking_facing_right","unicode":"🚶‍♂️‍➡️"},{"code":"man_with_beard","unicode":"🧔‍♂️"},{"code":"man_with_gua_pi_mao","unicode":"👲"},{"code":"man_with_probing_cane","unicode":"👨‍🦯"},{"code":"man_with_turban","unicode":"👳"},{"code":"man_with_veil","unicode":"👰‍♂️"},{"code":"man_with_white_cane_facing_right","unicode":"👨‍🦯‍➡️"},{"code":"mango","unicode":"🥭"},{"code":"mans_shoe","unicode":"👞"},{"code":"mantelpiece_clock","unicode":"🕰️"},{"code":"manual_wheelchair","unicode":"🦽"},{"code":"maple_leaf","unicode":"🍁"},{"code":"maracas","unicode":"🪇"},{"code":"martial_arts_uniform","unicode":"🥋"},{"code":"mask","unicode":"😷"},{"code":"massage","unicode":"💆"},{"code":"mate_drink","unicode":"🧉"},{"code":"meat_on_bone","unicode":"🍖"},{"code":"mechanic","unicode":"🧑‍🔧"},{"code":"mechanical_arm","unicode":"🦾"},{"code":"mechanical_leg","unicode":"🦿"},{"code":"medal","unicode":"🎖️"},{"code":"medical_symbol","unicode":"⚕️"},{"code":"mega","unicode":"📣"},{"code":"melon","unicode":"🍈"},{"code":"melting_face","unicode":"🫠"},{"code":"memo","unicode":"📝"},{"code":"men-with-bunny-ears-partying","unicode":"👯‍♂️"},{"code":"men_holding_hands","unicode":"👬"},{"code":"mending_heart","unicode":"❤️‍🩹"},{"code":"menorah_with_nine_branches","unicode":"🕎"},{"code":"mens","unicode":"🚹"},{"code":"mermaid","unicode":"🧜‍♀️"},{"code":"merman","unicode":"🧜‍♂️"},{"code":"merperson","unicode":"🧜"},{"code":"metro","unicode":"🚇"},{"code":"microbe","unicode":"🦠"},{"code":"microphone","unicode":"🎤"},{"code":"microscope","unicode":"🔬"},{"code":"middle_finger","unicode":"🖕"},{"code":"military_helmet","unicode":"🪖"},{"code":"milky_way","unicode":"🌌"},{"code":"minibus","unicode":"🚐"},{"code":"minidisc","unicode":"💽"},{"code":"mirror","unicode":"🪞"},{"code":"mirror_ball","unicode":"🪩"},{"code":"mobile_phone_off","unicode":"📴"},{"code":"money_mouth_face","unicode":"🤑"},{"code":"money_with_wings","unicode":"💸"},{"code":"moneybag","unicode":"💰"},{"code":"monkey","unicode":"🐒"},{"code":"monkey_face","unicode":"🐵"},{"code":"monorail","unicode":"🚝"},{"code":"moon","unicode":"🌔"},{"code":"moon_cake","unicode":"🥮"},{"code":"moose","unicode":"🫎"},{"code":"mortar_board","unicode":"🎓"},{"code":"mosque","unicode":"🕌"},{"code":"mosquito","unicode":"🦟"},{"code":"mostly_sunny","unicode":"🌤️"},{"code":"mother_christmas","unicode":"🤶"},{"code":"motor_boat","unicode":"🛥️"},{"code":"motor_scooter","unicode":"🛵"},{"code":"motorized_wheelchair","unicode":"🦼"},{"code":"motorway","unicode":"🛣️"},{"code":"mount_fuji","unicode":"🗻"},{"code":"mountain","unicode":"⛰️"},{"code":"mountain_bicyclist","unicode":"🚵"},{"code":"mountain_cableway","unicode":"🚠"},{"code":"mountain_railway","unicode":"🚞"},{"code":"mouse","unicode":"🐭"},{"code":"mouse2","unicode":"🐁"},{"code":"mouse_trap","unicode":"🪤"},{"code":"movie_camera","unicode":"🎥"},{"code":"moyai","unicode":"🗿"},{"code":"mrs_claus","unicode":"🤶"},{"code":"muscle","unicode":"💪"},{"code":"mushroom","unicode":"🍄"},{"code":"musical_keyboard","unicode":"🎹"},{"code":"musical_note","unicode":"🎵"},{"code":"musical_score","unicode":"🎼"},{"code":"mute","unicode":"🔇"},{"code":"mx_claus","unicode":"🧑‍🎄"},{"code":"nail_care","unicode":"💅"},{"code":"name_badge","unicode":"📛"},{"code":"national_park","unicode":"🏞️"},{"code":"nauseated_face","unicode":"🤢"},{"code":"nazar_amulet","unicode":"🧿"},{"code":"necktie","unicode":"👔"},{"code":"negative_squared_cross_mark","unicode":"❎"},{"code":"nerd_face","unicode":"🤓"},{"code":"nest_with_eggs","unicode":"🪺"},{"code":"nesting_dolls","unicode":"🪆"},{"code":"neutral_face","unicode":"😐"},{"code":"new","unicode":"🆕"},{"code":"new_moon","unicode":"🌑"},{"code":"new_moon_with_face","unicode":"🌚"},{"code":"newspaper","unicode":"📰"},{"code":"ng","unicode":"🆖"},{"code":"night_with_stars","unicode":"🌃"},{"code":"nine","unicode":"9️⃣"},{"code":"ninja","unicode":"🥷"},{"code":"no_bell","unicode":"🔕"},{"code":"no_bicycles","unicode":"🚳"},{"code":"no_entry","unicode":"⛔"},{"code":"no_entry_sign","unicode":"🚫"},{"code":"no_good","unicode":"🙅"},{"code":"no_mobile_phones","unicode":"📵"},{"code":"no_mouth","unicode":"😶"},{"code":"no_pedestrians","unicode":"🚷"},{"code":"no_smoking","unicode":"🚭"},{"code":"non-potable_water","unicode":"🚱"},{"code":"nose","unicode":"👃"},{"code":"notebook","unicode":"📓"},{"code":"notebook_with_decorative_cover","unicode":"📔"},{"code":"notes","unicode":"🎶"},{"code":"nut_and_bolt","unicode":"🔩"},{"code":"o","unicode":"⭕"},{"code":"o2","unicode":"🅾️"},{"code":"ocean","unicode":"🌊"},{"code":"octagonal_sign","unicode":"🛑"},{"code":"octopus","unicode":"🐙"},{"code":"oden","unicode":"🍢"},{"code":"office","unicode":"🏢"},{"code":"office_worker","unicode":"🧑‍💼"},{"code":"oil_drum","unicode":"🛢️"},{"code":"ok","unicode":"🆗"},{"code":"ok_hand","unicode":"👌"},{"code":"ok_woman","unicode":"🙆"},{"code":"old_key","unicode":"🗝️"},{"code":"older_adult","unicode":"🧓"},{"code":"older_man","unicode":"👴"},{"code":"older_woman","unicode":"👵"},{"code":"olive","unicode":"🫒"},{"code":"om_symbol","unicode":"🕉️"},{"code":"on","unicode":"🔛"},{"code":"oncoming_automobile","unicode":"🚘"},{"code":"oncoming_bus","unicode":"🚍"},{"code":"oncoming_police_car","unicode":"🚔"},{"code":"oncoming_taxi","unicode":"🚖"},{"code":"one","unicode":"1️⃣"},{"code":"one-piece_swimsuit","unicode":"🩱"},{"code":"onion","unicode":"🧅"},{"code":"open_book","unicode":"📖"},{"code":"open_file_folder","unicode":"📂"},{"code":"open_hands","unicode":"👐"},{"code":"open_mouth","unicode":"😮"},{"code":"ophiuchus","unicode":"⛎"},{"code":"orange_book","unicode":"📙"},{"code":"orange_heart","unicode":"🧡"},{"code":"orangutan","unicode":"🦧"},{"code":"orca","unicode":"🫍"},{"code":"orthodox_cross","unicode":"☦️"},{"code":"otter","unicode":"🦦"},{"code":"outbox_tray","unicode":"📤"},{"code":"owl","unicode":"🦉"},{"code":"ox","unicode":"🐂"},{"code":"oyster","unicode":"🦪"},{"code":"package","unicode":"📦"},{"code":"page_facing_up","unicode":"📄"},{"code":"page_with_curl","unicode":"📃"},{"code":"pager","unicode":"📟"},{"code":"palm_down_hand","unicode":"🫳"},{"code":"palm_tree","unicode":"🌴"},{"code":"palm_up_hand","unicode":"🫴"},{"code":"palms_up_together","unicode":"🤲"},{"code":"pancakes","unicode":"🥞"},{"code":"panda_face","unicode":"🐼"},{"code":"paperclip","unicode":"📎"},{"code":"parachute","unicode":"🪂"},{"code":"parking","unicode":"🅿️"},{"code":"parrot","unicode":"🦜"},{"code":"part_alternation_mark","unicode":"〽️"},{"code":"partly_sunny","unicode":"⛅"},{"code":"partly_sunny_rain","unicode":"🌦️"},{"code":"partying_face","unicode":"🥳"},{"code":"passenger_ship","unicode":"🛳️"},{"code":"passport_control","unicode":"🛂"},{"code":"paw_prints","unicode":"🐾"},{"code":"pea_pod","unicode":"🫛"},{"code":"peace_symbol","unicode":"☮️"},{"code":"peach","unicode":"🍑"},{"code":"peacock","unicode":"🦚"},{"code":"peanuts","unicode":"🥜"},{"code":"pear","unicode":"🍐"},{"code":"pencil","unicode":"📝"},{"code":"pencil2","unicode":"✏️"},{"code":"penguin","unicode":"🐧"},{"code":"pensive","unicode":"😔"},{"code":"people_holding_hands","unicode":"🧑‍🤝‍🧑"},{"code":"people_hugging","unicode":"🫂"},{"code":"performing_arts","unicode":"🎭"},{"code":"persevere","unicode":"😣"},{"code":"person_climbing","unicode":"🧗"},{"code":"person_doing_cartwheel","unicode":"🤸"},{"code":"person_feeding_baby","unicode":"🧑‍🍼"},{"code":"person_frowning","unicode":"🙍"},{"code":"person_in_lotus_position","unicode":"🧘"},{"code":"person_in_manual_wheelchair","unicode":"🧑‍🦽"},{"code":"person_in_manual_wheelchair_facing_right","unicode":"🧑‍🦽‍➡️"},{"code":"person_in_motorized_wheelchair","unicode":"🧑‍🦼"},{"code":"person_in_motorized_wheelchair_facing_right","unicode":"🧑‍🦼‍➡️"},{"code":"person_in_steamy_room","unicode":"🧖"},{"code":"person_in_tuxedo","unicode":"🤵"},{"code":"person_kneeling_facing_right","unicode":"🧎‍➡️"},{"code":"person_running_facing_right","unicode":"🏃‍➡️"},{"code":"person_walking_facing_right","unicode":"🚶‍➡️"},{"code":"person_with_ball","unicode":"⛹️"},{"code":"person_with_blond_hair","unicode":"👱"},{"code":"person_with_crown","unicode":"🫅"},{"code":"person_with_headscarf","unicode":"🧕"},{"code":"person_with_pouting_face","unicode":"🙎"},{"code":"person_with_probing_cane","unicode":"🧑‍🦯"},{"code":"person_with_white_cane_facing_right","unicode":"🧑‍🦯‍➡️"},{"code":"petri_dish","unicode":"🧫"},{"code":"phoenix","unicode":"🐦‍🔥"},{"code":"phone","unicode":"☎️"},{"code":"pick","unicode":"⛏️"},{"code":"pickup_truck","unicode":"🛻"},{"code":"pie","unicode":"🥧"},{"code":"pig","unicode":"🐷"},{"code":"pig2","unicode":"🐖"},{"code":"pig_nose","unicode":"🐽"},{"code":"pill","unicode":"💊"},{"code":"pilot","unicode":"🧑‍✈️"},{"code":"pinata","unicode":"🪅"},{"code":"pinched_fingers","unicode":"🤌"},{"code":"pinching_hand","unicode":"🤏"},{"code":"pineapple","unicode":"🍍"},{"code":"pink_heart","unicode":"🩷"},{"code":"pirate_flag","unicode":"🏴‍☠️"},{"code":"pisces","unicode":"♓"},{"code":"pizza","unicode":"🍕"},{"code":"placard","unicode":"🪧"},{"code":"place_of_worship","unicode":"🛐"},{"code":"playground_slide","unicode":"🛝"},{"code":"pleading_face","unicode":"🥺"},{"code":"plunger","unicode":"🪠"},{"code":"point_down","unicode":"👇"},{"code":"point_left","unicode":"👈"},{"code":"point_right","unicode":"👉"},{"code":"point_up","unicode":"☝️"},{"code":"point_up_2","unicode":"👆"},{"code":"polar_bear","unicode":"🐻‍❄️"},{"code":"police_car","unicode":"🚓"},{"code":"poodle","unicode":"🐩"},{"code":"poop","unicode":"💩"},{"code":"popcorn","unicode":"🍿"},{"code":"post_office","unicode":"🏣"},{"code":"postal_horn","unicode":"📯"},{"code":"postbox","unicode":"📮"},{"code":"potable_water","unicode":"🚰"},{"code":"potato","unicode":"🥔"},{"code":"potted_plant","unicode":"🪴"},{"code":"pouch","unicode":"👝"},{"code":"poultry_leg","unicode":"🍗"},{"code":"pound","unicode":"💷"},{"code":"pouring_liquid","unicode":"🫗"},{"code":"pouting_cat","unicode":"😾"},{"code":"pray","unicode":"🙏"},{"code":"prayer_beads","unicode":"📿"},{"code":"pregnant_man","unicode":"🫃"},{"code":"pregnant_person","unicode":"🫄"},{"code":"pregnant_woman","unicode":"🤰"},{"code":"pretzel","unicode":"🥨"},{"code":"prince","unicode":"🤴"},{"code":"princess","unicode":"👸"},{"code":"printer","unicode":"🖨️"},{"code":"probing_cane","unicode":"🦯"},{"code":"punch","unicode":"👊"},{"code":"purple_heart","unicode":"💜"},{"code":"purse","unicode":"👛"},{"code":"pushpin","unicode":"📌"},{"code":"put_litter_in_its_place","unicode":"🚮"},{"code":"question","unicode":"❓"},{"code":"rabbit","unicode":"🐰"},{"code":"rabbit2","unicode":"🐇"},{"code":"raccoon","unicode":"🦝"},{"code":"racehorse","unicode":"🐎"},{"code":"racing_car","unicode":"🏎️"},{"code":"racing_motorcycle","unicode":"🏍️"},{"code":"radio","unicode":"📻"},{"code":"radio_button","unicode":"🔘"},{"code":"radioactive_sign","unicode":"☢️"},{"code":"rage","unicode":"😡"},{"code":"railway_car","unicode":"🚃"},{"code":"railway_track","unicode":"🛤️"},{"code":"rain_cloud","unicode":"🌧️"},{"code":"rainbow","unicode":"🌈"},{"code":"rainbow-flag","unicode":"🏳️‍🌈"},{"code":"raised_back_of_hand","unicode":"🤚"},{"code":"raised_hand","unicode":"✋"},{"code":"raised_hand_with_fingers_splayed","unicode":"🖐️"},{"code":"raised_hands","unicode":"🙌"},{"code":"raising_hand","unicode":"🙋"},{"code":"ram","unicode":"🐏"},{"code":"ramen","unicode":"🍜"},{"code":"rat","unicode":"🐀"},{"code":"razor","unicode":"🪒"},{"code":"receipt","unicode":"🧾"},{"code":"recycle","unicode":"♻️"},{"code":"red_car","unicode":"🚗"},{"code":"red_circle","unicode":"🔴"},{"code":"red_envelope","unicode":"🧧"},{"code":"red_haired_man","unicode":"👨‍🦰"},{"code":"red_haired_person","unicode":"🧑‍🦰"},{"code":"red_haired_woman","unicode":"👩‍🦰"},{"code":"registered","unicode":"®️"},{"code":"relaxed","unicode":"☺️"},{"code":"relieved","unicode":"😌"},{"code":"reminder_ribbon","unicode":"🎗️"},{"code":"repeat","unicode":"🔁"},{"code":"repeat_one","unicode":"🔂"},{"code":"restroom","unicode":"🚻"},{"code":"reversed_hand_with_middle_finger_extended","unicode":"🖕"},{"code":"revolving_hearts","unicode":"💞"},{"code":"rewind","unicode":"⏪"},{"code":"rhinoceros","unicode":"🦏"},{"code":"ribbon","unicode":"🎀"},{"code":"rice","unicode":"🍚"},{"code":"rice_ball","unicode":"🍙"},{"code":"rice_cracker","unicode":"🍘"},{"code":"rice_scene","unicode":"🎑"},{"code":"right-facing_fist","unicode":"🤜"},{"code":"right_anger_bubble","unicode":"🗯️"},{"code":"rightwards_hand","unicode":"🫱"},{"code":"rightwards_pushing_hand","unicode":"🫸"},{"code":"ring","unicode":"💍"},{"code":"ring_buoy","unicode":"🛟"},{"code":"ringed_planet","unicode":"🪐"},{"code":"robot_face","unicode":"🤖"},{"code":"rock","unicode":"🪨"},{"code":"rocket","unicode":"🚀"},{"code":"roll_of_paper","unicode":"🧻"},{"code":"rolled_up_newspaper","unicode":"🗞️"},{"code":"roller_coaster","unicode":"🎢"},{"code":"roller_skate","unicode":"🛼"},{"code":"rolling_on_the_floor_laughing","unicode":"🤣"},{"code":"rooster","unicode":"🐓"},{"code":"root_vegetable","unicode":"🫜"},{"code":"rose","unicode":"🌹"},{"code":"rosette","unicode":"🏵️"},{"code":"rotating_light","unicode":"🚨"},{"code":"round_pushpin","unicode":"📍"},{"code":"rowboat","unicode":"🚣"},{"code":"ru","unicode":"🇷🇺"},{"code":"rugby_football","unicode":"🏉"},{"code":"runner","unicode":"🏃"},{"code":"running","unicode":"🏃"},{"code":"running_shirt_with_sash","unicode":"🎽"},{"code":"sa","unicode":"🈂️"},{"code":"safety_pin","unicode":"🧷"},{"code":"safety_vest","unicode":"🦺"},{"code":"sagittarius","unicode":"♐"},{"code":"sailboat","unicode":"⛵"},{"code":"sake","unicode":"🍶"},{"code":"salt","unicode":"🧂"},{"code":"saluting_face","unicode":"🫡"},{"code":"sandal","unicode":"👡"},{"code":"sandwich","unicode":"🥪"},{"code":"santa","unicode":"🎅"},{"code":"sari","unicode":"🥻"},{"code":"satellite","unicode":"🛰️"},{"code":"satellite_antenna","unicode":"📡"},{"code":"satisfied","unicode":"😆"},{"code":"sauropod","unicode":"🦕"},{"code":"saxophone","unicode":"🎷"},{"code":"scales","unicode":"⚖️"},{"code":"scarf","unicode":"🧣"},{"code":"school","unicode":"🏫"},{"code":"school_satchel","unicode":"🎒"},{"code":"scientist","unicode":"🧑‍🔬"},{"code":"scissors","unicode":"✂️"},{"code":"scooter","unicode":"🛴"},{"code":"scorpion","unicode":"🦂"},{"code":"scorpius","unicode":"♏"},{"code":"scream","unicode":"😱"},{"code":"scream_cat","unicode":"🙀"},{"code":"screwdriver","unicode":"🪛"},{"code":"scroll","unicode":"📜"},{"code":"seal","unicode":"🦭"},{"code":"seat","unicode":"💺"},{"code":"second_place_medal","unicode":"🥈"},{"code":"secret","unicode":"㊙️"},{"code":"see_no_evil","unicode":"🙈"},{"code":"seedling","unicode":"🌱"},{"code":"selfie","unicode":"🤳"},{"code":"serious_face_with_symbols_covering_mouth","unicode":"🤬"},{"code":"service_dog","unicode":"🐕‍🦺"},{"code":"seven","unicode":"7️⃣"},{"code":"sewing_needle","unicode":"🪡"},{"code":"shaking_face","unicode":"🫨"},{"code":"shallow_pan_of_food","unicode":"🥘"},{"code":"shamrock","unicode":"☘️"},{"code":"shark","unicode":"🦈"},{"code":"shaved_ice","unicode":"🍧"},{"code":"sheep","unicode":"🐑"},{"code":"shell","unicode":"🐚"},{"code":"shield","unicode":"🛡️"},{"code":"shinto_shrine","unicode":"⛩️"},{"code":"ship","unicode":"🚢"},{"code":"shirt","unicode":"👕"},{"code":"shit","unicode":"💩"},{"code":"shocked_face_with_exploding_head","unicode":"🤯"},{"code":"shoe","unicode":"👞"},{"code":"shopping_bags","unicode":"🛍️"},{"code":"shopping_trolley","unicode":"🛒"},{"code":"shorts","unicode":"🩳"},{"code":"shovel","unicode":"🪏"},{"code":"shower","unicode":"🚿"},{"code":"shrimp","unicode":"🦐"},{"code":"shrug","unicode":"🤷"},{"code":"shushing_face","unicode":"🤫"},{"code":"sign_of_the_horns","unicode":"🤘"},{"code":"signal_strength","unicode":"📶"},{"code":"singer","unicode":"🧑‍🎤"},{"code":"six","unicode":"6️⃣"},{"code":"six_pointed_star","unicode":"🔯"},{"code":"skateboard","unicode":"🛹"},{"code":"ski","unicode":"🎿"},{"code":"skier","unicode":"⛷️"},{"code":"skull","unicode":"💀"},{"code":"skull_and_crossbones","unicode":"☠️"},{"code":"skunk","unicode":"🦨"},{"code":"sled","unicode":"🛷"},{"code":"sleeping","unicode":"😴"},{"code":"sleeping_accommodation","unicode":"🛌"},{"code":"sleepy","unicode":"😪"},{"code":"sleuth_or_spy","unicode":"🕵️"},{"code":"slightly_frowning_face","unicode":"🙁"},{"code":"slightly_smiling_face","unicode":"🙂"},{"code":"slot_machine","unicode":"🎰"},{"code":"sloth","unicode":"🦥"},{"code":"small_airplane","unicode":"🛩️"},{"code":"small_blue_diamond","unicode":"🔹"},{"code":"small_orange_diamond","unicode":"🔸"},{"code":"small_red_triangle","unicode":"🔺"},{"code":"small_red_triangle_down","unicode":"🔻"},{"code":"smile","unicode":"😄"},{"code":"smile_cat","unicode":"😸"},{"code":"smiley","unicode":"😃"},{"code":"smiley_cat","unicode":"😺"},{"code":"smiling_face_with_3_hearts","unicode":"🥰"},{"code":"smiling_face_with_smiling_eyes_and_hand_covering_mouth","unicode":"🤭"},{"code":"smiling_face_with_tear","unicode":"🥲"},{"code":"smiling_imp","unicode":"😈"},{"code":"smirk","unicode":"😏"},{"code":"smirk_cat","unicode":"😼"},{"code":"smoking","unicode":"🚬"},{"code":"snail","unicode":"🐌"},{"code":"snake","unicode":"🐍"},{"code":"sneezing_face","unicode":"🤧"},{"code":"snow_capped_mountain","unicode":"🏔️"},{"code":"snow_cloud","unicode":"🌨️"},{"code":"snowboarder","unicode":"🏂"},{"code":"snowflake","unicode":"❄️"},{"code":"snowman","unicode":"☃️"},{"code":"snowman_without_snow","unicode":"⛄"},{"code":"soap","unicode":"🧼"},{"code":"sob","unicode":"😭"},{"code":"soccer","unicode":"⚽"},{"code":"socks","unicode":"🧦"},{"code":"softball","unicode":"🥎"},{"code":"soon","unicode":"🔜"},{"code":"sos","unicode":"🆘"},{"code":"sound","unicode":"🔉"},{"code":"space_invader","unicode":"👾"},{"code":"spades","unicode":"♠️"},{"code":"spaghetti","unicode":"🍝"},{"code":"sparkle","unicode":"❇️"},{"code":"sparkler","unicode":"🎇"},{"code":"sparkles","unicode":"✨"},{"code":"sparkling_heart","unicode":"💖"},{"code":"speak_no_evil","unicode":"🙊"},{"code":"speaker","unicode":"🔈"},{"code":"speaking_head_in_silhouette","unicode":"🗣️"},{"code":"speech_balloon","unicode":"💬"},{"code":"speedboat","unicode":"🚤"},{"code":"spider","unicode":"🕷️"},{"code":"spider_web","unicode":"🕸️"},{"code":"spiral_calendar_pad","unicode":"🗓️"},{"code":"spiral_note_pad","unicode":"🗒️"},{"code":"splatter","unicode":"🫟"},{"code":"spock-hand","unicode":"🖖"},{"code":"sponge","unicode":"🧽"},{"code":"spoon","unicode":"🥄"},{"code":"sports_medal","unicode":"🏅"},{"code":"squid","unicode":"🦑"},{"code":"stadium","unicode":"🏟️"},{"code":"staff_of_aesculapius","unicode":"⚕️"},{"code":"standing_person","unicode":"🧍"},{"code":"star","unicode":"⭐"},{"code":"star-struck","unicode":"🤩"},{"code":"star2","unicode":"🌟"},{"code":"star_and_crescent","unicode":"☪️"},{"code":"star_of_david","unicode":"✡️"},{"code":"stars","unicode":"🌠"},{"code":"station","unicode":"🚉"},{"code":"statue_of_liberty","unicode":"🗽"},{"code":"steam_locomotive","unicode":"🚂"},{"code":"stethoscope","unicode":"🩺"},{"code":"stew","unicode":"🍲"},{"code":"stopwatch","unicode":"⏱️"},{"code":"straight_ruler","unicode":"📏"},{"code":"strawberry","unicode":"🍓"},{"code":"stuck_out_tongue","unicode":"😛"},{"code":"stuck_out_tongue_closed_eyes","unicode":"😝"},{"code":"stuck_out_tongue_winking_eye","unicode":"😜"},{"code":"student","unicode":"🧑‍🎓"},{"code":"studio_microphone","unicode":"🎙️"},{"code":"stuffed_flatbread","unicode":"🥙"},{"code":"sun_behind_cloud","unicode":"🌥️"},{"code":"sun_behind_rain_cloud","unicode":"🌦️"},{"code":"sun_small_cloud","unicode":"🌤️"},{"code":"sun_with_face","unicode":"🌞"},{"code":"sunflower","unicode":"🌻"},{"code":"sunglasses","unicode":"😎"},{"code":"sunny","unicode":"☀️"},{"code":"sunrise","unicode":"🌅"},{"code":"sunrise_over_mountains","unicode":"🌄"},{"code":"superhero","unicode":"🦸"},{"code":"supervillain","unicode":"🦹"},{"code":"surfer","unicode":"🏄"},{"code":"sushi","unicode":"🍣"},{"code":"suspension_railway","unicode":"🚟"},{"code":"swan","unicode":"🦢"},{"code":"sweat","unicode":"😓"},{"code":"sweat_drops","unicode":"💦"},{"code":"sweat_smile","unicode":"😅"},{"code":"sweet_potato","unicode":"🍠"},{"code":"swimmer","unicode":"🏊"},{"code":"symbols","unicode":"🔣"},{"code":"synagogue","unicode":"🕍"},{"code":"syringe","unicode":"💉"},{"code":"t-rex","unicode":"🦖"},{"code":"table_tennis_paddle_and_ball","unicode":"🏓"},{"code":"taco","unicode":"🌮"},{"code":"tada","unicode":"🎉"},{"code":"takeout_box","unicode":"🥡"},{"code":"tamale","unicode":"🫔"},{"code":"tanabata_tree","unicode":"🎋"},{"code":"tangerine","unicode":"🍊"},{"code":"taurus","unicode":"♉"},{"code":"taxi","unicode":"🚕"},{"code":"tea","unicode":"🍵"},{"code":"teacher","unicode":"🧑‍🏫"},{"code":"teapot","unicode":"🫖"},{"code":"technologist","unicode":"🧑‍💻"},{"code":"teddy_bear","unicode":"🧸"},{"code":"telephone","unicode":"☎️"},{"code":"telephone_receiver","unicode":"📞"},{"code":"telescope","unicode":"🔭"},{"code":"tennis","unicode":"🎾"},{"code":"tent","unicode":"⛺"},{"code":"test_tube","unicode":"🧪"},{"code":"the_horns","unicode":"🤘"},{"code":"thermometer","unicode":"🌡️"},{"code":"thinking_face","unicode":"🤔"},{"code":"third_place_medal","unicode":"🥉"},{"code":"thong_sandal","unicode":"🩴"},{"code":"thought_balloon","unicode":"💭"},{"code":"thread","unicode":"🧵"},{"code":"three","unicode":"3️⃣"},{"code":"three_button_mouse","unicode":"🖱️"},{"code":"thumbsdown","unicode":"👎"},{"code":"thumbsup","unicode":"👍"},{"code":"thunder_cloud_and_rain","unicode":"⛈️"},{"code":"ticket","unicode":"🎫"},{"code":"tiger","unicode":"🐯"},{"code":"tiger2","unicode":"🐅"},{"code":"timer_clock","unicode":"⏲️"},{"code":"tired_face","unicode":"😫"},{"code":"tm","unicode":"™️"},{"code":"toilet","unicode":"🚽"},{"code":"tokyo_tower","unicode":"🗼"},{"code":"tomato","unicode":"🍅"},{"code":"tongue","unicode":"👅"},{"code":"toolbox","unicode":"🧰"},{"code":"tooth","unicode":"🦷"},{"code":"toothbrush","unicode":"🪥"},{"code":"top","unicode":"🔝"},{"code":"tophat","unicode":"🎩"},{"code":"tornado","unicode":"🌪️"},{"code":"tornado_cloud","unicode":"🌪️"},{"code":"trackball","unicode":"🖲️"},{"code":"tractor","unicode":"🚜"},{"code":"traffic_light","unicode":"🚥"},{"code":"train","unicode":"🚋"},{"code":"train2","unicode":"🚆"},{"code":"tram","unicode":"🚊"},{"code":"transgender_flag","unicode":"🏳️‍⚧️"},{"code":"transgender_symbol","unicode":"⚧️"},{"code":"treasure_chest","unicode":"🪎"},{"code":"triangular_flag_on_post","unicode":"🚩"},{"code":"triangular_ruler","unicode":"📐"},{"code":"trident","unicode":"🔱"},{"code":"triumph","unicode":"😤"},{"code":"troll","unicode":"🧌"},{"code":"trolleybus","unicode":"🚎"},{"code":"trombone","unicode":"🪊"},{"code":"trophy","unicode":"🏆"},{"code":"tropical_drink","unicode":"🍹"},{"code":"tropical_fish","unicode":"🐠"},{"code":"truck","unicode":"🚚"},{"code":"trumpet","unicode":"🎺"},{"code":"tshirt","unicode":"👕"},{"code":"tulip","unicode":"🌷"},{"code":"tumbler_glass","unicode":"🥃"},{"code":"turkey","unicode":"🦃"},{"code":"turtle","unicode":"🐢"},{"code":"tv","unicode":"📺"},{"code":"twisted_rightwards_arrows","unicode":"🔀"},{"code":"two","unicode":"2️⃣"},{"code":"two_hearts","unicode":"💕"},{"code":"two_men_holding_hands","unicode":"👬"},{"code":"two_women_holding_hands","unicode":"👭"},{"code":"u5272","unicode":"🈹"},{"code":"u5408","unicode":"🈴"},{"code":"u55b6","unicode":"🈺"},{"code":"u6307","unicode":"🈯"},{"code":"u6708","unicode":"🈷️"},{"code":"u6709","unicode":"🈶"},{"code":"u6e80","unicode":"🈵"},{"code":"u7121","unicode":"🈚"},{"code":"u7533","unicode":"🈸"},{"code":"u7981","unicode":"🈲"},{"code":"u7a7a","unicode":"🈳"},{"code":"uk","unicode":"🇬🇧"},{"code":"umbrella","unicode":"☂️"},{"code":"umbrella_on_ground","unicode":"⛱️"},{"code":"umbrella_with_rain_drops","unicode":"☔"},{"code":"unamused","unicode":"😒"},{"code":"underage","unicode":"🔞"},{"code":"unicorn_face","unicode":"🦄"},{"code":"unlock","unicode":"🔓"},{"code":"up","unicode":"🆙"},{"code":"upside_down_face","unicode":"🙃"},{"code":"us","unicode":"🇺🇸"},{"code":"v","unicode":"✌️"},{"code":"vampire","unicode":"🧛"},{"code":"vertical_traffic_light","unicode":"🚦"},{"code":"vhs","unicode":"📼"},{"code":"vibration_mode","unicode":"📳"},{"code":"video_camera","unicode":"📹"},{"code":"video_game","unicode":"🎮"},{"code":"violin","unicode":"🎻"},{"code":"virgo","unicode":"♍"},{"code":"volcano","unicode":"🌋"},{"code":"volleyball","unicode":"🏐"},{"code":"vs","unicode":"🆚"},{"code":"waffle","unicode":"🧇"},{"code":"walking","unicode":"🚶"},{"code":"waning_crescent_moon","unicode":"🌘"},{"code":"waning_gibbous_moon","unicode":"🌖"},{"code":"warning","unicode":"⚠️"},{"code":"wastebasket","unicode":"🗑️"},{"code":"watch","unicode":"⌚"},{"code":"water_buffalo","unicode":"🐃"},{"code":"water_polo","unicode":"🤽"},{"code":"watermelon","unicode":"🍉"},{"code":"wave","unicode":"👋"},{"code":"waving_black_flag","unicode":"🏴"},{"code":"waving_white_flag","unicode":"🏳️"},{"code":"wavy_dash","unicode":"〰️"},{"code":"waxing_crescent_moon","unicode":"🌒"},{"code":"waxing_gibbous_moon","unicode":"🌔"},{"code":"wc","unicode":"🚾"},{"code":"weary","unicode":"😩"},{"code":"wedding","unicode":"💒"},{"code":"weight_lifter","unicode":"🏋️"},{"code":"whale","unicode":"🐳"},{"code":"whale2","unicode":"🐋"},{"code":"wheel","unicode":"🛞"},{"code":"wheel_of_dharma","unicode":"☸️"},{"code":"wheelchair","unicode":"♿"},{"code":"white_check_mark","unicode":"✅"},{"code":"white_circle","unicode":"⚪"},{"code":"white_flower","unicode":"💮"},{"code":"white_frowning_face","unicode":"☹️"},{"code":"white_haired_man","unicode":"👨‍🦳"},{"code":"white_haired_person","unicode":"🧑‍🦳"},{"code":"white_haired_woman","unicode":"👩‍🦳"},{"code":"white_heart","unicode":"🤍"},{"code":"white_large_square","unicode":"⬜"},{"code":"white_medium_small_square","unicode":"◽"},{"code":"white_medium_square","unicode":"◻️"},{"code":"white_small_square","unicode":"▫️"},{"code":"white_square_button","unicode":"🔳"},{"code":"wilted_flower","unicode":"🥀"},{"code":"wind_blowing_face","unicode":"🌬️"},{"code":"wind_chime","unicode":"🎐"},{"code":"window","unicode":"🪟"},{"code":"wine_glass","unicode":"🍷"},{"code":"wing","unicode":"🪽"},{"code":"wink","unicode":"😉"},{"code":"wireless","unicode":"🛜"},{"code":"wolf","unicode":"🐺"},{"code":"woman","unicode":"👩"},{"code":"woman-biking","unicode":"🚴‍♀️"},{"code":"woman-bouncing-ball","unicode":"⛹️‍♀️"},{"code":"woman-bowing","unicode":"🙇‍♀️"},{"code":"woman-boy","unicode":"👩‍👦"},{"code":"woman-boy-boy","unicode":"👩‍👦‍👦"},{"code":"woman-cartwheeling","unicode":"🤸‍♀️"},{"code":"woman-facepalming","unicode":"🤦‍♀️"},{"code":"woman-frowning","unicode":"🙍‍♀️"},{"code":"woman-gesturing-no","unicode":"🙅‍♀️"},{"code":"woman-gesturing-ok","unicode":"🙆‍♀️"},{"code":"woman-getting-haircut","unicode":"💇‍♀️"},{"code":"woman-getting-massage","unicode":"💆‍♀️"},{"code":"woman-girl","unicode":"👩‍👧"},{"code":"woman-girl-boy","unicode":"👩‍👧‍👦"},{"code":"woman-girl-girl","unicode":"👩‍👧‍👧"},{"code":"woman-golfing","unicode":"🏌️‍♀️"},{"code":"woman-heart-man","unicode":"👩‍❤️‍👨"},{"code":"woman-heart-woman","unicode":"👩‍❤️‍👩"},{"code":"woman-juggling","unicode":"🤹‍♀️"},{"code":"woman-kiss-man","unicode":"👩‍❤️‍💋‍👨"},{"code":"woman-kiss-woman","unicode":"👩‍❤️‍💋‍👩"},{"code":"woman-lifting-weights","unicode":"🏋️‍♀️"},{"code":"woman-mountain-biking","unicode":"🚵‍♀️"},{"code":"woman-playing-handball","unicode":"🤾‍♀️"},{"code":"woman-playing-water-polo","unicode":"🤽‍♀️"},{"code":"woman-pouting","unicode":"🙎‍♀️"},{"code":"woman-raising-hand","unicode":"🙋‍♀️"},{"code":"woman-rowing-boat","unicode":"🚣‍♀️"},{"code":"woman-running","unicode":"🏃‍♀️"},{"code":"woman-shrugging","unicode":"🤷‍♀️"},{"code":"woman-surfing","unicode":"🏄‍♀️"},{"code":"woman-swimming","unicode":"🏊‍♀️"},{"code":"woman-tipping-hand","unicode":"💁‍♀️"},{"code":"woman-walking","unicode":"🚶‍♀️"},{"code":"woman-wearing-turban","unicode":"👳‍♀️"},{"code":"woman-with-bunny-ears-partying","unicode":"👯‍♀️"},{"code":"woman-woman-boy","unicode":"👩‍👩‍👦"},{"code":"woman-woman-boy-boy","unicode":"👩‍👩‍👦‍👦"},{"code":"woman-woman-girl","unicode":"👩‍👩‍👧"},{"code":"woman-woman-girl-boy","unicode":"👩‍👩‍👧‍👦"},{"code":"woman-woman-girl-girl","unicode":"👩‍👩‍👧‍👧"},{"code":"woman-wrestling","unicode":"🤼‍♀️"},{"code":"woman_and_man_holding_hands","unicode":"👫"},{"code":"woman_climbing","unicode":"🧗‍♀️"},{"code":"woman_feeding_baby","unicode":"👩‍🍼"},{"code":"woman_in_lotus_position","unicode":"🧘‍♀️"},{"code":"woman_in_manual_wheelchair","unicode":"👩‍🦽"},{"code":"woman_in_manual_wheelchair_facing_right","unicode":"👩‍🦽‍➡️"},{"code":"woman_in_motorized_wheelchair","unicode":"👩‍🦼"},{"code":"woman_in_motorized_wheelchair_facing_right","unicode":"👩‍🦼‍➡️"},{"code":"woman_in_steamy_room","unicode":"🧖‍♀️"},{"code":"woman_in_tuxedo","unicode":"🤵‍♀️"},{"code":"woman_kneeling","unicode":"🧎‍♀️"},{"code":"woman_kneeling_facing_right","unicode":"🧎‍♀️‍➡️"},{"code":"woman_running_facing_right","unicode":"🏃‍♀️‍➡️"},{"code":"woman_standing","unicode":"🧍‍♀️"},{"code":"woman_walking_facing_right","unicode":"🚶‍♀️‍➡️"},{"code":"woman_with_beard","unicode":"🧔‍♀️"},{"code":"woman_with_probing_cane","unicode":"👩‍🦯"},{"code":"woman_with_veil","unicode":"👰‍♀️"},{"code":"woman_with_white_cane_facing_right","unicode":"👩‍🦯‍➡️"},{"code":"womans_clothes","unicode":"👚"},{"code":"womans_flat_shoe","unicode":"🥿"},{"code":"womans_hat","unicode":"👒"},{"code":"women-with-bunny-ears-partying","unicode":"👯‍♀️"},{"code":"women_holding_hands","unicode":"👭"},{"code":"womens","unicode":"🚺"},{"code":"wood","unicode":"🪵"},{"code":"woozy_face","unicode":"🥴"},{"code":"world_map","unicode":"🗺️"},{"code":"worm","unicode":"🪱"},{"code":"worried","unicode":"😟"},{"code":"wrench","unicode":"🔧"},{"code":"wrestlers","unicode":"🤼"},{"code":"writing_hand","unicode":"✍️"},{"code":"x","unicode":"❌"},{"code":"x-ray","unicode":"🩻"},{"code":"yarn","unicode":"🧶"},{"code":"yawning_face","unicode":"🥱"},{"code":"yellow_heart","unicode":"💛"},{"code":"yen","unicode":"💴"},{"code":"yin_yang","unicode":"☯️"},{"code":"yo-yo","unicode":"🪀"},{"code":"yum","unicode":"😋"},{"code":"zany_face","unicode":"🤪"},{"code":"zap","unicode":"⚡"},{"code":"zebra_face","unicode":"🦓"},{"code":"zero","unicode":"0️⃣"},{"code":"zipper_mouth_face","unicode":"🤐"},{"code":"zombie","unicode":"🧟"},{"code":"zzz","unicode":"💤"}] \ No newline at end of file diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 000000000..5714801dd --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3b4d73f6c..cf74c4590 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -374,4 +374,5 @@ The service temporarily stores messages for channels you (and others) visit to p إلغاء تسجيل الدخول تصغير تكبير الصورة + تاريخ: %1$s diff --git a/app/src/main/res/values-b+zh+Hant+TW/strings.xml b/app/src/main/res/values-b+zh+Hant+TW/strings.xml index 69c4edf10..92ae6b428 100644 --- a/app/src/main/res/values-b+zh+Hant+TW/strings.xml +++ b/app/src/main/res/values-b+zh+Hant+TW/strings.xml @@ -24,8 +24,10 @@ 刪除頻道 已封鎖此頻道 尚未新增頻道 + 新增頻道以開始聊天 確認登出 您確定要登出嗎? + 登出? 登出 上傳媒體 拍照 @@ -43,25 +45,71 @@ FeelsDankMan DankChat 背景執行中 開啟表情符號選單 + 關閉表情符號選單 + 沒有最近使用的表情符號 + 表情符號 登入至 Twitch.tv 開始聊天 已斷線 尚未登入 回覆 - 您有新的提及訊息 + Send announcement 您有新的提及訊息 %1$s 剛於 #%2$s 提及了您 您剛在 #%1$s 中被提及 已登入為 %1$s 登入失敗 已複製: %1$s + 上傳完成: %1$s 在上傳時遭遇錯誤 上傳失敗: %1$s + 上傳 + 已複製到剪貼簿 + 複製連結 重試 已重整表情符號 讀取資料失敗: %1$s 因複數錯誤導致資料載入失敗:\n%1$s + DankChat 徽章 + 全域徽章 + 全域 FFZ 表情 + 全域 BTTV 表情 + 全域 7TV 表情 + 頻道徽章 + FFZ 表情 + BTTV 表情 + 7TV 表情 + Twitch 表情 + Cheermotes + 近期訊息 + %1$s (%2$s) + + 首次聊天 + 固定聊天訊息 + 巨大表情 + 動畫訊息 + 已兌換 %1$s + + %1$d 秒 + + + %1$d 分鐘 + + + %1$d 小時 + + + %1$d 天 + + + %1$d 週 + + %1$s %2$s + %1$s %2$s %3$s + 貼上 頻道名稱 + 頻道已經加入 + 退格鍵 常用 訂閱 頻道 @@ -82,6 +130,84 @@ %1$s新增了7TV表情%2$s。 %1$s重新命名了7TV表情%2$s成%3$s。 %1$s移除了7TV表情%2$s。 + + 因以下原因攔截了一則訊息: %1$s。允許將會把該訊息發佈到聊天室。 + 允許 + 拒絕 + 已允許 + 已拒絕 + 已過期 + 嘿!你的訊息正在被版主審核中,尚未發送。 + 版主已接受你的訊息。 + 版主已拒絕你的訊息。 + %1$s (等級 %2$d) + + 符合 %1$d 個封鎖詞彙 %2$s + + 無法%1$s AutoMod 訊息 - 該訊息已被處理。 + 無法%1$s AutoMod 訊息 - 您需要重新驗證。 + 無法%1$s AutoMod 訊息 - 您沒有權限執行此操作。 + 無法%1$s AutoMod 訊息 - 找不到目標訊息。 + 無法%1$s AutoMod 訊息 - 發生未知錯誤。 + %1$s 在 AutoMod 上新增了 %2$s 為封鎖詞彙。 + %1$s 在 AutoMod 上新增了 %2$s 為允許詞彙。 + %1$s 從 AutoMod 上移除了封鎖詞彙 %2$s。 + %1$s 從 AutoMod 上移除了允許詞彙 %2$s。 + + + 您被禁言了 %1$s + 您被 %2$s 禁言了 %1$s + 您被 %2$s 禁言了 %1$s: %3$s + %1$s 將 %2$s 禁言了 %3$s + %1$s 將 %2$s 禁言了 %3$s: %4$s + %1$s 已被禁言 %2$s + 您已被封鎖 + 您被 %1$s 封鎖了 + 您被 %1$s 封鎖了: %2$s + %1$s 封鎖了 %2$s + %1$s 封鎖了 %2$s: %3$s + %1$s 已被永久封鎖 + %1$s 解除了 %2$s 的禁言 + %1$s 解除了 %2$s 的封鎖 + %1$s 將 %2$s 設為了模組 + %1$s 取消了 %2$s 的模組身份 + %1$s 已將 %2$s 新增為此頻道的 VIP + %1$s 已將 %2$s 從此頻道的 VIP 中移除 + %1$s 已警告了 %2$s + %1$s 已警告了 %2$s: %3$s + %1$s 發起了對 %2$s 的突襲 + %1$s 取消了對 %2$s 的突襲 + %1$s 刪除了 %2$s 的訊息 + %1$s 刪除了 %2$s 的訊息,內容為: %3$s + %1$s 的一則訊息已被刪除 + %1$s 的一則訊息已被刪除,內容為: %2$s + %1$s 清除了聊天室 + 聊天室已被管理員清除 + %1$s 開啟了僅限表情符號模式 + %1$s 關閉了僅限表情符號模式 + %1$s 開啟了僅限追隨者模式 + %1$s 開啟了僅限追隨者模式 (%2$s) + %1$s 關閉了僅限追隨者模式 + %1$s 開啟了獨特聊天模式 + %1$s 關閉了獨特聊天模式 + %1$s 開啟了低速模式 + %1$s 開啟了低速模式 (%2$s) + %1$s 關閉了低速模式 + %1$s 開啟了僅限訂閱者模式 + %1$s 關閉了僅限訂閱者模式 + %1$s 在 %4$s 中將 %2$s 禁言了 %3$s + %1$s 在 %4$s 中將 %2$s 禁言了 %3$s: %5$s + %1$s 在 %3$s 中解除了 %2$s 的禁言 + %1$s 在 %3$s 中封鎖了 %2$s + %1$s 在 %3$s 中封鎖了 %2$s: %4$s + %1$s 在 %3$s 中解除了 %2$s 的封鎖 + %1$s 在 %3$s 中刪除了 %2$s 的訊息 + %1$s 在 %3$s 中刪除了 %2$s 的訊息,內容為: %4$s + %1$s%2$s + + \u0020(%1$d 次) + + < 訊息已被刪除 > Regex 常規式 新增項目 @@ -104,9 +230,12 @@ 確認移除頻道 您確定要將此頻道移除嗎? 您確定要移除頻道\"%1$s\"嗎? + 移除此頻道? + 移除頻道\"%1$s\"? 移除 確認封鎖頻道 您確定要封鎖頻道\"%1$s\"嗎? + 封鎖頻道\"%1$s\"? 封鎖 移除封鎖 提及使用者 @@ -130,6 +259,61 @@ 您可以設定一個自訂的主機來用於上傳媒體,例如imgur.com或是s-ul.eu。DankChat使用與Chatterino相同的設定格式。\n若需幫助請查看這則指導文章: https://wiki.chatterino.com/Image%20Uploader/ 開啟/關閉全螢幕 開啟/關閉實況 + 顯示實況 + 隱藏實況 + 僅音訊 + 退出僅音訊模式 + 全螢幕 + 退出全螢幕 + 隱藏輸入框 + 顯示輸入框 + 頻道滑動導航 + 在聊天中滑動切換頻道 + 頻道管理 + + 最多 %1$d 個動作 + + 搜尋訊息 + 上一則訊息 + 切換實況 + 頻道管理 + 全螢幕 + 隱藏輸入框 + 設定動作 + 偵錯 + 僅限表情符號 + 僅限訂閱者 + 低速模式 + 慢速模式 (%1$s) + 獨特聊天 (R9K) + 僅限追隨者 + 僅限追隨者 (%1$s) + 自訂 + 任何人 + %1$d秒 + %1$d分 + %1$d時 + %1$d天 + %1$d週 + %1$d月 + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + 啟用護盾模式? + 這將套用頻道預先設定的安全設定,可能包括聊天限制、AutoMod 設定和聊天驗證要求。 + 啟用 Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel 帳戶 重新登入 登出 @@ -140,12 +324,15 @@ 聊天室狀態 確認永ban 您確定要永ban這名用戶嗎? + 封鎖此使用者? Ban 確認禁言 禁言 確認訊息刪除 您確定要刪除這則訊息嗎? + 刪除此訊息? 刪除 + 清除聊天室? 更新聊天室模式 限定表情符號 訂閱者限定 @@ -157,6 +344,8 @@ 新增指令 移除指令 觸發 + 此觸發器已被內建指令保留 + 此觸發器已被其他指令使用 指令 自訂指令 檢舉 @@ -195,14 +384,57 @@ 提醒 聊天 一般 + 建議 + 訊息 + 用戶 + 表情與徽章 + 建議模式 + 輸入時建議匹配項目 + 僅在觸發字元後建議 關於 樣式 DankChat %1$s 是由 @flex3rs 及其貢獻者所製 顯示訊息輸入 顯示訊息輸入方框以便傳送訊息 使用系統預設 - 真黑暗模式 - 強制聊天室背景顏色設為黑色 + Amoled 深色模式 + 為 OLED 螢幕提供純黑背景 + 強調色 + 跟隨系統桌布 + 藍色 + 青色 + 綠色 + 萊姆色 + 黃色 + 橘色 + 紅色 + 粉紅色 + 紫色 + 靛色 + 棕色 + 灰色 + 色彩風格 + 系統預設 + 使用系統預設色彩配置 + Tonal Spot + 沉穩柔和的色調 + Neutral + 近乎單色,淡雅色調 + Vibrant + 鮮豔飽和的色彩 + Expressive + 活潑的色彩搭配偏移色調 + Rainbow + 廣泛的色調光譜 + Fruit Salad + 活潑的多彩調色盤 + Monochrome + 僅有黑、白和灰色 + Fidelity + 忠於強調色 + Content + 強調色搭配類似的第三色 + 更多風格 顯示 元件 顯示被靜音的訊息 @@ -214,8 +446,16 @@ 非常大 - 自動完成表情符號及使用者 - 於輸入訊息時,依照輸入字串顯示相關表情符號以及使用者名 + 建議 + 選擇輸入時顯示的建議類型 + 表情 + 以 : 觸發 + 用戶 + 以 @ 觸發 + Twitch 指令 + 以 / 觸發 + Supibot指令 + 以 $ 觸發 啟動時自動讀取聊天紀錄 在重新連接後載入訊息歷史 嘗試捕捉在連接斷開時漏掉的訊息 @@ -225,7 +465,7 @@ 頻道資訊 開發者選項 除錯模式 - 提供已攔截的例外訊息 + 在輸入欄中顯示除錯分析操作並在本機收集當機報告 時間戳記格式 啟用文字朗讀 朗讀目前選取頻道的訊息 @@ -239,6 +479,9 @@ 忽略 URL 在文字朗讀時忽略表情符號 忽略 表情符號 + 音量 + 音訊閃避 + TTS朗讀時降低其他應用程式的音量 TTS 行列顏色調整 以不同顏色隔開訊息 @@ -251,12 +494,19 @@ 使用者長按行為 輕觸開啟用戶資訊;長按提及用戶 輕觸提及用戶;長按開啟用戶資訊 + 為暱稱上色 + 為未設定顏色的用戶隨機分配顏色 強制使用英文 強制文字朗讀使用英文,而非系統預設語言 顯示第三方表情符號 Twitch 服務條款和用戶政策 顯示微項動作 顯示用於切換全螢幕、實況與聊天室模式的微項動作 + 顯示字元計數器 + 在輸入框中顯示字碼數 + 顯示清除輸入按鈕 + 顯示傳送按鈕 + 輸入 媒體上傳者 設定上傳者 近期上傳 @@ -285,6 +535,9 @@ 自訂登入 繞過Twitch指令處理 取消攔截Twitch指令並改為傳送至聊天室 + 聊天傳送協議 + 使用 Helix API 傳送 + 透過 Twitch Helix API 傳送聊天訊息,而非 IRC 7TV即時表情符號更新 即時表情更新背景行為 更新將停止於%1$s後。\n降低這個數字可能會提升電池壽命。 @@ -321,14 +574,17 @@ 使用者 黑名單的使用者 Twitch + 徽章 復原 物件已移除 已封鎖使用者 %1$s 無法解除封鎖使用者 %1$s + 徽章 無法封鎖使用者 %1$s 您的使用者名稱 訂閱與活動 公告 + 連續觀看 第一則訊息 固定訊息顯示 使用頻道點數兌換的醒目訊息 @@ -337,6 +593,7 @@ 基於某些格式產生通知與醒目訊息提示 當某些使用者發出訊息時產生通知與醒目提示 關閉來自於某些使用者的通知與醒目提示 (例如機器人) + 基於徽章對使用者的訊息產生通知與醒目提示 基於某些格式忽略訊息 忽略來自於某些使用的訊息 管理封鎖的Twitch使用者 @@ -354,10 +611,29 @@ 複製訊息 複製完整訊息 回覆訊息 + 回覆原始訊息 查看回覆串 複製訊息ID 更多… + 跳至訊息 + 訊息已不在聊天紀錄中 + 訊息紀錄 + 全域歷史 + 歷史紀錄:%1$s + 搜尋訊息… + 依使用者名稱篩選 + 包含連結的訊息 + 包含表情符號的訊息 + 依徽章名稱篩選 + 使用者 + 徽章 回覆至@%1$s + 悄悄話至@%1$s + 傳送悄悄話 + 新悄悄話 + 傳送悄悄話至 + 使用者名稱 + 開始 未找到回覆串 未找到訊息 使用表情符號 @@ -398,4 +674,223 @@ 顯示實況類別 同時也顯示實況類別 開關輸入框 + 實況主 + 管理員 + 工作人員 + 模組 + 主要模組 + 已驗證 + VIP + 創始者 + 訂閱者 + 選擇自訂標示色彩 + 預設 + 選擇色彩 + 切換應用程式列 + 錯誤: %s + + + DankChat + 讓我們為您進行設定。 + 開始使用 + 使用 Twitch 登入 + 登入以傳送訊息、使用您的表情符號、接收悄悄話,並解鎖所有功能。 + 系統會一次性要求您授予數項 Twitch 權限,這樣您在使用不同功能時就不需要重新授權。DankChat 僅在您主動要求時才會執行管理或直播相關操作。 + 使用 Twitch 登入 + 登入成功 + 略過 + 繼續 + 訊息紀錄 + DankChat 可於啟動時透過第三方服務載入歷史訊息。 為了取得這些訊息,DankChat 會將您開啟的頻道名稱傳送至該服務。 該服務會暫時儲存您(及其他人)造訪的頻道訊息以提供此功能。\n\n您可以稍後在設定中變更此選項,或透過 https://recent-messages.robotty.de/ 了解更多資訊 + 啟用 + 停用 + 通知 + 當應用程式在背景執行時,DankChat 可以在有人於聊天室中提及您時通知您。 + 允許通知 + 若不允許通知,當應用程式在背景執行時,您將不會收到聊天室中的提及通知。 + 開啟通知設定 + + + 可自訂的快捷動作,快速存取搜尋、實況等功能 + 點此查看更多動作並設定您的動作列 + 您可以在此自訂動作列中顯示的動作 + 在輸入框上向下滑動即可快速隱藏 + 點此恢復輸入框 + 下一步 + 了解 + 略過導覽 + 您可以在此新增更多頻道 + + + 一般 + 驗證 + 啟用 Twitch EventSub + 使用 EventSub 取代已棄用的 PubSub 來接收各種即時事件 + 啟用 EventSub 除錯輸出 + 將 EventSub 相關除錯資訊以系統訊息顯示 + 撤銷權杖並重新啟動 + 使目前的權杖失效並重新啟動應用程式 + 未登入 + 無法解析 %1$s 的頻道 ID + 訊息未送出 + 訊息被丟棄:%1$s(%2$s) + 缺少 user:write:chat 權限,請重新登入 + 無權在此頻道發送訊息 + 訊息過長 + 已被限速,請稍後再試 + 發送失敗:%1$s + + + 您必須登入才能使用 %1$s 指令 + 找不到符合該使用者名稱的使用者。 + 發生了未知錯誤。 + 您沒有權限執行該操作。 + 缺少必要的權限範圍。請重新登入您的帳號後再試一次。 + 缺少登入憑證。請重新登入您的帳號後再試一次。 + 用法:/block <user> + 您已成功封鎖使用者 %1$s + 無法封鎖使用者 %1$s,找不到該名稱的使用者! + 無法封鎖使用者 %1$s,發生未知錯誤! + 用法:/unblock <user> + 您已成功解除封鎖使用者 %1$s + 無法解除封鎖使用者 %1$s,找不到該名稱的使用者! + 無法解除封鎖使用者 %1$s,發生未知錯誤! + 頻道目前未直播。 + 已直播時間:%1$s + 您在此聊天室可用的指令:%1$s + 用法:%1$s <username> <message>。 + 悄悄話已送出。 + 悄悄話發送失敗 - %1$s + 用法:%1$s <message> - 以醒目提示方式引起對您訊息的注意。 + 公告發送失敗 - %1$s + 此頻道沒有任何管理員。 + 此頻道的管理員為 %1$s。 + 無法列出管理員 - %1$s + 用法:%1$s <username> - 授予使用者管理員身分。 + 您已將 %1$s 新增為此頻道的管理員。 + 無法新增頻道管理員 - %1$s + 用法:%1$s <username> - 撤銷使用者的管理員身分。 + 您已將 %1$s 從此頻道的管理員中移除。 + 無法移除頻道管理員 - %1$s + 此頻道沒有任何 VIP。 + 此頻道的 VIP 為 %1$s。 + 無法列出 VIP - %1$s + 用法:%1$s <username> - 授予使用者 VIP 身分。 + 您已將 %1$s 新增為此頻道的 VIP。 + 無法新增 VIP - %1$s + 用法:%1$s <username> - 撤銷使用者的 VIP 身分。 + 您已將 %1$s 從此頻道的 VIP 中移除。 + 無法移除 VIP - %1$s + 用法:%1$s <username> [原因] - 永久禁止使用者發言。原因為選填,將顯示給目標使用者和其他管理員。使用 /unban 來解除封禁。 + 封禁使用者失敗 - 您無法封禁自己。 + 封禁使用者失敗 - 您無法封禁實況主。 + 封禁使用者失敗 - %1$s + 用法:%1$s <username> - 解除對使用者的封禁。 + 解除封禁使用者失敗 - %1$s + 用法:%1$s <username> [時間][時間單位] [原因] - 暫時禁止使用者發言。時間(選填,預設:10 分鐘)必須為正整數;時間單位(選填,預設:s)必須為 s、m、h、d、w 其中之一;最長時間為 2 週。原因為選填,將顯示給目標使用者和其他管理員。 + 封禁使用者失敗 - 您無法對自己執行暫時禁言。 + 封禁使用者失敗 - 您無法對實況主執行暫時禁言。 + 暫時禁言使用者失敗 - %1$s + 刪除聊天訊息失敗 - %1$s + 用法:/delete <msg-id> - 刪除指定的訊息。 + 無效的 msg-id:\"%1$s\"。 + 刪除聊天訊息失敗 - %1$s + 用法:/color <color> - 顏色必須為 Twitch 支援的顏色之一(%1$s),或者如果您有 Turbo 或 Prime,可以使用 hex code(#000000)。 + 您的顏色已更改為 %1$s + 更改顏色為 %1$s 失敗 - %2$s + 已成功在 %1$s%2$s 新增直播標記。 + 建立直播標記失敗 - %1$s + 用法:/commercial <length> - 為目前頻道開始指定時長的廣告。有效的時長選項為 30、60、90、120、150 及 180 秒。 + + 開始 %1$d 秒的廣告時段。請注意您仍在直播中,並非所有觀眾都會收到廣告。您可以在 %2$d 秒後再次播放廣告。 + + 開始廣告失敗 - %1$s + 用法:/raid <username> - 突襲一位使用者。只有實況主可以發起突襲。 + 無效的使用者名稱:%1$s + 您已開始突襲 %1$s。 + 發起突襲失敗 - %1$s + 您已取消突襲。 + 取消突襲失敗 - %1$s + 用法:%1$s [時間] - 啟用僅限追隨者模式(只有追隨者可以發言)。時間(選填,預設:0 分鐘)必須為正數,後接時間單位(m、h、d、w);最長時間為 3 個月。 + 此聊天室已在 %1$s 僅限追隨者模式中。 + 更新聊天設定失敗 - %1$s + 此聊天室未處於僅限追隨者模式。 + 此聊天室已在僅限表情模式中。 + 此聊天室未處於僅限表情模式。 + 此聊天室已在僅限訂閱者模式中。 + 此聊天室未處於僅限訂閱者模式。 + 此聊天室已在獨特聊天模式中。 + 此聊天室未處於獨特聊天模式。 + 用法:%1$s [時間] - 啟用慢速模式(限制使用者發送訊息的頻率)。時間(選填,預設:30)必須為正整數秒數;最大值為 120。 + 此聊天室已在 %1$d 秒慢速模式中。 + 此聊天室未處於慢速模式。 + 用法:%1$s <username> - 向指定的 Twitch 使用者發送推薦。 + 已向 %1$s 發送推薦 + 發送推薦失敗 - %1$s + 護盾模式已啟用。 + 護盾模式已停用。 + 更新護盾模式失敗 - %1$s + 您無法對自己發送悄悄話。 + 由於 Twitch 限制,您現在需要驗證手機號碼才能發送悄悄話。您可以在 Twitch 設定中新增手機號碼。https://www.twitch.tv/settings/security + 收件人不允許來自陌生人或您的悄悄話。 + 您被 Twitch 限速了。請幾秒後再試。 + 您每天最多只能向 40 位不同的收件人發送悄悄話。在每日限制內,您每秒最多可發送 3 則悄悄話,每分鐘最多可發送 100 則悄悄話。 + 由於 Twitch 限制,此指令只能由實況主使用。請改用 Twitch 網站。 + %1$s 已經是此頻道的管理員。 + %1$s 目前是 VIP,請先 /unvip 再重試此指令。 + %1$s 不是此頻道的管理員。 + %1$s 未被此頻道封禁。 + %1$s 已在此頻道中被封禁。 + 您無法對 %2$s 執行 %1$s。 + 對此使用者存在衝突的封禁操作。請再試一次。 + 顏色必須為 Twitch 支援的顏色之一(%1$s),或者如果您有 Turbo 或 Prime,可以使用 hex code(#000000)。 + 您必須正在直播才能播放廣告。 + 您必須等到冷卻時間結束後才能再次播放廣告。 + 指令必須包含大於零的廣告時段長度。 + 您沒有進行中的突襲。 + 頻道無法突襲自己。 + 實況主不能向自己發送推薦。 + 實況主目前未直播或觀眾人數不足。 + 時間超出有效範圍:%1$s。 + 該訊息已被處理。 + 找不到目標訊息。 + 您的訊息太長了。 + 您被限速了。請稍後再試。 + 目標使用者 + 日誌檢視器 + 檢視應用程式日誌 + 日誌 + 分享日誌 + 檢視日誌 + 沒有可用的日誌檔案 + 搜尋日誌 + + 已選取 %1$d 項 + + 複製選取的日誌 + 清除選取 + 偵測到當機 + 應用程式在您上次使用期間當機。 + 執行緒:%1$s + 複製 + 聊天報告 + 加入 #flex3rs 並準備當機摘要以傳送 + 電郵報告 + 透過電子郵件傳送詳細的當機報告 + 透過電子郵件傳送當機報告 + 以下資料將包含在報告中: + 堆疊追蹤 + 包含目前的記錄檔 + 當機報告 + 檢視最近的當機報告 + 找不到當機報告 + 當機報告 + 分享當機報告 + 刪除 + 刪除此當機報告? + 全部清除 + 刪除所有當機報告? + 捲動至底部 + 查看歷史 + 沒有符合目前篩選條件的訊息 diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index b8ac333c4..30b570fba 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -48,20 +48,76 @@ Адключана Вы не ўвайшлі Адказаць - У вас новыя згадванні + Send announcement У вас новыя згадванні %1$s згадаў вас у #%2$s Вас згадалі ў #%1$s Вы ўвайшлі як %1$s Памылка ўваходу Скапіявана: %1$s + Загрузка завершана: %1$s Памылка пры загрузцы Памылка пры загрузцы: %1$s + Загрузіць + Скапіравана ў буфер абмену + Капіяваць URL Паўтарыць Смайлы абноўлены Памылка загрузкі дадзеных: %1$s Не ўдалося загрузіць даныя: некалькі памылак:\n%1$s + Значкі DankChat + Глабальныя значкі + Глабальныя FFZ-эмоўты + Глабальныя BTTV-эмоўты + Глабальныя 7TV-эмоўты + Значкі канала + FFZ-эмоўты + BTTV-эмоўты + 7TV-эмоўты + Twitch-эмоўты + Cheermote-ы + Апошнія паведамленні + %1$s (%2$s) + + Першае паведамленне + Павышанае паведамленне + Гіганцкі эмоут + Аніміраванае паведамленне + Выкарыстана: %1$s + + %1$d секунду + %1$d секунды + %1$d секунд + %1$d секунд + + + %1$d хвіліну + %1$d хвіліны + %1$d хвілін + %1$d хвілін + + + %1$d гадзіну + %1$d гадзіны + %1$d гадзін + %1$d гадзін + + + %1$d дзень + %1$d дні + %1$d дзён + %1$d дзён + + + %1$d тыдзень + %1$d тыдні + %1$d тыдняў + %1$d тыдняў + + %1$s %2$s + %1$s %2$s %3$s Уставіць Назва канала + Канал ужо дададзены Нядаўнія Смайлы падпіскі Смайлы канала @@ -152,6 +208,8 @@ Дадаць каманду Выдаліць каманду Трыгер + Гэты трыгер зарэзерваваны ўбудаванай камандай + Гэты трыгер ужо выкарыстоўваецца іншай камандай Каманда Уласныя каманды Паскардзіцца @@ -189,14 +247,57 @@ Апавяшчэнні Чат Агульныя + Падказкі + Паведамленні + Карыстальнікі + Эмоцыі і значкі + Рэжым падказак + Прапаноўваць супадзенні падчас уводу + Прапаноўваць толькі пасля сімвала-трыгера Пра праграму Выгляд DankChat %1$s створаны @flex3rs і іншымі ўдзельнікамі Адлюстроўваць радок уводу Адлюстроўваць радок для ўводу паведамленняў У адпаведнасці з сістэмай - Сапраўдная цёмная тэма - Перамыкаць колер задняга фону чату на чорны + Amoled цёмны рэжым + Чыста чорны фон для OLED экранаў + Акцэнтны колер + Паводле сістэмных шпалер + Сіні + Бірузовы + Зялёны + Лаймавы + Жоўты + Аранжавы + Чырвоны + Ружовы + Фіялетавы + Індыга + Карычневы + Шэры + Стыль колераў + Сістэмны па змаўчанні + Выкарыстоўваць стандартную колеравую палітру сістэмы + Tonal Spot + Спакойныя і прыглушаныя колеры + Neutral + Амаль манахромны, лёгкі адценне + Vibrant + Яркія і насычаныя колеры + Expressive + Гульнявыя колеры са зрушанымі адценнямі + Rainbow + Шырокі спектр адценняў + Fruit Salad + Гульнявая, шматколерная палітра + Monochrome + Толькі чорны, белы і шэры + Fidelity + Захоўвае дакладнасць акцэнтнага колеру + Content + Акцэнтны колер з аналагічным трацічным + Больш стыляў Адлюстраванне Кампаненты Адлюстроўваць выдаленыя паведамленні @@ -208,8 +309,16 @@ Маленькі Вялікі Вялізны - Падказкі карыстальнікаў і смайлаў - Адлюстроўваць падказкі для нікаў карыстальнікаў і смайлаў пры наборы паведамленняў + Падказкі + Абярыце, якія падказкі паказваць пры ўводзе + Эмоцыі + Выклікаць з : + Карыстальнікі + Выклікаць з @ + Каманды Twitch + Выклікаць з / + Каманды Supibot + Выклікаць з $ Загружаць гісторыю паведамленняў адразу Загрузіць гісторыю паведамленняў пасля паўторнага падключэння Спрабаваць атрымаць прапушчаныя паведамленні, якія не былі атрыманы падчас разрыву злучэння @@ -219,7 +328,7 @@ Дадзеныя канала Налады для распрацоўшчыкаў Рэжым адладкі - Адлюстроўваць інфармацыю пра запісаныя выключэнні + Паказваць дзеянне адладкавай аналітыкі ў панэлі ўводу і збіраць справаздачы пра збоі лакальна Фармат часавых пазнак Уключыць сінтэзатар гаворкі Зачытвае паведамленні актыўнага канала @@ -233,6 +342,9 @@ Ігнараваць URL Ігнараваць смайлы і эмодзі ў сінтэзатары гаворкі Ігнараваць смайлы + Гучнасць + Прыглушэнне гуку + Змяншаць гучнасць іншых праграм падчас агучкі Сінтэзатар гаворкі Шахматныя лініі Падзяляць кожную лінію, карыстаючыся колерамі фону з рознай яркасцю @@ -245,12 +357,19 @@ Паводзіны пры доўгім націску на нік карыстальніка Звычайны націск адкрывае ўсплывальнае акно з інфармацыяй пра карыстальніка, доўгае – згадванне Звычайны націск адкрывае згадванне, доўгае – усплывальнае акно з інфармацыяй пра карыстальніка + Каляровыя нікі + Прызначаць выпадковы колер карыстальнікам без усталяванага колеру Пераключыць мову на ангельскую Пераключыць мову сінтэзатара гаворкі з сістэмнага на ангельскую Бачныя пабочныя смайлы Умовы карыстання і палітыка карыстальніка Twitch: Адлюстроўваць хуткія пераключальнікі Адлюстроўваць пераключальнікі для поўнаэкраннага рэжыму і налады рэжымаў чату + Паказваць лічыльнік сімвалаў + Адлюстроўвае колькасць кодавых пунктаў у полі ўводу + Паказваць кнопку ачысткі ўводу + Паказваць кнопку адпраўкі + Увод Загрузчык медыя Наладзіць загрузчык Нядаўнія загрузкі @@ -279,6 +398,9 @@ Індывідуальны лагін Абыход апрацоўкі каманд Twitch Адключае перахопліванне каманд Twitch і адпраўляе іх у чат + Пратакол адпраўкі чата + Выкарыстоўваць Helix API для адпраўкі + Адпраўляць паведамленні чата праз Twitch Helix API замест IRC Абнаўленні смайлаў 7TV у рэальным часе Паводзіны фону абнаўленняў смайлаў у рэальным часе Абнаўленні спыняюцца пасля %1$s.\nЗніжэнне гэтага значэння можа павялічыць час работы ад батарэі. @@ -323,6 +445,7 @@ Ваша імя карыстальніка Падпіскі і падзеі Аб\'явы + Серыі праглядаў Першыя паведамленні Павышаныя паведамленні Выдзяленні, абмененыя за балы канала @@ -348,9 +471,22 @@ Скапіраваць тэкст Скапіраваць увесь тэкст Адказаць на паведамленне + Адказаць на зыходнае паведамленне Паглядзець галіну Скапіраваць ID паведамлення Больш… + Перайсці да паведамлення + Паведамленне больш не ў гісторыі чата + Гісторыя паведамленняў + Глабальная гісторыя + Гісторыя: %1$s + Пошук паведамленняў… + Фільтр па імені карыстальніка + Паведамленні са спасылкамі + Паведамленні з эмоўтамі + Фільтр па назве значка + Карыстальнік + Значок У адказ @%1$s Галіна адказаў не знойдзена Паведамленне не знойдзена @@ -401,4 +537,399 @@ Паказваць катэгорыю трансляцыі Таксама паказваць катэгорыю трансляцыі Пераключыць радок уводу + Пераключыць панэль праграмы + Памылка: %s + Выйсці? + Выдаліць гэты канал? + Выдаліць канал \"%1$s\"? + Заблакаваць канал \"%1$s\"? + Забаніць гэтага карыстальніка? + Выдаліць гэтае паведамленне? + Ачысціць чат? + Наладжвальныя дзеянні для хуткага доступу да пошуку, трансляцый і іншага + Націсніце тут для дадатковых дзеянняў і наладкі панэлі дзеянняў + Тут вы можаце наладзіць, якія дзеянні адлюстроўваюцца на панэлі дзеянняў + Правядзіце ўніз па полю ўводу, каб хутка схаваць яго + Націсніце тут, каб вярнуць поле ўводу + Далей + Зразумела + Прапусціць тур + Тут вы можаце дадаць больш каналаў + + + Паведамленне затрымана з прычыны: %1$s. Дазвол апублікуе яго ў чаце. + Дазволіць + Адхіліць + Ухвалена + Адхілена + Тэрмін скончыўся + Гэй! Тваё паведамленне правяраецца мадэратарамі і яшчэ не адпраўлена. + Мадэратары прынялі тваё паведамленне. + Мадэратары адхілілі тваё паведамленне. + %1$s (узровень %2$d) + + супадае з %1$d заблакаваным тэрмінам %2$s + супадае з %1$d заблакаванымі тэрмінамі %2$s + супадае з %1$d заблакаванымі тэрмінамі %2$s + супадае з %1$d заблакаванымі тэрмінамі %2$s + + Не ўдалося %1$s паведамленне AutoMod - паведамленне ўжо апрацавана. + Не ўдалося %1$s паведамленне AutoMod - вам трэба паўторна аўтарызавацца. + Не ўдалося %1$s паведамленне AutoMod - у вас няма дазволу на гэтае дзеянне. + Не ўдалося %1$s паведамленне AutoMod - мэтавае паведамленне не знойдзена. + Не ўдалося %1$s паведамленне AutoMod - адбылася невядомая памылка. + %1$s дадаў %2$s як заблакаваны тэрмін у AutoMod. + %1$s дадаў %2$s як дазволены тэрмін у AutoMod. + %1$s выдаліў %2$s як заблакаваны тэрмін з AutoMod. + %1$s выдаліў %2$s як дазволены тэрмін з AutoMod. + + + + Вас было заглушана на %1$s + Вас было заглушана на %1$s мадэратарам %2$s + Вас было заглушана на %1$s мадэратарам %2$s: %3$s + %1$s заглушыў %2$s на %3$s + %1$s заглушыў %2$s на %3$s: %4$s + %1$s быў заглушаны на %2$s + Вас было забанена + Вас было забанена мадэратарам %1$s + Вас было забанена мадэратарам %1$s: %2$s + %1$s забаніў %2$s + %1$s забаніў %2$s: %3$s + %1$s быў перманентна забанены + %1$s зняў заглушэнне з %2$s + %1$s разбаніў %2$s + %1$s прызначыў мадэратарам %2$s + %1$s зняў мадэратара з %2$s + %1$s дадаў %2$s як VIP гэтага канала + %1$s выдаліў %2$s як VIP гэтага канала + %1$s папярэдзіў %2$s + %1$s папярэдзіў %2$s: %3$s + %1$s пачаў рэйд на %2$s + %1$s адмяніў рэйд на %2$s + %1$s выдаліў паведамленне ад %2$s + %1$s выдаліў паведамленне ад %2$s з тэкстам: %3$s + Паведамленне ад %1$s было выдалена + Паведамленне ад %1$s было выдалена з тэкстам: %2$s + %1$s ачысціў чат + Чат быў ачышчаны мадэратарам + %1$s уключыў рэжым толькі эмоцыі + %1$s выключыў рэжым толькі эмоцыі + %1$s уключыў рэжым толькі для падпісчыкаў канала + %1$s уключыў рэжым толькі для падпісчыкаў канала (%2$s) + %1$s выключыў рэжым толькі для падпісчыкаў канала + %1$s уключыў рэжым унікальнага чату + %1$s выключыў рэжым унікальнага чату + %1$s уключыў павольны рэжым + %1$s уключыў павольны рэжым (%2$s) + %1$s выключыў павольны рэжым + %1$s уключыў рэжым толькі для падпісчыкаў + %1$s выключыў рэжым толькі для падпісчыкаў + %1$s заглушыў %2$s на %3$s у %4$s + %1$s заглушыў %2$s на %3$s у %4$s: %5$s + %1$s зняў заглушэнне з %2$s у %3$s + %1$s забаніў %2$s у %3$s + %1$s забаніў %2$s у %3$s: %4$s + %1$s разбаніў %2$s у %3$s + %1$s выдаліў паведамленне ад %2$s у %3$s + %1$s выдаліў паведамленне ад %2$s у %3$s з тэкстам: %4$s + %1$s%2$s + + \u0020(%1$d раз) + \u0020(%1$d разы) + \u0020(%1$d разоў) + \u0020(%1$d разоў) + + + + Выдаліць + Адправіць шэпт + Шэпт да @%1$s + Новы шэпт + Адправіць шэпт да + Імя карыстальніка + Адправіць + + + Толькі эмоуты + Толькі падпісчыкі + Павольны рэжым + Павольны рэжым (%1$s) + Унікальны чат (R9K) + Толькі фалаверы + Толькі фалаверы (%1$s) + Іншае + Любы + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Актываваць рэжым шчыта? + Гэта прыменіць папярэдне наладжаныя параметры бяспекі канала, якія могуць уключаць абмежаванні чата, налады AutoMod і патрабаванні верыфікацыі. + Актываваць Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Дадайце канал, каб пачаць размову + Няма нядаўніх эмоутаў + + + Паказаць трансляцыю + Схаваць трансляцыю + Толькі аўдыё + Выйсці з рэжыму аўдыё + На ўвесь экран + Выйсці з поўнаэкраннага рэжыму + Схаваць увод + Паказаць увод + Навігацыя каналаў свайпам + Пераключэнне каналаў свайпам па чаце + Мадэрацыя канала + + + Пошук паведамленняў + Апошняе паведамленне + Пераключыць трансляцыю + Мадэрацыя канала + На ўвесь экран + Схаваць увод + Наладзіць дзеянні + Адладка + + Максімум %1$d дзеянне + Максімум %1$d дзеянні + Максімум %1$d дзеянняў + Максімум %1$d дзеянняў + + + + DankChat + Давайце ўсё наладзім. + Увайсці праз Twitch + Увайдзіце, каб адпраўляць паведамленні, выкарыстоўваць свае эмоуты, атрымліваць шэпты і разблакаваць усе функцыі. + Вам будзе прапанавана даць некалькі дазволаў Twitch адразу, каб вам не давялося паўторна аўтарызавацца пры выкарыстанні розных функцый. DankChat выконвае дзеянні мадэрацыі і кіравання стрымам толькі тады, калі вы самі гэта запытваеце. + Увайсці праз Twitch + Уваход паспяховы + Апавяшчэнні + DankChat можа апавяшчаць вас, калі нехта згадвае вас у чаце, пакуль праграма працуе ў фоне. + Дазволіць апавяшчэнні + Адкрыць налады апавяшчэнняў + Без апавяшчэнняў вы не даведаецеся, калі нехта згадвае вас у чаце, пакуль праграма працуе ў фоне. + Гісторыя паведамленняў + DankChat загружае гістарычныя паведамленні са старонняга сэрвісу пры запуску. Для атрымання паведамленняў DankChat адпраўляе назвы адкрытых каналаў гэтаму сэрвісу. Сэрвіс часова захоўвае паведамленні наведаных каналаў.\n\nВы можаце змяніць гэта пазней у наладах або даведацца больш на https://recent-messages.robotty.de/ + Уключыць + Адключыць + Працягнуць + Пачаць + Прапусціць + + + Агульныя + Аўтарызацыя + Уключыць Twitch EventSub + Выкарыстоўвае EventSub для розных падзей у рэальным часе замест састарэлага PubSub + Уключыць адладачны вывад EventSub + Выводзіць адладачную інфармацыю пра EventSub як сістэмныя паведамленні + Адклікаць токен і перазапусціць + Робіць бягучы токен несапраўдным і перазапускае праграму + Не ўвайшлі ў сістэму + Не ўдалося вызначыць ID канала для %1$s + Паведамленне не было адпраўлена + Паведамленне адхілена: %1$s (%2$s) + Адсутнічае дазвол user:write:chat, калі ласка, увайдзіце зноў + Няма дазволу адпраўляць паведамленні ў гэтым канале + Паведамленне занадта вялікае + Перавышаны ліміт запытаў, паспрабуйце пазней + Памылка адпраўкі: %1$s + + + Вы павінны ўвайсці, каб выкарыстоўваць каманду %1$s + Не знойдзены карыстальнік з такім імем. + Адбылася невядомая памылка. + У вас няма дазволу на гэта дзеянне. + Адсутнічае неабходны дазвол. Увайдзіце зноў і паспрабуйце яшчэ раз. + Адсутнічаюць уліковыя даныя. Увайдзіце зноў і паспрабуйце яшчэ раз. + Выкарыстанне: /block <user> + Вы паспяхова заблакавалі карыстальніка %1$s + Не ўдалося заблакаваць карыстальніка %1$s, карыстальнік з такім імем не знойдзены! + Не ўдалося заблакаваць карыстальніка %1$s, адбылася невядомая памылка! + Выкарыстанне: /unblock <user> + Вы паспяхова разблакавалі карыстальніка %1$s + Не ўдалося разблакаваць карыстальніка %1$s, карыстальнік з такім імем не знойдзены! + Не ўдалося разблакаваць карыстальніка %1$s, адбылася невядомая памылка! + Канал не ў эфіры. + Час у эфіры: %1$s + Даступныя вам каманды ў гэтым пакоі: %1$s + Выкарыстанне: %1$s <username> <message>. + Шэпт адпраўлены. + Не ўдалося адправіць шэпт - %1$s + Выкарыстанне: %1$s <message> - Прыцягніце ўвагу да свайго паведамлення з дапамогай вылучэння. + Не ўдалося адправіць аб\'яву - %1$s + У гэтага канала няма мадэратараў. + Мадэратары гэтага канала: %1$s. + Не ўдалося атрымаць спіс мадэратараў - %1$s + Выкарыстанне: %1$s <username> - Даць карыстальніку статус мадэратара. + Вы дадалі %1$s як мадэратара гэтага канала. + Не ўдалося дадаць мадэратара канала - %1$s + Выкарыстанне: %1$s <username> - Адклікаць статус мадэратара ў карыстальніка. + Вы выдалілі %1$s з мадэратараў гэтага канала. + Не ўдалося выдаліць мадэратара канала - %1$s + У гэтага канала няма VIP. + VIP гэтага канала: %1$s. + Не ўдалося атрымаць спіс VIP - %1$s + Выкарыстанне: %1$s <username> - Даць карыстальніку статус VIP. + Вы дадалі %1$s як VIP гэтага канала. + Не ўдалося дадаць VIP - %1$s + Выкарыстанне: %1$s <username> - Адклікаць статус VIP у карыстальніка. + Вы выдалілі %1$s з VIP гэтага канала. + Не ўдалося выдаліць VIP - %1$s + Выкарыстанне: %1$s <username> [прычына] - Назаўжды забараніць карыстальніку пісаць у чат. Прычына неабавязковая і будзе паказана мэтаваму карыстальніку і іншым мадэратарам. Выкарыстоўвайце /unban для зняцця бана. + Не ўдалося забаніць карыстальніка - Вы не можаце забаніць сябе. + Не ўдалося забаніць карыстальніка - Вы не можаце забаніць стрымера. + Не ўдалося забаніць карыстальніка - %1$s + Выкарыстанне: %1$s <username> - Зняць бан з карыстальніка. + Не ўдалося разбаніць карыстальніка - %1$s + Выкарыстанне: %1$s <username> [працягласць][адзінка часу] [прычына] - Часова забараніць карыстальніку пісаць у чат. Працягласць (неабавязковая, па змаўчанні: 10 хвілін) павінна быць дадатным цэлым лікам; адзінка часу (неабавязковая, па змаўчанні: s) павінна быць адной з s, m, h, d, w; максімальная працягласць - 2 тыдні. Прычына неабавязковая і будзе паказана мэтаваму карыстальніку і іншым мадэратарам. + Не ўдалося забаніць карыстальніка - Вы не можаце зрабіць тайм-аўт самому сабе. + Не ўдалося забаніць карыстальніка - Вы не можаце зрабіць тайм-аўт стрымеру. + Не ўдалося зрабіць тайм-аўт карыстальніку - %1$s + Не ўдалося выдаліць паведамленні чата - %1$s + Выкарыстанне: /delete <msg-id> - Выдаляе ўказанае паведамленне. + Няправільны msg-id: \"%1$s\". + Не ўдалося выдаліць паведамленні чата - %1$s + Выкарыстанне: /color <color> - Колер павінен быць адным з падтрымліваемых колераў Twitch (%1$s) або hex code (#000000), калі ў вас ёсць Turbo або Prime. + Ваш колер зменены на %1$s + Не ўдалося змяніць колер на %1$s - %2$s + Паспяхова дададзены маркер стрыму ў %1$s%2$s. + Не ўдалося стварыць маркер стрыму - %1$s + Выкарыстанне: /commercial <length> - Запускае рэкламу зададзенай працягласці для бягучага канала. Дапушчальныя варыянты: 30, 60, 90, 120, 150 і 180 секунд. + + Пачынаецца рэкламная паўза працягласцю %1$d секунд. Памятайце, што вы ўсё яшчэ ў эфіры, і не ўсе гледачы атрымаюць рэкламу. Вы можаце запусціць наступную рэкламу праз %2$d секунд. + Пачынаецца рэкламная паўза працягласцю %1$d секунд. Памятайце, што вы ўсё яшчэ ў эфіры, і не ўсе гледачы атрымаюць рэкламу. Вы можаце запусціць наступную рэкламу праз %2$d секунд. + Пачынаецца рэкламная паўза працягласцю %1$d секунд. Памятайце, што вы ўсё яшчэ ў эфіры, і не ўсе гледачы атрымаюць рэкламу. Вы можаце запусціць наступную рэкламу праз %2$d секунд. + Пачынаецца рэкламная паўза працягласцю %1$d секунд. Памятайце, што вы ўсё яшчэ ў эфіры, і не ўсе гледачы атрымаюць рэкламу. Вы можаце запусціць наступную рэкламу праз %2$d секунд. + + Не ўдалося запусціць рэкламу - %1$s + Выкарыстанне: /raid <username> - Зрабіць рэйд на карыстальніка. Толькі стрымер можа пачаць рэйд. + Няправільнае імя карыстальніка: %1$s + Вы пачалі рэйд на %1$s. + Не ўдалося пачаць рэйд - %1$s + Вы адмянілі рэйд. + Не ўдалося адмяніць рэйд - %1$s + Выкарыстанне: %1$s [працягласць] - Уключае рэжым толькі для падпісчыкаў (толькі падпісчыкі могуць пісаць у чат). Працягласць (неабавязковая, па змаўчанні: 0 хвілін) павінна быць дадатным лікам з адзінкай часу (m, h, d, w); максімальная працягласць - 3 месяцы. + Гэты пакой ужо ў рэжыме толькі для падпісчыкаў (%1$s). + Не ўдалося абнавіць налады чата - %1$s + Гэты пакой не ў рэжыме толькі для падпісчыкаў. + Гэты пакой ужо ў рэжыме толькі эмоцый. + Гэты пакой не ў рэжыме толькі эмоцый. + Гэты пакой ужо ў рэжыме толькі для падпісчыкаў. + Гэты пакой не ў рэжыме толькі для падпісчыкаў. + Гэты пакой ужо ў рэжыме унікальнага чата. + Гэты пакой не ў рэжыме унікальнага чата. + Выкарыстанне: %1$s [працягласць] - Уключае павольны рэжым (абмяжоўвае частату адпраўкі паведамленняў). Працягласць (неабавязковая, па змаўчанні: 30) павінна быць дадатным лікам секунд; максімум - 120. + Гэты пакой ужо ў павольным рэжыме (%1$d секунд). + Гэты пакой не ў павольным рэжыме. + Выкарыстанне: %1$s <username> - Адпраўляе шаўтаўт указанаму карыстальніку Twitch. + Шаўтаўт адпраўлены %1$s + Не ўдалося адправіць шаўтаўт - %1$s + Рэжым шчыта актываваны. + Рэжым шчыта дэактываваны. + Не ўдалося абнавіць рэжым шчыта - %1$s + Вы не можаце шаптаць самому сабе. + З-за абмежаванняў Twitch, цяпер патрабуецца пацверджаны нумар тэлефона для адпраўкі шэптаў. Вы можаце дадаць нумар тэлефона ў наладах Twitch. https://www.twitch.tv/settings/security + Атрымальнік не дазваляе шэпты ад незнаёмых або ад вас напрамую. + Twitch абмяжоўвае вашу хуткасць. Паспрабуйце зноў праз некалькі секунд. + Вы можаце адпраўляць шэпты максімум 40 унікальным атрымальнікам у дзень. У межах дзённага ліміту вы можаце адпраўляць максімум 3 шэпты ў секунду і максімум 100 шэптаў у хвіліну. + З-за абмежаванняў Twitch гэта каманда даступна толькі стрымеру. Калі ласка, выкарыстоўвайце сайт Twitch. + %1$s ужо з\'яўляецца мадэратарам гэтага канала. + %1$s цяпер з\'яўляецца VIP, выкарыстайце /unvip і паўтарыце каманду. + %1$s не з\'яўляецца мадэратарам гэтага канала. + %1$s не забанены ў гэтым канале. + %1$s ужо забанены ў гэтым канале. + Вы не можаце %1$s %2$s. + Адбылася канфліктная аперацыя бана для гэтага карыстальніка. Калі ласка, паспрабуйце зноў. + Колер павінен быць адным з падтрымліваемых колераў Twitch (%1$s) або hex code (#000000), калі ў вас ёсць Turbo або Prime. + Вы павінны весці прамую трансляцыю, каб запускаць рэкламу. + Вы павінны пачакаць, пакуль скончыцца перыяд чакання, перш чым запусціць наступную рэкламу. + Каманда павінна ўтрымліваць жаданую працягласць рэкламнай паўзы, большую за нуль. + У вас няма актыўнага рэйду. + Канал не можа рэйдзіць сам сябе. + Стрымер не можа даць шаўтаўт самому сабе. + Стрымер не вядзе прамую трансляцыю або не мае аднаго ці больш гледачоў. + Працягласць выходзіць за межы дапушчальнага дыяпазону: %1$s. + Паведамленне ўжо было апрацавана. + Мэтавае паведамленне не знойдзена. + Ваша паведамленне занадта доўгае. + Вашы запыты абмежаваныя. Паспрабуйце зноў праз хвіліну. + Мэтавы карыстальнік + Прагляд логаў + Прагляд логаў праграмы + Логі + Падзяліцца логамі + Праглядзець логі + Няма даступных файлаў логаў + Пошук у логах + + Выбрана: %1$d + Выбрана: %1$d + Выбрана: %1$d + Выбрана: %1$d + + Капіраваць выбраныя логі + Ачысціць выбар + Выяўлены збой + Праграма аварыйна завяршылася падчас апошняга сеансу. + Паток: %1$s + Капіраваць + Справаздача ў чат + Далучаецца да #flex3rs і рыхтуе зводку збою для адпраўкі + Справаздача па эл. пошце + Адправіць падрабязную справаздачу пра збой па электроннай пошце + Адправіць справаздачу пра збой па электроннай пошце + Наступныя дадзеныя будуць уключаны ў справаздачу: + Стэк выклікаў + Уключыць бягучы файл журнала + Справаздачы пра збоі + Прагляд апошніх справаздач пра збоі + Справаздачы пра збоі не знойдзены + Справаздача пра збой + Падзяліцца справаздачай пра збой + Выдаліць + Выдаліць гэту справаздачу пра збой? + Ачысціць усё + Выдаліць усе справаздачы пра збоі? + Пракруціць уніз + Значок + Адміністратар + Стрымер + Заснавальнік + Галоўны мадэратар + Мадэратар + Супрацоўнік + Падпісчык + Пацверджаны + VIP + Значкі + Стварайце апавяшчэнні і вылучайце паведамленні карыстальнікаў на аснове значкоў. + Абраць колер + Абраць карыстальніцкі колер вылучэння + Па змаўчанні + Паглядзець гісторыю + Няма паведамленняў, якія адпавядаюць бягучым фільтрам diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 1105404ba..0a40abe3a 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -10,6 +10,7 @@ Afegir canal Renombrar canal D\'acord + Desa Cancel·lar Descartar Copiar @@ -36,28 +37,84 @@ Hosting extern proporcionat per %1$s, utilitzar sota el teu propi risc. Pujada de contingut personalitzat Missatge copiat + ID del missatge copiat Error informació copiada Parar FeelsDankMan DankChat obert en segon pla Obrir el menú d\'emotes + Tanca el menú d\'emotes + Cap emote recent + Emotes Iniciar sessió a Twitch.tv Comença a xatejar Desconnectat Sessió no iniciada - Tens noves mencions + Resposta + Send announcement Tens noves mencions %1$s t\'acaba de mencionar en #%2$s Has estat mencionat a #%1$s Iniciant sessió com %1$s Error en iniciar sessió Copiat: %1$s + Pujada completada: %1$s Error durant la pujada Error durant la pujada: %1$s + Puja + Copiat al porta-retalls + Copia l\'URL Reintentar Emotes recargats Càrrega de dades fallida: %1$s + Càrrega de dades fallida amb múltiples errors:\n%1$s + Insígnies DankChat + Insígnies globals + Emotes FFZ globals + Emotes BTTV globals + Emotes 7TV globals + Insígnies del canal + Emotes FFZ + Emotes BTTV + Emotes 7TV + Emotes Twitch + Cheermotes + Missatges recents + %1$s (%2$s) + + Primer missatge + Missatge destacat + Emote gegant + Missatge animat + Bescanviat %1$s + %1$d segon + %1$d segons + %1$d segons + + + %1$d minut + %1$d minuts + %1$d minuts + + + %1$d hora + %1$d hores + %1$d hores + + + %1$d dia + %1$d dies + %1$d dies + + + %1$d setmana + %1$d setmanes + %1$d setmanes + + %1$s %2$s + %1$s %2$s %3$s Enganxa Nom del canal + El canal ja està afegit Recent Subscriptors Canal @@ -74,6 +131,10 @@ No han pogut carregar els emotes de FFZ (Error %1$s) No han pogut carregar els emotes de BTTV (Error %1$s) No han pogut carregar els emotes de 7TV (Error %1$s) + %1$s ha canviat el conjunt d\'emotes 7TV actiu a \"%2$s\". + %1$s ha afegit l\'emote 7TV %2$s. + %1$s ha reanomenat l\'emote 7TV %2$s a %3$s. + %1$s ha eliminat l\'emote 7TV %2$s. < Missatge eliminat > Regex Afegeix una entrada @@ -85,9 +146,11 @@ El servei emmagatzema temporalment missatges per a canals que tu (i altres) visi - Per a no fer ús d\'aquesta característica, clica Opt-Out a baix o desactiva la càrrega de l\'historial de missatges des de la configuració més tard. - Pots aprendre més del teu propi canal visitant https://recent-messages.robotty.de/
+ Opt-in Opt-out Més Mencions / Missatges privats + Fil de respostes Missatges privats %1$s t\'ha enviat un missatge privat Mencions @@ -99,6 +162,7 @@ El servei emmagatzema temporalment missatges per a canals que tu (i altres) visi Esteu segurs que voleu bloquejar el canal \"%1$s\"? Bloquejar Desbloquejar + Desvetar Mencionar l\'usuari Xiuxiuejar usuari Creat: %1$s @@ -130,8 +194,11 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Modes de xat Confirmar ban Estàs segur que vols vetar aquest usuari? + Vetar Confirmar expulsió temporal + Expulsar temporalment Confimar eliminació del missatge + Estàs segur que vols eliminar aquest missatge? Eliminar Actualitzar modes de xat Només emotes. @@ -143,6 +210,9 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Minuts Afegir un commandament Eliminar el commandament + Disparador + Aquest disparador està reservat per un comandament integrat + Aquest disparador ja està en ús per un altre comandament Commandaments Comandaments personalitzats Reportar @@ -154,7 +224,20 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Esborrar pujades Amfitrió Reinicia + Token OAuth + Verifica i desa + Només es suporten Client IDs que funcionen amb l\'API Helix de Twitch + Mostra els permisos requerits + Permisos requerits + Permisos que falten + Alguns permisos requerits per DankChat falten al token i algunes funcionalitats podrien no funcionar correctament. Vols continuar fent servir aquest token?\nFalten: %1$s + Continuar + Permisos que falten: %1$s + Error: %1$s + El token no pot estar buit + El token no és vàlid Moderador + Moderador en cap Predit \"%1$s\" Nivell %1$s Mostrar l\'hora @@ -168,13 +251,57 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Notificacions Xat General + Suggeriments + Missatges + Usuaris + Emotes i insgnies + Mode de suggeriment + Suggereix coincidències mentre escrius + Suggereix només després d\'un caràcter activador + Quant a Aparença DankChat %1$s creat per @flex3rs i col·laboradors Mostrar entrada Mostra el camp d\'entrada per enviar missatges Seguir sistema per defecte - Mode oscur vertader - Força el color de fons del xat a negre + Mode fosc AMOLED + Fons negre pur per a pantalles OLED + Color d\'accent + Segueix el fons de pantalla del sistema + Blau + Blau xarxet + Verd + Llima + Groc + Taronja + Vermell + Rosa + Porpra + Índigo + Marró + Gris + Estil de color + Predeterminat del sistema + Utilitza la paleta de colors predeterminada del sistema + Tonal Spot + Colors calmats i suaus + Neutral + Gairebé monocrom, matís subtil + Vibrant + Colors intensos i saturats + Expressive + Colors juganers amb tons desplaçats + Rainbow + Ampli espectre de tons + Fruit Salad + Paleta juganer i multicolor + Monochrome + Només negre, blanc i gris + Fidelity + Fidel al color d\'accent + Content + Color d\'accent amb terciari anàleg + Més estils Visualització Components Mostrar missatges timed out @@ -186,15 +313,26 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Petita Gran Molt gran - Suggeriments d\'usuari i emotes - Mostrar suggeriments per emotes i usuaris actius al escriure + Suggeriments + Tria quins suggeriments mostrar mentre escrius + Emotes + Activar amb : + Usuaris + Activar amb @ + Ordres de Twitch + Activar amb / + Ordres de Supibot + Activar amb $ Carregar historial de missatges a l\'inici + Carregar historial de missatges després de reconnectar + Intenta obtenir els missatges perduts que no s\'han rebut durant les caigudes de connexió Historial de missatges Obre tauler de control Aprendre més sobre el servei i desactivar historial de missatges pel teu propi canal Dades del canal Opcions de desenvolupador - Proporciona informació per a qualsevol excepció que hagi estat atrapada + Mode de depuració + Mostra l\'acció d\'analítiques de depuració a la barra d\'entrada i recull informes d\'error localment Format del temps Activar TTS Llegeix en veu alta missatges del canal actiu @@ -208,6 +346,9 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Ignora els URL Ignora emotes i emoticones a TTS Ignorar emotes + Volum + Atenuació d\'àudio + Reduir el volum d\'altres aplicacions mentre el TTS parla TTS Línies a quadres Separar cada línia amb diferent brillantor de fons @@ -220,12 +361,19 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Comportament del doble click sobre usuaris Clic normal obre una finestra emergent, clic llarg per a mencions Clic normal per a mencions, clic llarg obre una finestra emergent + Acolorir noms d\'usuari + Assignar un color aleatori als usuaris sense un color definit Forçar llenguatge a anglès Forçar llenguatge de la veu TTS a anglès en comptes del per defecte en el sistema Emotes de tercers visibles Twitch termes de servei i política d\'usuari Mostrar accions chip Mostrar chips per alternar pantalla completa, streams i ajustar modes de xat + Mostra el comptador de caràcters + Mostra el recompte de punts de codi al camp d\'entrada + Mostra el botó d\'esborrar l\'entrada + Mostra el botó d\'enviar + Entrada Pujador de media Configurar pujador Pujades recents @@ -237,6 +385,7 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Tema clar Permetre emotes no allistats Desactiva la filtració d\'emotes no aprovats o allistats + Host personalitzat de missatges recents Buscar informació de l\'emissió Busca periòdicament informació de l\'emissió dels canals oberts. Necessari per a iniciar emissions incorporades. Desactiva l\'entrada si no s\'està connectat al xat @@ -245,7 +394,33 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Inici de sessió expirat! L\'inici de sessió ha expirat! Si us plau, accediu de nou. Tornar a iniciar sessió + No s\'ha pogut verificar el token d\'inici de sessió, comprova la connexió. + Evita les recàrregues de l\'emissió Permet la prevenció experimental de les recàrregues de l\'emissió després del canvi d\'orientació o tornant a obrir DankChat. + Mostra els canvis després d\'actualitzar + Novetats + Inici de sessió personalitzat + Omet la gestió de comandaments de Twitch + Desactiva la intercepció de comandaments de Twitch i els envia al xat + Protocol d\'enviament del xat + Utilitza Helix API per enviar + Envia missatges de xat mitjançant Twitch Helix API en lloc d\'IRC + Actualitzacions en viu d\'emotes 7TV + Comportament de les actualitzacions d\'emotes en segon pla + Les actualitzacions s\'aturen després de %1$s.\nReducir aquest nombre pot millorar la durada de la bateria. + Les actualitzacions estan sempre actives.\nAfegir un temps límit pot millorar la durada de la bateria. + Les actualitzacions mai estan actives en segon pla. + Mai actiu + 1 minut + 5 minuts + 30 minuts + 1 hora + Sempre actiu + Emissions en directe + Activa el mode imatge en imatge + Permet que les emissions continuïn reproduint-se mentre l\'app és en segon pla + Reinicia la configuració del pujador de media + Estàs segur que vols reiniciar la configuració del pujador de media als valors per defecte? Reinicia Esborrar pujades recents Voleu esborrar l\'historial de pujada? Els arxius pujats no seran eliminats. @@ -257,38 +432,512 @@ Mira aquesta guia per a més ajuda: https://wiki.chatterino.com/Image%20Uploader Notificació Editar els missatges destacats Missatges destacats i ignorats - Nom d’usuari + Nom d\'usuari Bloquejar Reemplaçament Ignora Editar continguts ignorats Missatges Usuaris + Usuaris a la llista negra Twitch + Insígnies Desfer Element suprimit Usuari %1$s desbloquejat Error en desbloquejar l\'usuari %1$s + Insígnia Error en bloquejar l\'usuari %1$s El vostre nom d\'usuari Subscripcions i esdeveniments Anuncis + Ratxes de visualització Primers missatges + Missatges destacats Missatges destacats amb punts de canal + Respostes Personalitzat Crea notificacions i missatges destacats basats en certes configuracions Crea notificacions i destaca missatges de certs usuaris Desactiva notificacions i missatges destacats de certs usuaris (p.e. bots) + Crea notificacions i destaca missatges d\'usuaris segons les seves insígnies. Ignora missatges basats en certes configuracions Ignora missatges de certs usuaris Administrar usuaris bloquejats de Twitch Crea notificacions per a missatges privats Inici de sessió caducat! L\'inici de sessió ha caducat i no té accés a certes funcions. Si us plau, inicieu sessió de nou. + Visualització personalitzada d\'usuari Eliminar l\'ordenació personalitzada de l\'usuari Àlies Color personalitzat Àlies personalitzat Escollir color d\'usuari personalitzat Afegeix un nom i color personalitzat per a usuaris + Responent a + Mostra/amaga la barra d\'aplicació + Error: %s + Tancar sessió? + Eliminar aquest canal? + Eliminar el canal \"%1$s\"? + Bloquejar el canal \"%1$s\"? + Bloquejar aquest usuari? + Eliminar aquest missatge? + Netejar el xat? + Copia el missatge + Copia el missatge complet + Respon al missatge + Respon al missatge original + Mostra el fil + Copia l\'ID del missatge + Més… + Anar al missatge + No s\'ha trobat el missatge + El missatge ja no és a l\'historial del xat + Historial de missatges + Historial global + Historial: %1$s + Cerca missatges… + Filtra per nom d\'usuari + Missatges amb enllaços + Missatges amb emotes + Filtra per nom d\'insígnia + Usuari + Insígnia + Responent a @%1$s + No s\'ha trobat el fil de respostes + + + Retrocés + Envia un xiuxiueig + Xiuxiuejant a @%1$s + Nou xiuxiueig + Envia xiuxiueig a + Nom d\'usuari + Enviar + + + Utilitza l\'emote + Copia + Obre l\'enllaç de l\'emote + Imatge de l\'emote + Emote de Twitch + Emote FFZ del canal + Emote FFZ global + Emote BTTV del canal + Emote BTTV compartit + Emote BTTV global + Emote 7TV del canal + Emote 7TV global + Àlies de %1$s + Creat per %1$s + (Amplada zero) + Emote copiat + + + DankChat s\'ha actualitzat! + Novetats de la v%1$s: + + + Confirmar cancel·lació de l\'inici de sessió + Estàs segur que vols cancel·lar el procés d\'inici de sessió? + Cancel·la l\'inici de sessió + Redueix + Amplia + + + Enrere + Xat compartit + + En directe amb %1$d espectador durant %2$s + En directe amb %1$d espectadors durant %2$s + En directe amb %1$d espectadors durant %2$s + + + %d mes + %d mesos + %d mesos + + Llicències de codi obert + + En directe amb %1$d espectador a %2$s durant %3$s + En directe amb %1$d espectadors a %2$s durant %3$s + En directe amb %1$d espectadors a %2$s durant %3$s + + Mostra la categoria de l\'emissió + Mostra també la categoria de l\'emissió + Commuta l\'entrada + + + Emissor + Admin + Staff + Moderador + Moderador en cap + Verificat + VIP + Fundador + Subscriptor + + + Escull un color de ressaltat personalitzat + Per defecte + Escull un color + + + Només emotes + Només subscriptors + Mode lent + Mode lent (%1$s) + Xat únic (R9K) + Només seguidors + Només seguidors (%1$s) + Personalitzat + Qualsevol + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Activar el mode d\'escut? + Això aplicarà les configuracions de seguretat preconfigurades del canal, que poden incloure restriccions de xat, ajustos d\'AutoMod i requisits de verificació. + Activa Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Afegeix un canal per començar a xatejar + + + Mostra l\'emissió + Amaga l\'emissió + Només àudio + Surt del mode àudio + Pantalla completa + Surt de la pantalla completa + Amaga l\'entrada + Mostra l\'entrada + Navegació de canals per lliscament + Canvia de canal lliscant al xat + Moderació del canal + + + Cerca missatges + Últim missatge + Commuta l\'emissió + Moderació del canal + Pantalla completa + Amaga l\'entrada + Configura accions + Depuració + + Màxim %1$d acció + Màxim %1$d accions + Màxim %1$d accions + + + + S\'ha retingut un missatge per motiu: %1$s. Permetre el publicarà al xat. + Permetre + Denegar + Aprovat + Denegat + Caducat + Ei! El teu missatge està sent revisat pels moderadors i no s\'ha enviat. + Els moderadors han acceptat el teu missatge. + Els moderadors han rebutjat el teu missatge. + %1$s (nivell %2$d) + + coincideix amb %1$d terme bloquejat %2$s + coincideix amb %1$d termes bloquejats %2$s + coincideix amb %1$d termes bloquejats %2$s + + No s\'ha pogut %1$s el missatge d\'AutoMod - el missatge ja ha estat processat. + No s\'ha pogut %1$s el missatge d\'AutoMod - cal tornar a autenticar-se. + No s\'ha pogut %1$s el missatge d\'AutoMod - no tens permís per realitzar aquesta acció. + No s\'ha pogut %1$s el missatge d\'AutoMod - missatge objectiu no trobat. + No s\'ha pogut %1$s el missatge d\'AutoMod - s\'ha produït un error desconegut. + %1$s ha afegit %2$s com a terme bloquejat a AutoMod. + %1$s ha afegit %2$s com a terme permès a AutoMod. + %1$s ha eliminat %2$s com a terme bloquejat d\'AutoMod. + %1$s ha eliminat %2$s com a terme permès d\'AutoMod. + + + Has estat expulsat temporalment per %1$s + Has estat expulsat temporalment per %1$s per %2$s + Has estat expulsat temporalment per %1$s per %2$s: %3$s + %1$s ha expulsat temporalment %2$s per %3$s + %1$s ha expulsat temporalment %2$s per %3$s: %4$s + %1$s ha estat expulsat temporalment per %2$s + Has estat banejat + Has estat banejat per %1$s + Has estat banejat per %1$s: %2$s + %1$s ha banejat %2$s + %1$s ha banejat %2$s: %3$s + %1$s ha estat banejat permanentment + %1$s ha llevat l\'expulsió temporal de %2$s + %1$s ha desbanejat %2$s + %1$s ha nomenat %2$s moderador + %1$s ha retirat %2$s de moderador + %1$s ha afegit %2$s com a VIP d\'aquest canal + %1$s ha retirat %2$s com a VIP d\'aquest canal + %1$s ha advertit %2$s + %1$s ha advertit %2$s: %3$s + %1$s ha iniciat un raid a %2$s + %1$s ha cancel·lat el raid a %2$s + %1$s ha eliminat un missatge de %2$s + %1$s ha eliminat un missatge de %2$s dient: %3$s + Un missatge de %1$s ha estat eliminat + Un missatge de %1$s ha estat eliminat dient: %2$s + %1$s ha buidat el xat + El xat ha estat buidat per un moderador + %1$s ha activat el mode emote-only + %1$s ha desactivat el mode emote-only + %1$s ha activat el mode followers-only + %1$s ha activat el mode followers-only (%2$s) + %1$s ha desactivat el mode followers-only + %1$s ha activat el mode unique-chat + %1$s ha desactivat el mode unique-chat + %1$s ha activat el mode slow + %1$s ha activat el mode slow (%2$s) + %1$s ha desactivat el mode slow + %1$s ha activat el mode subscribers-only + %1$s ha desactivat el mode subscribers-only + %1$s ha expulsat temporalment %2$s per %3$s a %4$s + %1$s ha expulsat temporalment %2$s per %3$s a %4$s: %5$s + %1$s ha llevat l\'expulsió temporal de %2$s a %3$s + %1$s ha banejat %2$s a %3$s + %1$s ha banejat %2$s a %3$s: %4$s + %1$s ha desbanejat %2$s a %3$s + %1$s ha eliminat un missatge de %2$s a %3$s + %1$s ha eliminat un missatge de %2$s a %3$s dient: %4$s + %1$s%2$s + + \u0020(%1$d vegada) + \u0020(%1$d vegades) + \u0020(%1$d vegades) + + + + DankChat + Configurem-ho tot. + Inicia sessió amb Twitch + Inicia sessió per enviar missatges, fer servir els teus emotes, rebre xiuxiueigs i desbloquejar totes les funcions. + Se t\'demanarà que concedeixis diversos permisos de Twitch alhora perquè no hagis de tornar a autoritzar quan facis servir funcions diferents. DankChat només realitza accions de moderació i gestió de transmissions quan tu ho demanes. + Inicia sessió amb Twitch + Inici de sessió correcte + Notificacions + DankChat pot notificar-te quan algú et menciona al xat mentre l\'aplicació és en segon pla. + Permet les notificacions + Obre la configuració de notificacions + Sense notificacions, no sabràs quan algú et menciona al xat mentre l\'aplicació és en segon pla. + Historial de missatges + DankChat carrega missatges històrics d\'un servei de tercers en iniciar. Per obtenir els missatges, DankChat envia els noms dels canals oberts a aquest servei. El servei emmagatzema temporalment els missatges dels canals visitats.\n\nPots canviar això més tard a la configuració o saber-ne més a https://recent-messages.robotty.de/ + Activa + Desactiva + Continua + Comença + Omet + + + Accions personalitzables per accedir ràpidament a la cerca, les transmissions i més + Toca aquí per a més accions i per configurar la barra d\'accions + Aquí pots personalitzar quines accions apareixen a la barra d\'accions + Llisca cap avall sobre l\'entrada per amagar-la ràpidament + Toca aquí per recuperar l\'entrada + Següent + Entès + Ometre el tour + Aquí pots afegir més canals + + + General + Autenticació + Activa Twitch EventSub + Utilitza EventSub per a diversos esdeveniments en temps real en lloc del PubSub obsolet + Activa la sortida de depuració d\'EventSub + Mostra la sortida de depuració relacionada amb EventSub com a missatges del sistema + Revoca el token i reinicia + Invalida el token actual i reinicia l\'aplicació + No s\'ha iniciat sessió + No s\'ha pogut resoldre l\'ID del canal per a %1$s + El missatge no s\'ha enviat + Missatge descartat: %1$s (%2$s) + Falta el permís user:write:chat, torneu a iniciar sessió + No teniu autorització per enviar missatges en aquest canal + El missatge és massa gran + Límit de freqüència superat, torneu-ho a provar d\'aquí a un moment + Error d\'enviament: %1$s + + + Heu d\'estar connectat per utilitzar la comanda %1$s + No s\'ha trobat cap usuari amb aquest nom. + S\'ha produït un error desconegut. + No teniu permís per realitzar aquesta acció. + Falta el permís requerit. Torneu a iniciar sessió amb el vostre compte i torneu-ho a provar. + Falten les credencials d\'inici de sessió. Torneu a iniciar sessió amb el vostre compte i torneu-ho a provar. + Ús: /block <usuari> + Heu bloquejat correctament l\'usuari %1$s + No s\'ha pogut bloquejar l\'usuari %1$s, no s\'ha trobat cap usuari amb aquest nom! + No s\'ha pogut bloquejar l\'usuari %1$s, s\'ha produït un error desconegut! + Ús: /unblock <usuari> + Heu desbloquejat correctament l\'usuari %1$s + No s\'ha pogut desbloquejar l\'usuari %1$s, no s\'ha trobat cap usuari amb aquest nom! + No s\'ha pogut desbloquejar l\'usuari %1$s, s\'ha produït un error desconegut! + El canal no està en directe. + Temps en directe: %1$s + Comandes disponibles per a vós en aquesta sala: %1$s + Ús: %1$s <nom d\'usuari> <missatge>. + Xiuxiueig enviat. + No s\'ha pogut enviar el xiuxiueig - %1$s + Ús: %1$s <missatge> - Crideu l\'atenció sobre el vostre missatge amb un ressaltat. + No s\'ha pogut enviar l\'anunci - %1$s + Aquest canal no té cap moderador. + Els moderadors d\'aquest canal són %1$s. + No s\'han pogut llistar els moderadors - %1$s + Ús: %1$s <nom d\'usuari> - Atorgueu l\'estatus de moderador a un usuari. + Heu afegit %1$s com a moderador d\'aquest canal. + No s\'ha pogut afegir el moderador del canal - %1$s + Ús: %1$s <nom d\'usuari> - Revoqueu l\'estatus de moderador d\'un usuari. + Heu eliminat %1$s com a moderador d\'aquest canal. + No s\'ha pogut eliminar el moderador del canal - %1$s + Aquest canal no té cap VIP. + Els VIP d\'aquest canal són %1$s. + No s\'han pogut llistar els VIP - %1$s + Ús: %1$s <nom d\'usuari> - Atorgueu l\'estatus de VIP a un usuari. + Heu afegit %1$s com a VIP d\'aquest canal. + No s\'ha pogut afegir el VIP - %1$s + Ús: %1$s <nom d\'usuari> - Revoqueu l\'estatus de VIP d\'un usuari. + Heu eliminat %1$s com a VIP d\'aquest canal. + No s\'ha pogut eliminar el VIP - %1$s + Ús: %1$s <nom d\'usuari> [motiu] - Prohibeix permanentment a un usuari xatejar. El motiu és opcional i es mostrarà a l\'usuari objectiu i als altres moderadors. Utilitzeu /unban per eliminar una prohibició. + No s\'ha pogut prohibir l\'usuari - No us podeu prohibir a vós mateix. + No s\'ha pogut prohibir l\'usuari - No podeu prohibir l\'emissor. + No s\'ha pogut prohibir l\'usuari - %1$s + Ús: %1$s <nom d\'usuari> - Elimina la prohibició d\'un usuari. + No s\'ha pogut eliminar la prohibició de l\'usuari - %1$s + Ús: %1$s <nom d\'usuari> [durada][unitat de temps] [motiu] - Prohibeix temporalment a un usuari xatejar. La durada (opcional, per defecte: 10 minuts) ha de ser un nombre enter positiu; la unitat de temps (opcional, per defecte: s) ha de ser s, m, h, d o w; la durada màxima és de 2 setmanes. El motiu és opcional i es mostrarà a l\'usuari objectiu i als altres moderadors. + No s\'ha pogut prohibir l\'usuari - No us podeu aplicar un temps d\'espera a vós mateix. + No s\'ha pogut prohibir l\'usuari - No podeu aplicar un temps d\'espera a l\'emissor. + No s\'ha pogut aplicar el temps d\'espera a l\'usuari - %1$s + No s\'han pogut esborrar els missatges del xat - %1$s + Ús: /delete <msg-id> - Esborra el missatge especificat. + msg-id no vàlid: \"%1$s\". + No s\'han pogut esborrar els missatges del xat - %1$s + Ús: /color <color> - El color ha de ser un dels colors compatibles de Twitch (%1$s) o un hex code (#000000) si teniu Turbo o Prime. + El vostre color s\'ha canviat a %1$s + No s\'ha pogut canviar el color a %1$s - %2$s + S\'ha afegit correctament un marcador de directe a %1$s%2$s. + No s\'ha pogut crear el marcador de directe - %1$s + Ús: /commercial <durada> - Inicia un anunci amb la durada especificada per al canal actual. Les durades vàlides són 30, 60, 90, 120, 150 i 180 segons. + + S\'inicia una pausa publicitària de %1$d segons. Tingueu en compte que encara esteu en directe i no tots els espectadors rebran un anunci. Podeu executar un altre anunci en %2$d segons. + S\'inicia una pausa publicitària de %1$d segons. Tingueu en compte que encara esteu en directe i no tots els espectadors rebran un anunci. Podeu executar un altre anunci en %2$d segons. + S\'inicia una pausa publicitària de %1$d segons. Tingueu en compte que encara esteu en directe i no tots els espectadors rebran un anunci. Podeu executar un altre anunci en %2$d segons. + + No s\'ha pogut iniciar l\'anunci - %1$s + Ús: /raid <nom d\'usuari> - Feu una raid a un usuari. Només l\'emissor pot iniciar una raid. + Nom d\'usuari no vàlid: %1$s + Heu iniciat una raid a %1$s. + No s\'ha pogut iniciar la raid - %1$s + Heu cancel·lat la raid. + No s\'ha pogut cancel·lar la raid - %1$s + Ús: %1$s [durada] - Activa el mode només seguidors (només els seguidors poden xatejar). La durada (opcional, per defecte: 0 minuts) ha de ser un nombre positiu seguit d\'una unitat de temps (m, h, d, w); la durada màxima és de 3 mesos. + Aquesta sala ja està en mode només seguidors de %1$s. + No s\'han pogut actualitzar els paràmetres del xat - %1$s + Aquesta sala no està en mode només seguidors. + Aquesta sala ja està en mode només emotes. + Aquesta sala no està en mode només emotes. + Aquesta sala ja està en mode només subscriptors. + Aquesta sala no està en mode només subscriptors. + Aquesta sala ja està en mode de xat únic. + Aquesta sala no està en mode de xat únic. + Ús: %1$s [durada] - Activa el mode lent (limita la freqüència d\'enviament de missatges). La durada (opcional, per defecte: 30) ha de ser un nombre positiu de segons; màxim 120. + Aquesta sala ja està en mode lent de %1$d segons. + Aquesta sala no està en mode lent. + Ús: %1$s <nom d\'usuari> - Envia un shoutout a l\'usuari de Twitch especificat. + S\'ha enviat un shoutout a %1$s + No s\'ha pogut enviar el shoutout - %1$s + El mode d\'escut s\'ha activat. + El mode d\'escut s\'ha desactivat. + No s\'ha pogut actualitzar el mode d\'escut - %1$s + No us podeu enviar un xiuxiueig a vós mateix. + A causa de les restriccions de Twitch, ara es requereix un número de telèfon verificat per enviar xiuxiueigs. Podeu afegir un número de telèfon a la configuració de Twitch. https://www.twitch.tv/settings/security + El destinatari no accepta xiuxiueigs de desconeguts o directament de vós. + Twitch us està limitant la velocitat. Torneu-ho a provar d\'aquí a uns segons. + Podeu enviar xiuxiueigs a un màxim de 40 destinataris únics per dia. Dins del límit diari, podeu enviar un màxim de 3 xiuxiueigs per segon i un màxim de 100 xiuxiueigs per minut. + A causa de les restriccions de Twitch, aquesta comanda només la pot utilitzar l\'emissor. Utilitzeu el lloc web de Twitch en el seu lloc. + %1$s ja és moderador d\'aquest canal. + %1$s és actualment un VIP, feu /unvip i torneu a provar aquesta comanda. + %1$s no és moderador d\'aquest canal. + %1$s no està prohibit en aquest canal. + %1$s ja està prohibit en aquest canal. + No podeu %1$s %2$s. + Hi ha hagut una operació de prohibició en conflicte en aquest usuari. Torneu-ho a provar. + El color ha de ser un dels colors compatibles de Twitch (%1$s) o un hex code (#000000) si teniu Turbo o Prime. + Heu d\'estar en directe per executar anuncis. + Heu d\'esperar que el vostre període de refredament expiri abans de poder executar un altre anunci. + La comanda ha d\'incloure una durada de pausa publicitària desitjada que sigui superior a zero. + No teniu cap raid activa. + Un canal no pot fer una raid a si mateix. + L\'emissor no es pot fer un shoutout a si mateix. + L\'emissor no està en directe o no té un o més espectadors. + La durada està fora del rang vàlid: %1$s. + El missatge ja s\'ha processat. + No s\'ha trobat el missatge objectiu. + El vostre missatge era massa llarg. + Se us està limitant la velocitat. Torneu-ho a provar d\'aquí a un moment. + L\'usuari objectiu + Visor de registres + Mostra els registres de l\'aplicació + Registres + Comparteix els registres + Mostra els registres + No hi ha fitxers de registre disponibles + Cerca als registres + + %1$d seleccionats + %1$d seleccionats + %1$d seleccionats + + Copia els registres seleccionats + Esborra la selecció + Error detectat + L\'aplicació s\'ha tancat inesperadament durant la darrera sessió. + Fil: %1$s + Copia + Informe per xat + S\'uneix a #flex3rs i prepara un resum de l\'error per enviar + Informe per correu + Envia un informe detallat de l\'error per correu electrònic + Enviar informe d\'error per correu electrònic + Les dades següents s\'inclouran a l\'informe: + Traça de la pila + Inclou el fitxer de registre actual + Informes d\'error + Veure els informes d\'error recents + No s\'han trobat informes d\'error + Informe d\'error + Comparteix l\'informe d\'error + Elimina + Eliminar aquest informe d\'error? + Esborra-ho tot + Eliminar tots els informes d\'error? + Desplaça cap avall + Veure historial + No hi ha missatges que coincideixin amb els filtres actuals diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 3b7cfaf40..22e1c4003 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -48,20 +48,75 @@ Odpojeno Nejste přihlášen Odpovědět - Máte nové zmínky + Send announcement Máte nové zmínky %1$s vás právě zmínil v #%2$s Byli jste zmíněni v #%1$s Přihlašování jako %1$s Přihlášení se nezdařilo Zkopírováno: %1$s + Nahrávání dokončeno: %1$s Chyba při nahrávání Chyba při nahrávání: %1$s + Nahrát + Zkopírováno do schránky + Kopírovat URL Opakovat Emotikony byly znovu načteny Načítání dat se nezdařilo: %1$s Načítání dat se nezdařilo kvůli několika chybám: \n%1$s + Odznaky DankChat + Globální odznaky + Globální FFZ emotikony + Globální BTTV emotikony + Globální 7TV emotikony + Odznaky kanálu + FFZ emotikony + BTTV emotikony + 7TV emotikony + Twitch emotikony + Cheermoty + Nedávné zprávy + %1$s (%2$s) + + První zpráva + Zvýrazněná zpráva + Obří emote + Animovaná zpráva + Uplatněno %1$s + %1$d sekundu + %1$d sekundy + %1$d sekund + %1$d sekund + + + %1$d minutu + %1$d minuty + %1$d minut + %1$d minut + + + %1$d hodinu + %1$d hodiny + %1$d hodin + %1$d hodin + + + %1$d den + %1$d dny + %1$d dnů + %1$d dnů + + + %1$d týden + %1$d týdny + %1$d týdnů + %1$d týdnů + + %1$s %2$s + %1$s %2$s %3$s Vložit Název kanálu + Kanál je již přidán Nedávné Předplatitelské Kanál @@ -158,6 +213,8 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Přidat příkaz Odebrat příkaz Spouštěč + Tento spouštěč je vyhrazen vestavěným příkazem + Tento spouštěč je již používán jiným příkazem Příkaz Vlastní příkazy Nahlásit @@ -196,14 +253,54 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Upozornění Chat Obecné + Návrhy + Zprávy + Uživatelé + Emotikony a odznaky O aplikaci Vzhled DankChat %1$s je vytvořen uživatelem @flex3rs a přispěvateli Zobrazit vstup Zobrazí vstupní pole pro odesílání zpráv Stejný jako režim systému - Pravý tmavý režim - Vynutí černou barvu pozadí chatu + AMOLED tmavý režim + Čistě černé pozadí pro OLED displeje + Barva zvýraznění + Podle systémové tapety + Modrá + Šedozelená + Zelená + Limetková + Žlutá + Oranžová + Červená + Růžová + Fialová + Indigo + Hnědá + Šedá + Styl barev + Výchozí systémový + Použít výchozí systémovou paletu barev + Tonal Spot + Klidné a tlumené barvy + Neutral + Téměř jednobarevný, jemný odstín + Vibrant + Výrazné a sytě nasycené barvy + Expressive + Hravé barvy s posunutými odstíny + Rainbow + Široké spektrum odstínů + Fruit Salad + Hravá, vícebarevná paleta + Monochrome + Pouze černá, bílá a šedá + Fidelity + Věrný barvě zvýraznění + Content + Barva zvýraznění s analogickou terciární + Další styly Zobrazení Nástroje Zobrazit smazané zprávy @@ -215,8 +312,19 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Malé Velké Velmi velké - Návrhy uživatelů a emotikon - Zobrazí návrhy emotikonů a uživatelských jmen při psaní + Návrhy + Zvolte, které návrhy zobrazovat při psaní + Emotikony + Uživatelé + Příkazy Twitche + Příkazy Supibota + Aktivovat pomocí : + Aktivovat pomocí @ + Aktivovat pomocí / + Aktivovat pomocí $ + Režim návrhů + Navrhovat shody při psaní + Navrhovat pouze po spouštěcím znaku Při zapnutí načíst historii zpráv Načíst historii zpráv po opětovném připojení Pokusí se o načtení zmeškaných zpráv, které nebyly načteny při výpadcích spojení @@ -226,7 +334,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Data kanálu Nastavení vývojáře Režim ladění - Poskytuje informace o všech chybách, které byly zachyceny + Zobrazit akci ladící analytiky ve vstupním panelu a sbírat hlášení o pádech lokálně Formát časových razítek Povolit TTS Čte zprávy aktivního kanálu @@ -240,6 +348,9 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Ignorovat odkazy Ignoruje emotikony při používání TTS Ignorovat emotikony + Hlasitost + Ztlumení zvuku + Snížit hlasitost ostatních aplikací při přehrávání TTS TTS Kostkované řádky Oddělit každý řádek odlišným jasem pozadí @@ -252,12 +363,19 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Chování po přidržení jména uživatele Normální kliknutí otevře pop-up menu, dlouhé přidržení označí Normální kliknutí označí uživatele, dlouhé přidržení otevře pop-up menu + Obarvit přezdívky + Přiřadit náhodnou barvu uživatelům bez nastavené barvy Vnutit anglický jazyk Vnutit jazyk TTS do angličtiny místo systémového jazyka Viditelné emotikony třetích stran Uživatelské zásady a Podmínky služby Twitch: Zobrazit rychlé přepínače Zobrazí šipku s nastavením pro zobrazení chatu na celou obrazovku, zobrazení streamu nebo úpravy režimů v chatu + Zobrazit počítadlo znaků + Zobrazuje počet kódových bodů ve vstupním poli + Zobrazit tlačítko vymazání vstupu + Zobrazit tlačítko odeslání + Vstup Nahrávač médií Konfigurace nahrávače Nedávná nahrání @@ -286,6 +404,9 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Vlastní přihlášení Obejít zpracování Twitch příkazů Zakáže zpracování Twitch příkazů a místo toho je rovnou odešle do chatu + Protokol odesílání chatu + Použít Helix API k odesílání + Odesílat zprávy přes Twitch Helix API místo IRC Aktualizace emotikonů 7TV Chování aktualizací emotikonů na pozadí Aktualizace se po uplynutí %1$s zastaví.\nSnížení hodnoty může zvýšit výdrž baterie. @@ -330,6 +451,7 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Tvá přezdívka Předplatné a Události Oznámení v chatu + Série sledování První zprávy Zvýrazněné zprávy Zvýraznění zakoupené přes věrnostní body @@ -355,9 +477,22 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zkopírovat zprávu Zkopírovat celou zprávu Odpovědět na zprávu + Odpovědět na původní zprávu Zobrazit vlákno Zkopírovat ID zprávy Více… + Přejít na zprávu + Zpráva již není v historii chatu + Historie zpráv + Globální historie + Historie: %1$s + Hledat zprávy… + Filtrovat podle uživatelského jména + Zprávy obsahující odkazy + Zprávy obsahující emoty + Filtrovat podle názvu odznaku + Uživatel + Odznak Odpověď uživateli @%1$s Vlákno odpovědí nebylo nalezeno Zpráva nenalezena @@ -402,4 +537,405 @@ Koukněte na tento návod pro pomoc: https://wiki.chatterino.com/Image%20Uploade Zobrazit kategorii vysílání Zobrazit také kategorii vysílání Přepnout vstup + Přepnout panel aplikace + Chyba: %s + Odhlásit se? + Odebrat tento kanál? + Odebrat kanál \"%1$s\"? + Zablokovat kanál \"%1$s\"? + Zabanovat tohoto uživatele? + Smazat tuto zprávu? + Vymazat chat? + Přizpůsobitelné akce pro rychlý přístup k vyhledávání, streamům a dalším + Klepněte sem pro další akce a konfiguraci panelu akcí + Zde můžete přizpůsobit, které akce se zobrazí na panelu akcí + Přejeďte dolů na vstupu pro jeho rychlé skrytí + Klepněte sem pro obnovení vstupu + Další + Rozumím + Přeskočit prohlídku + Zde můžete přidat další kanály + + + Zpráva zadržena z důvodu: %1$s. Povolení ji zveřejní v chatu. + Povolit + Zamítnout + Schváleno + Zamítnuto + Vypršelo + Hej! Tvoje zpráva je kontrolována moderátory a zatím nebyla odeslána. + Moderátoři přijali tvoji zprávu. + Moderátoři zamítli tvoji zprávu. + %1$s (úroveň %2$d) + + odpovídá %1$d blokovanému výrazu %2$s + odpovídá %1$d blokovaným výrazům %2$s + odpovídá %1$d blokovaným výrazům %2$s + odpovídá %1$d blokovaným výrazům %2$s + + Nepodařilo se %1$s zprávu AutoMod - zpráva již byla zpracována. + Nepodařilo se %1$s zprávu AutoMod - je třeba se znovu přihlásit. + Nepodařilo se %1$s zprávu AutoMod - nemáte oprávnění k provedení této akce. + Nepodařilo se %1$s zprávu AutoMod - cílová zpráva nebyla nalezena. + Nepodařilo se %1$s zprávu AutoMod - došlo k neznámé chybě. + %1$s přidal/a %2$s jako blokovaný výraz na AutoMod. + %1$s přidal/a %2$s jako povolený výraz na AutoMod. + %1$s odebral/a %2$s jako blokovaný výraz z AutoMod. + %1$s odebral/a %2$s jako povolený výraz z AutoMod. + + + + Byl/a jste ztlumen/a na %1$s + Byl/a jste ztlumen/a na %1$s uživatelem %2$s + Byl/a jste ztlumen/a na %1$s uživatelem %2$s: %3$s + %1$s ztlumil/a %2$s na %3$s + %1$s ztlumil/a %2$s na %3$s: %4$s + %1$s byl/a ztlumen/a na %2$s + Byl/a jste zabanován/a + Byl/a jste zabanován/a uživatelem %1$s + Byl/a jste zabanován/a uživatelem %1$s: %2$s + %1$s zabanoval/a %2$s + %1$s zabanoval/a %2$s: %3$s + %1$s byl/a permanentně zabanován/a + %1$s zrušil/a ztlumení %2$s + %1$s odbanoval/a %2$s + %1$s jmenoval/a moderátorem %2$s + %1$s odebral/a moderátora %2$s + %1$s přidal/a %2$s jako VIP tohoto kanálu + %1$s odebral/a %2$s jako VIP tohoto kanálu + %1$s varoval/a %2$s + %1$s varoval/a %2$s: %3$s + %1$s zahájil/a raid na %2$s + %1$s zrušil/a raid na %2$s + %1$s smazal/a zprávu od %2$s + %1$s smazal/a zprávu od %2$s s textem: %3$s + Zpráva od %1$s byla smazána + Zpráva od %1$s byla smazána s textem: %2$s + %1$s vyčistil/a chat + Chat byl vyčištěn moderátorem + %1$s zapnul/a režim pouze emotikony + %1$s vypnul/a režim pouze emotikony + %1$s zapnul/a režim pouze pro sledující + %1$s zapnul/a režim pouze pro sledující (%2$s) + %1$s vypnul/a režim pouze pro sledující + %1$s zapnul/a režim unikátního chatu + %1$s vypnul/a režim unikátního chatu + %1$s zapnul/a pomalý režim + %1$s zapnul/a pomalý režim (%2$s) + %1$s vypnul/a pomalý režim + %1$s zapnul/a režim pouze pro odběratele + %1$s vypnul/a režim pouze pro odběratele + %1$s ztlumil/a %2$s na %3$s v %4$s + %1$s ztlumil/a %2$s na %3$s v %4$s: %5$s + %1$s zrušil/a ztlumení %2$s v %3$s + %1$s zabanoval/a %2$s v %3$s + %1$s zabanoval/a %2$s v %3$s: %4$s + %1$s odbanoval/a %2$s v %3$s + %1$s smazal/a zprávu od %2$s v %3$s + %1$s smazal/a zprávu od %2$s v %3$s s textem: %4$s + %1$s%2$s + + \u0020(%1$d krát) + \u0020(%1$d krát) + \u0020(%1$d krát) + \u0020(%1$d krát) + + + + Smazat + Odeslat šepot + Šeptání s @%1$s + Nový šepot + Odeslat šepot + Uživatelské jméno + Odeslat + + + Pouze emotikony + Pouze odběratelé + Pomalý režim + Pomalý režim (%1$s) + Unikátní chat (R9K) + Pouze sledující + Pouze sledující (%1$s) + Vlastní + Jakýkoliv + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Aktivovat režim štítu? + Toto použije předkonfigurovaná bezpečnostní nastavení kanálu, která mohou zahrnovat omezení chatu, nastavení AutoModu a požadavky na ověření. + Aktivovat Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Přidejte kanál a začněte chatovat + Žádné nedávné emotikony + + + Zobrazit stream + Skrýt stream + Pouze zvuk + Ukončit režim zvuku + Celá obrazovka + Ukončit celou obrazovku + Skrýt vstup + Zobrazit vstup + Navigace kanálů přejetím + Přepínejte kanály přejetím po chatu + Moderování kanálu + + + Hledat zprávy + Poslední zpráva + Přepnout stream + Moderování kanálu + Celá obrazovka + Skrýt vstup + Konfigurovat akce + Ladění + + Maximálně %1$d akce + Maximálně %1$d akce + Maximálně %1$d akcí + Maximálně %1$d akcí + + + + DankChat + Pojďme vše nastavit. + Přihlásit se přes Twitch + Přihlaste se pro odesílání zpráv, používání emotikonů, příjem šepotů a odemknutí všech funkcí. + Budete požádáni o udělení několika oprávnění pro Twitch najednou, abyste nemuseli znovu autorizovat při používání různých funkcí. DankChat provádí moderátorské akce a akce správy vysílání pouze tehdy, když o to sami požádáte. + Přihlásit se přes Twitch + Přihlášení úspěšné + Oznámení + DankChat vás může upozornit, když vás někdo zmíní v chatu, zatímco aplikace běží na pozadí. + Povolit oznámení + Otevřít nastavení oznámení + Bez oznámení se nedozvíte, když vás někdo zmíní v chatu, zatímco aplikace běží na pozadí. + Historie zpráv + DankChat načítá historické zprávy ze služby třetí strany při spuštění. Pro získání zpráv DankChat odesílá názvy otevřených kanálů této službě. Služba dočasně ukládá zprávy navštívených kanálů.\n\nToto můžete později změnit v nastavení nebo se dozvědět více na https://recent-messages.robotty.de/ + Povolit + Zakázat + Pokračovat + Začít + Přeskočit + + + Obecné + Ověření + Povolit Twitch EventSub + Používá EventSub pro různé události v reálném čase místo zastaralého PubSub + Povolit ladící výstup EventSub + Zobrazuje ladící výstup týkající se EventSub jako systémové zprávy + Zrušit token a restartovat + Zneplatní aktuální token a restartuje aplikaci + Nepřihlášen + Nepodařilo se zjistit ID kanálu pro %1$s + Zpráva nebyla odeslána + Zpráva zahozena: %1$s (%2$s) + Chybí oprávnění user:write:chat, prosím přihlaste se znovu + Nemáte oprávnění posílat zprávy v tomto kanálu + Zpráva je příliš velká + Příliš mnoho požadavků, zkuste to za chvíli znovu + Odeslání se nezdařilo: %1$s + + + Pro použití příkazu %1$s musíte být přihlášeni + Nebyl nalezen uživatel s tímto jménem. + Došlo k neznámé chybě. + Nemáte oprávnění k provedení této akce. + Chybí požadované oprávnění. Přihlaste se znovu a zkuste to znovu. + Chybí přihlašovací údaje. Přihlaste se znovu a zkuste to znovu. + Použití: /block <uživatel> + Úspěšně jste zablokovali uživatele %1$s + Uživatele %1$s se nepodařilo zablokovat, uživatel s tímto jménem nebyl nalezen! + Uživatele %1$s se nepodařilo zablokovat, došlo k neznámé chybě! + Použití: /unblock <uživatel> + Úspěšně jste odblokovali uživatele %1$s + Uživatele %1$s se nepodařilo odblokovat, uživatel s tímto jménem nebyl nalezen! + Uživatele %1$s se nepodařilo odblokovat, došlo k neznámé chybě! + Kanál není živě. + Doba vysílání: %1$s + Příkazy dostupné v této místnosti: %1$s + Použití: %1$s <uživatelské jméno> <zpráva>. + Šepot odeslán. + Nepodařilo se odeslat šepot - %1$s + Použití: %1$s <zpráva> - Upozorněte na svou zprávu zvýrazněním. + Nepodařilo se odeslat oznámení - %1$s + Tento kanál nemá žádné moderátory. + Moderátoři tohoto kanálu jsou %1$s. + Nepodařilo se zobrazit moderátory - %1$s + Použití: %1$s <uživatelské jméno> - Udělí uživateli status moderátora. + Přidali jste %1$s jako moderátora tohoto kanálu. + Nepodařilo se přidat moderátora kanálu - %1$s + Použití: %1$s <uživatelské jméno> - Odebere uživateli status moderátora. + Odebrali jste %1$s status moderátora tohoto kanálu. + Nepodařilo se odebrat moderátora kanálu - %1$s + Tento kanál nemá žádné VIP. + VIP tohoto kanálu jsou %1$s. + Nepodařilo se zobrazit VIP - %1$s + Použití: %1$s <uživatelské jméno> - Udělí uživateli VIP status. + Přidali jste %1$s jako VIP tohoto kanálu. + Nepodařilo se přidat VIP - %1$s + Použití: %1$s <uživatelské jméno> - Odebere uživateli VIP status. + Odebrali jste %1$s VIP status tohoto kanálu. + Nepodařilo se odebrat VIP - %1$s + Použití: %1$s <uživatelské jméno> [důvod] - Trvale zakáže uživateli psát do chatu. Důvod je volitelný a bude zobrazen cílovému uživateli a dalším moderátorům. Pro zrušení banu použijte /unban. + Nepodařilo se zabanovat uživatele - Nemůžete zabanovat sami sebe. + Nepodařilo se zabanovat uživatele - Nemůžete zabanovat vysílatele. + Nepodařilo se zabanovat uživatele - %1$s + Použití: %1$s <uživatelské jméno> - Zruší ban uživatele. + Nepodařilo se odbanovat uživatele - %1$s + Použití: %1$s <uživatelské jméno> [doba trvání][jednotka času] [důvod] - Dočasně zakáže uživateli psát do chatu. Doba trvání (volitelná, výchozí: 10 minut) musí být kladné celé číslo; jednotka času (volitelná, výchozí: s) musí být jedna z s, m, h, d, w; maximální doba je 2 týdny. Důvod je volitelný a bude zobrazen cílovému uživateli a dalším moderátorům. + Nepodařilo se zabanovat uživatele - Nemůžete dát timeout sami sobě. + Nepodařilo se zabanovat uživatele - Nemůžete dát timeout vysílateli. + Nepodařilo se dát timeout uživateli - %1$s + Nepodařilo se smazat zprávy chatu - %1$s + Použití: /delete <msg-id> - Smaže zadanou zprávu. + Neplatný msg-id: \"%1$s\". + Nepodařilo se smazat zprávy chatu - %1$s + Použití: /color <barva> - Barva musí být jedna z podporovaných barev Twitche (%1$s) nebo hex kód (#000000), pokud máte Turbo nebo Prime. + Vaše barva byla změněna na %1$s + Nepodařilo se změnit barvu na %1$s - %2$s + Úspěšně přidána značka streamu v %1$s%2$s. + Nepodařilo se vytvořit značku streamu - %1$s + Použití: /commercial <délka> - Spustí reklamu se zadanou dobou trvání pro aktuální kanál. Platné délky jsou 30, 60, 90, 120, 150 a 180 sekund. + + Spouštění %1$d sekundové reklamní přestávky. Mějte na paměti, že stále vysíláte a ne všichni diváci uvidí reklamu. Další reklamu můžete spustit za %2$d sekund. + Spouštění %1$d sekundové reklamní přestávky. Mějte na paměti, že stále vysíláte a ne všichni diváci uvidí reklamu. Další reklamu můžete spustit za %2$d sekund. + Spouštění %1$d sekundové reklamní přestávky. Mějte na paměti, že stále vysíláte a ne všichni diváci uvidí reklamu. Další reklamu můžete spustit za %2$d sekund. + Spouštění %1$d sekundové reklamní přestávky. Mějte na paměti, že stále vysíláte a ne všichni diváci uvidí reklamu. Další reklamu můžete spustit za %2$d sekund. + + Nepodařilo se spustit reklamu - %1$s + Použití: /raid <uživatelské jméno> - Provede raid na uživatele. Pouze vysílatel může zahájit raid. + Neplatné uživatelské jméno: %1$s + Zahájili jste raid na %1$s. + Nepodařilo se zahájit raid - %1$s + Zrušili jste raid. + Nepodařilo se zrušit raid - %1$s + Použití: %1$s [doba trvání] - Aktivuje režim pouze pro sledující (jen sledující mohou chatovat). Doba trvání (volitelná, výchozí: 0 minut) musí být kladné číslo s jednotkou času (m, h, d, w); maximální doba je 3 měsíce. + Tato místnost je již v režimu pouze pro sledující po dobu %1$s. + Nepodařilo se aktualizovat nastavení chatu - %1$s + Tato místnost není v režimu pouze pro sledující. + Tato místnost je již v režimu pouze emotikony. + Tato místnost není v režimu pouze emotikony. + Tato místnost je již v režimu pouze pro odběratele. + Tato místnost není v režimu pouze pro odběratele. + Tato místnost je již v režimu unikátního chatu. + Tato místnost není v režimu unikátního chatu. + Použití: %1$s [doba trvání] - Aktivuje pomalý režim (omezí četnost odesílání zpráv). Doba trvání (volitelná, výchozí: 30) musí být kladný počet sekund; maximálně 120. + Tato místnost je již v %1$d sekundovém pomalém režimu. + Tato místnost není v pomalém režimu. + Použití: %1$s <uživatelské jméno> - Odešle shoutout zadanému uživateli Twitche. + Shoutout odeslán uživateli %1$s + Nepodařilo se odeslat shoutout - %1$s + Režim štítu byl aktivován. + Režim štítu byl deaktivován. + Nepodařilo se aktualizovat režim štítu - %1$s + Nemůžete šeptat sami sobě. + Kvůli omezením Twitche je nyní pro odesílání šepotů vyžadováno ověřené telefonní číslo. Telefonní číslo můžete přidat v nastavení Twitche. https://www.twitch.tv/settings/security + Příjemce nepřijímá šepoty od cizích lidí nebo přímo od vás. + Twitch omezuje vaši rychlost. Zkuste to znovu za několik sekund. + Denně můžete šeptat maximálně 40 unikátním příjemcům. V rámci denního limitu můžete odeslat maximálně 3 šepoty za sekundu a 100 šepotů za minutu. + Kvůli omezením Twitche může tento příkaz použít pouze vysílatel. Použijte prosím webové stránky Twitche. + %1$s je již moderátorem tohoto kanálu. + %1$s je momentálně VIP, použijte /unvip a zkuste tento příkaz znovu. + %1$s není moderátorem tohoto kanálu. + %1$s není zabanován v tomto kanálu. + %1$s je již zabanován v tomto kanálu. + Nemůžete %1$s %2$s. + Došlo ke konfliktu operace banu u tohoto uživatele. Zkuste to prosím znovu. + Barva musí být jedna z podporovaných barev Twitche (%1$s) nebo hex kód (#000000), pokud máte Turbo nebo Prime. + Pro spuštění reklam musíte vysílat živě. + Musíte počkat na uplynutí ochranné lhůty, než můžete spustit další reklamu. + Příkaz musí obsahovat požadovanou délku reklamní přestávky větší než nula. + Nemáte aktivní raid. + Kanál nemůže provést raid sám na sebe. + Vysílatel nemůže dát shoutout sám sobě. + Vysílatel nevysílá živě nebo nemá jednoho či více diváků. + Doba trvání je mimo platný rozsah: %1$s. + Zpráva již byla zpracována. + Cílová zpráva nebyla nalezena. + Vaše zpráva byla příliš dlouhá. + Vaše rychlost je omezována. Zkuste to znovu za chvíli. + Cílový uživatel + Prohlížeč logů + Zobrazit logy aplikace + Logy + Sdílet logy + Zobrazit logy + Žádné soubory logů nejsou dostupné + Hledat v logách + + %1$d vybráno + %1$d vybráno + %1$d vybráno + %1$d vybráno + + Kopírovat vybrané logy + Zrušit výběr + Zjištěn pád + Aplikace spadla během poslední relace. + Vlákno: %1$s + Kopírovat + Hlášení přes chat + Připojí se k #flex3rs a připraví shrnutí pádu k odeslání + Hlášení e-mailem + Odeslat podrobné hlášení o pádu e-mailem + Odeslat hlášení o pádu e-mailem + Následující data budou zahrnuta v hlášení: + Stopa zásobníku + Zahrnout aktuální soubor logů + Hlášení o pádech + Zobrazit nedávná hlášení o pádech + Žádná hlášení o pádech nenalezena + Hlášení o pádu + Sdílet hlášení o pádu + Smazat + Smazat toto hlášení o pádu? + Vymazat vše + Smazat všechna hlášení o pádech? + Posunout dolů + Odznak + Admin + Vysílající + Zakladatel + Hlavní moderátor + Moderátor + Staff + Odběratel + Ověřený + VIP + Odznaky + Vytvářejte upozornění a zvýrazňujte zprávy uživatelů na základě odznáčků. + Vybrat barvu + Vybrat vlastní barvu zvýraznění + Výchozí + + Živě s %1$d divákem v %2$s po dobu %3$s + Živě s %1$d diváky v %2$s po dobu %3$s + Živě s %1$d diváků v %2$s po dobu %3$s + Živě s %1$d diváků v %2$s po dobu %3$s + + Zobrazit historii + Žádné zprávy neodpovídají aktuálním filtrům diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c554b23af..80ac00a33 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -48,20 +48,66 @@ Verbindung getrennt Nicht angemeldet Antworten - Du hast neue Erwähnungen + Send announcement Du hast neue Erwähnungen %1$s hat dich in #%2$s erwähnt Du wurdest in #%1$s erwähnt Anmelden als %1$s Anmelden fehlgeschlagen Kopiert: %1$s + Upload abgeschlossen: %1$s Fehler beim Hochladen Fehler beim Hochladen: %1$s + Hochladen + In Zwischenablage kopiert + URL kopieren Wiederholen Emotes neu geladen Datenladen fehlgeschlagen: %1$s Laden der Daten fehlgeschlagen mit mehreren Fehlern:\n%1$s + DankChat Badges + Globale Badges + Globale FFZ Emotes + Globale BTTV Emotes + Globale 7TV Emotes + Kanal-Badges + FFZ Emotes + BTTV Emotes + 7TV Emotes + Twitch Emotes + Cheermotes + Letzte Nachrichten + %1$s (%2$s) + + Erste Nachricht + Hervorgehobene Nachricht + Riesenemote + Animierte Nachricht + Eingelöst: %1$s + + %1$d Sekunde + %1$d Sekunden + + + %1$d Minute + %1$d Minuten + + + %1$d Stunde + %1$d Stunden + + + %1$d Tag + %1$d Tage + + + %1$d Woche + %1$d Wochen + + %1$s %2$s + %1$s %2$s %3$s Einfügen Kanalname + Kanal ist bereits hinzugefügt Zuletzt Abos Kanal @@ -158,6 +204,8 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
Befehl hinzufügen Befehl entfernen Auslöser + Dieser Auslöser ist durch einen integrierten Befehl reserviert + Dieser Auslöser wird bereits von einem anderen Befehl verwendet Befehl Benutzerdefinierte Befehle Melden @@ -198,12 +246,52 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
Allgemein Über Aussehen + Vorschläge + Nachrichten + Benutzer + Emotes & Abzeichen DankChat %1$s wurde von @flex3rs und weiteren Mitwirkenden entwickelt Eingabefeld anzeigen Eingabefeld zum Senden von Nachrichten anzeigen Systemstandard verwenden - Echtes dunkles Design - Macht den Chathintergrund schwarz + AMOLED-Dunkelmodus + Reines Schwarz als Hintergrund für OLED-Bildschirme + Akzentfarbe + Systemhintergrund folgen + Blau + Blaugrün + Grün + Limette + Gelb + Orange + Rot + Rosa + Lila + Indigo + Braun + Grau + Farbstil + Systemstandard + Standard-Farbpalette des Systems verwenden + Tonal Spot + Ruhige und gedämpfte Farben + Neutral + Fast einfarbig, dezenter Farbton + Vibrant + Kräftige und satte Farben + Expressive + Verspielte Farben mit verschobenen Farbtönen + Rainbow + Breites Spektrum an Farbtönen + Fruit Salad + Verspielte, bunte Farbpalette + Monochrome + Nur Schwarz, Weiß und Grau + Fidelity + Bleibt der Akzentfarbe treu + Content + Akzentfarbe mit analogem Tertiärton + Weitere Stile Bildschirm Komponenten Entfernte Nachrichten anzeigen @@ -215,8 +303,19 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
Klein Groß Sehr Groß - Emote- und Nutzervorschläge - Zeige Vorschläge für Emotes und aktive Nutzer während der Eingabe + Vorschläge + Wähle, welche Vorschläge beim Tippen angezeigt werden + Emotes + Benutzer + Twitch-Befehle + Supibot-Befehle + Auslöser mit : + Auslöser mit @ + Auslöser mit / + Auslöser mit $ + Vorschlagsmodus + Vorschläge beim Tippen anzeigen + Nur nach einem Auslösezeichen vorschlagen Chatverlauf laden Nachrichtenverlauf nach Verbindungsabbrüchen neu laden Versucht, verpasste Nachrichten zu laden, die bei Verbindungsabbrüchen verloren gingen @@ -226,7 +325,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
Kanalinformationen Entwickleroptionen Debug-Modus - Enthält Informationen über vorherige Fehlernachrichten + Debug-Analyse-Aktion in der Eingabeleiste anzeigen und Absturzberichte lokal sammeln Formatierung des Zeitstempels TTS aktivieren Liest Nachrichten des aktiven Kanals vor @@ -240,6 +339,9 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
URLs Ignorieren Ignoriert Emotes und Emojis in TTS Emotes ignorieren + Lautstärke + Audio-Ducking + Andere Audiolautstärke während TTS-Wiedergabe verringern TTS Zebramuster Nachrichten werden mit abwechselnder Hintergrundfarbe dargestellt @@ -252,12 +354,19 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/
Nutzernamen-Langklickverhalten Normaler Klick öffnet Nutzerpopup, langer Klick erwähnt Nutzer Normaler Klick erwähnt Nutzer, langer Klick öffnet Nutzerpopup + Spitznamen einfärben + Nutzern ohne festgelegte Farbe eine zufällige Farbe zuweisen Englische Sprachausgabe erzwingen TTS wird in Englisch ausgegeben und die Systemeinstellungen werden ignoriert Angezeigte Emotes von Drittanbietern Twitch Nutzungsbedingungen und Benutzerrichtlinien: Chips anzeigen Zeigt Chips zum Wechseln von Vollbild-, Stream- und Chatmodus an + Zeichenzähler anzeigen + Zeigt die Anzahl der Codepunkte im Eingabefeld an + Eingabe-Löschen-Schaltfläche anzeigen + Senden-Schaltfläche anzeigen + Eingabe Medienupload Upload konfigurieren Letzte Uploads @@ -286,6 +395,9 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Benutzerdefinierte Anmeldung Twitch-Befehlsbehandlung überspringen Deaktiviert das Abfangen von Twitch-Befehlen und sendet sie stattdessen an den Chat + Chat-Sendeprotokoll + Helix API zum Senden verwenden + Chatnachrichten über die Twitch Helix API statt IRC senden 7TV Live-Emote-Updates Live-Emote-Updates Hintergrundverhalten Updates stoppen nach %1$s.\nEin niedrigeres Limit kann die Akkulaufzeit verbessern. @@ -332,6 +444,7 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Dein Benutzername Abonnements und Events Ankündigungen + Zuschauer-Serien Erste Nachrichten Hervorgehobene Nachrichten Mit Kanalpunkten eingelöste Hervorhebungen @@ -358,9 +471,22 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Nachricht kopieren Vollständige Nachricht kopieren Antworten + Auf ursprüngliche Nachricht antworten Antwortverlauf anzeigen Nachrichten-ID kopieren Mehr… + Zur Nachricht springen + Nachricht nicht mehr im Chatverlauf + Nachrichtenverlauf + Globaler Verlauf + Verlauf: %1$s + Nachrichten suchen… + Nach Benutzername filtern + Nachrichten mit Links + Nachrichten mit Emotes + Nach Abzeichenname filtern + Benutzer + Abzeichen Antwort an @%1$s Antwortverlauf nicht gefunden Nachricht nicht gefunden @@ -417,4 +543,373 @@ Für Hilfe, siehe: https://wiki.chatterino.com/Image%20Uploader/ Wähle benutzerdefinierte Highlight Farbe Standard Farbe wählen + App-Leiste umschalten + Fehler: %s + Abmelden? + Diesen Kanal entfernen? + Kanal \"%1$s\" entfernen? + Kanal \"%1$s\" blockieren? + Diesen Nutzer bannen? + Diese Nachricht löschen? + Chat löschen? + Anpassbare Aktionen für schnellen Zugriff auf Suche, Streams und mehr + Tippe hier für weitere Aktionen und um deine Aktionsleiste zu konfigurieren + Hier kannst du anpassen, welche Aktionen in deiner Aktionsleiste erscheinen + Wische auf der Eingabe nach unten, um sie schnell auszublenden + Tippe hier, um die Eingabe zurückzuholen + Weiter + Verstanden + Tour überspringen + Hier kannst du weitere Kanäle hinzufügen + + + Nachricht zurückgehalten wegen: %1$s. Erlauben veröffentlicht sie im Chat. + Erlauben + Ablehnen + Genehmigt + Abgelehnt + Abgelaufen + Hey! Deine Nachricht wird von Mods überprüft und wurde noch nicht gesendet. + Mods haben deine Nachricht akzeptiert. + Mods haben deine Nachricht abgelehnt. + %1$s (Stufe %2$d) + + entspricht %1$d blockiertem Begriff %2$s + entspricht %1$d blockierten Begriffen %2$s + + AutoMod-Nachricht konnte nicht %1$s werden - Nachricht wurde bereits verarbeitet. + AutoMod-Nachricht konnte nicht %1$s werden - du musst dich erneut anmelden. + AutoMod-Nachricht konnte nicht %1$s werden - du hast keine Berechtigung für diese Aktion. + AutoMod-Nachricht konnte nicht %1$s werden - Zielnachricht nicht gefunden. + AutoMod-Nachricht konnte nicht %1$s werden - ein unbekannter Fehler ist aufgetreten. + %1$s hat %2$s als blockierten Begriff auf AutoMod hinzugefügt. + %1$s hat %2$s als erlaubten Begriff auf AutoMod hinzugefügt. + %1$s hat %2$s als blockierten Begriff von AutoMod entfernt. + %1$s hat %2$s als erlaubten Begriff von AutoMod entfernt. + + + Du wurdest für %1$s getimeouted + Du wurdest für %1$s von %2$s getimeouted + Du wurdest für %1$s von %2$s getimeouted: %3$s + %1$s hat %2$s für %3$s getimeouted + %1$s hat %2$s für %3$s getimeouted: %4$s + %1$s wurde für %2$s getimeouted + Du wurdest gebannt + Du wurdest von %1$s gebannt + Du wurdest von %1$s gebannt: %2$s + %1$s hat %2$s gebannt + %1$s hat %2$s gebannt: %3$s + %1$s wurde permanent gebannt + %1$s hat den Timeout von %2$s aufgehoben + %1$s hat %2$s entbannt + %1$s hat %2$s zum Moderator gemacht + %1$s hat %2$s als Moderator entfernt + %1$s hat %2$s als VIP dieses Kanals hinzugefügt + %1$s hat %2$s als VIP dieses Kanals entfernt + %1$s hat %2$s verwarnt + %1$s hat %2$s verwarnt: %3$s + %1$s hat einen Raid auf %2$s gestartet + %1$s hat den Raid auf %2$s abgebrochen + %1$s hat eine Nachricht von %2$s gelöscht + %1$s hat eine Nachricht von %2$s gelöscht mit dem Inhalt: %3$s + Eine Nachricht von %1$s wurde gelöscht + Eine Nachricht von %1$s wurde gelöscht mit dem Inhalt: %2$s + %1$s hat den Chat geleert + Der Chat wurde von einem Moderator geleert + %1$s hat den Emote-only-Modus aktiviert + %1$s hat den Emote-only-Modus deaktiviert + %1$s hat den Followers-only-Modus aktiviert + %1$s hat den Followers-only-Modus aktiviert (%2$s) + %1$s hat den Followers-only-Modus deaktiviert + %1$s hat den Unique-Chat-Modus aktiviert + %1$s hat den Unique-Chat-Modus deaktiviert + %1$s hat den Slow-Modus aktiviert + %1$s hat den Slow-Modus aktiviert (%2$s) + %1$s hat den Slow-Modus deaktiviert + %1$s hat den Subscribers-only-Modus aktiviert + %1$s hat den Subscribers-only-Modus deaktiviert + %1$s hat %2$s für %3$s in %4$s getimeouted + %1$s hat %2$s für %3$s in %4$s getimeouted: %5$s + %1$s hat den Timeout von %2$s in %3$s aufgehoben + %1$s hat %2$s in %3$s gebannt + %1$s hat %2$s in %3$s gebannt: %4$s + %1$s hat %2$s in %3$s entbannt + %1$s hat eine Nachricht von %2$s in %3$s gelöscht + %1$s hat eine Nachricht von %2$s in %3$s gelöscht mit dem Inhalt: %4$s + %1$s%2$s + + \u0020(%1$d Mal) + \u0020(%1$d Mal) + + + + Rücktaste + Flüsternachricht senden + Flüstern an @%1$s + Neue Flüsternachricht + Flüsternachricht senden an + Benutzername + Starten + + + Nur Emotes + Nur Abonnenten + Langsamer Modus + Langsamer Modus (%1$s) + Einzigartiger Chat (R9K) + Nur Follower + Nur Follower (%1$s) + Benutzerdefiniert + Alle + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Schildmodus aktivieren? + Dies wendet die vorkonfigurierten Sicherheitseinstellungen des Kanals an, darunter Chat-Einschränkungen, AutoMod-Überschreibungen und Chat-Verifizierungsanforderungen. + Aktivieren Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Füge einen Kanal hinzu, um zu chatten + Keine kürzlich verwendeten Emotes + + + Stream anzeigen + Stream ausblenden + Nur Audio + Audio-Modus beenden + Vollbild + Vollbild beenden + Eingabe ausblenden + Eingabe einblenden + Kanal-Wischnavigation + Kanäle durch Wischen im Chat wechseln + Kanalmoderation + + + Nachrichten durchsuchen + Letzte Nachricht + Stream umschalten + Kanalmoderation + Vollbild + Eingabe ausblenden + Aktionen konfigurieren + Debug + + Maximal %1$d Aktion + Maximal %1$d Aktionen + + + + DankChat + Lass uns loslegen. + Mit Twitch anmelden + Melde dich an, um Nachrichten zu senden, deine Emotes zu nutzen, Flüsternachrichten zu empfangen und alle Funktionen freizuschalten. + Du wirst gebeten, mehrere Twitch-Berechtigungen auf einmal zu erteilen, damit du bei der Nutzung verschiedener Funktionen nicht erneut autorisieren musst. DankChat führt Moderations- und Stream-Aktionen nur aus, wenn du es verlangst. + Mit Twitch anmelden + Anmeldung erfolgreich + Benachrichtigungen + DankChat kann dich benachrichtigen, wenn dich jemand im Chat erwähnt, während die App im Hintergrund ist. + Benachrichtigungen erlauben + Benachrichtigungseinstellungen öffnen + Ohne Benachrichtigungen erfährst du nicht, wenn dich jemand im Chat erwähnt, während die App im Hintergrund ist. + Nachrichtenverlauf + DankChat lädt beim Start historische Nachrichten von einem Drittanbieter-Dienst. Um die Nachrichten abzurufen, sendet DankChat die Namen der geöffneten Kanäle an diesen Dienst. Der Dienst speichert Nachrichten für besuchte Kanäle vorübergehend.\n\nDu kannst dies später in den Einstellungen ändern oder mehr erfahren unter https://recent-messages.robotty.de/ + Aktivieren + Deaktivieren + Weiter + Loslegen + Überspringen + + + Allgemein + Authentifizierung + Twitch EventSub aktivieren + Verwendet EventSub für verschiedene Echtzeit-Ereignisse anstelle des veralteten PubSub + EventSub-Debug-Ausgabe aktivieren + Gibt Debug-Informationen zu EventSub als Systemnachrichten aus + Token widerrufen und neu starten + Macht den aktuellen Token ungültig und startet die App neu + Nicht angemeldet + Kanal-ID für %1$s konnte nicht aufgelöst werden + Nachricht wurde nicht gesendet + Nachricht verworfen: %1$s (%2$s) + Fehlende Berechtigung user:write:chat, bitte erneut anmelden + Keine Berechtigung, Nachrichten in diesem Kanal zu senden + Nachricht ist zu groß + Ratenbegrenzung erreicht, versuche es gleich nochmal + Senden fehlgeschlagen: %1$s + + + Du musst angemeldet sein, um den Befehl %1$s zu verwenden + Kein Benutzer mit diesem Benutzernamen gefunden. + Ein unbekannter Fehler ist aufgetreten. + Du hast keine Berechtigung, diese Aktion auszuführen. + Fehlende erforderliche Berechtigung. Melde dich erneut an und versuche es nochmal. + Fehlende Anmeldedaten. Melde dich erneut an und versuche es nochmal. + Verwendung: /block <Benutzer> + Du hast den Benutzer %1$s erfolgreich blockiert + Benutzer %1$s konnte nicht blockiert werden, kein Benutzer mit diesem Namen gefunden! + Benutzer %1$s konnte nicht blockiert werden, ein unbekannter Fehler ist aufgetreten! + Verwendung: /unblock <Benutzer> + Du hast den Benutzer %1$s erfolgreich entblockt + Benutzer %1$s konnte nicht entblockt werden, kein Benutzer mit diesem Namen gefunden! + Benutzer %1$s konnte nicht entblockt werden, ein unbekannter Fehler ist aufgetreten! + Kanal ist nicht live. + Sendezeit: %1$s + Verfügbare Befehle in diesem Raum: %1$s + Verwendung: %1$s <Benutzername> <Nachricht>. + Flüsternachricht gesendet. + Flüsternachricht konnte nicht gesendet werden - %1$s + Verwendung: %1$s <Nachricht> - Hebe deine Nachricht mit einer Hervorhebung hervor. + Ankündigung konnte nicht gesendet werden - %1$s + Dieser Kanal hat keine Moderatoren. + Die Moderatoren dieses Kanals sind %1$s. + Moderatoren konnten nicht aufgelistet werden - %1$s + Verwendung: %1$s <Benutzername> - Verleihe einem Benutzer den Moderatorenstatus. + Du hast %1$s als Moderator dieses Kanals hinzugefügt. + Moderator konnte nicht hinzugefügt werden - %1$s + Verwendung: %1$s <Benutzername> - Entziehe einem Benutzer den Moderatorenstatus. + Du hast %1$s als Moderator dieses Kanals entfernt. + Moderator konnte nicht entfernt werden - %1$s + Dieser Kanal hat keine VIPs. + Die VIPs dieses Kanals sind %1$s. + VIPs konnten nicht aufgelistet werden - %1$s + Verwendung: %1$s <Benutzername> - Verleihe einem Benutzer den VIP-Status. + Du hast %1$s als VIP dieses Kanals hinzugefügt. + VIP konnte nicht hinzugefügt werden - %1$s + Verwendung: %1$s <Benutzername> - Entziehe einem Benutzer den VIP-Status. + Du hast %1$s als VIP dieses Kanals entfernt. + VIP konnte nicht entfernt werden - %1$s + Verwendung: %1$s <Benutzername> [Grund] - Sperre einen Benutzer dauerhaft vom Chat. Der Grund ist optional und wird dem betroffenen Benutzer und anderen Moderatoren angezeigt. Verwende /unban, um eine Sperre aufzuheben. + Benutzer konnte nicht gesperrt werden - Du kannst dich nicht selbst sperren. + Benutzer konnte nicht gesperrt werden - Du kannst den Broadcaster nicht sperren. + Benutzer konnte nicht gesperrt werden - %1$s + Verwendung: %1$s <Benutzername> - Hebt die Sperre eines Benutzers auf. + Sperre konnte nicht aufgehoben werden - %1$s + Verwendung: %1$s <Benutzername> [Dauer][Zeiteinheit] [Grund] - Sperre einen Benutzer vorübergehend vom Chat. Die Dauer (optional, Standard: 10 Minuten) muss eine positive Ganzzahl sein; die Zeiteinheit (optional, Standard: s) muss s, m, h, d oder w sein; maximale Dauer ist 2 Wochen. Der Grund ist optional und wird dem betroffenen Benutzer und anderen Moderatoren angezeigt. + Benutzer konnte nicht gesperrt werden - Du kannst dich nicht selbst mit einem Timeout belegen. + Benutzer konnte nicht gesperrt werden - Du kannst den Broadcaster nicht mit einem Timeout belegen. + Timeout konnte nicht verhängt werden - %1$s + Chatnachrichten konnten nicht gelöscht werden - %1$s + Verwendung: /delete <msg-id> - Löscht die angegebene Nachricht. + Ungültige msg-id: \"%1$s\". + Chatnachrichten konnten nicht gelöscht werden - %1$s + Verwendung: /color <Farbe> - Die Farbe muss eine der von Twitch unterstützten Farben sein (%1$s) oder ein hex code (#000000), wenn du Turbo oder Prime hast. + Deine Farbe wurde zu %1$s geändert + Farbe konnte nicht zu %1$s geändert werden - %2$s + Stream-Markierung bei %1$s%2$s erfolgreich gesetzt. + Stream-Markierung konnte nicht erstellt werden - %1$s + Verwendung: /commercial <Länge> - Startet eine Werbung mit der angegebenen Dauer für den aktuellen Kanal. Gültige Längen sind 30, 60, 90, 120, 150 und 180 Sekunden. + + Starte %1$d Sekunden lange Werbepause. Bedenke, dass du weiterhin live bist und nicht alle Zuschauer eine Werbung erhalten. Du kannst in %2$d Sekunden eine weitere Werbung starten. + Starte %1$d Sekunden lange Werbepause. Bedenke, dass du weiterhin live bist und nicht alle Zuschauer eine Werbung erhalten. Du kannst in %2$d Sekunden eine weitere Werbung starten. + + Werbung konnte nicht gestartet werden - %1$s + Verwendung: /raid <Benutzername> - Raide einen Benutzer. Nur der Broadcaster kann einen Raid starten. + Ungültiger Benutzername: %1$s + Du hast einen Raid auf %1$s gestartet. + Raid konnte nicht gestartet werden - %1$s + Du hast den Raid abgebrochen. + Raid konnte nicht abgebrochen werden - %1$s + Verwendung: %1$s [Dauer] - Aktiviert den Nur-Follower-Modus (nur Follower dürfen chatten). Die Dauer (optional, Standard: 0 Minuten) muss eine positive Zahl gefolgt von einer Zeiteinheit sein (m, h, d, w); maximale Dauer ist 3 Monate. + Dieser Raum ist bereits im %1$s Nur-Follower-Modus. + Chat-Einstellungen konnten nicht aktualisiert werden - %1$s + Dieser Raum ist nicht im Nur-Follower-Modus. + Dieser Raum ist bereits im Nur-Emote-Modus. + Dieser Raum ist nicht im Nur-Emote-Modus. + Dieser Raum ist bereits im Nur-Abonnenten-Modus. + Dieser Raum ist nicht im Nur-Abonnenten-Modus. + Dieser Raum ist bereits im Unique-Chat-Modus. + Dieser Raum ist nicht im Unique-Chat-Modus. + Verwendung: %1$s [Dauer] - Aktiviert den Langsam-Modus (begrenzt, wie oft Benutzer Nachrichten senden dürfen). Die Dauer (optional, Standard: 30) muss eine positive Anzahl von Sekunden sein; maximal 120. + Dieser Raum ist bereits im %1$d-Sekunden-Langsam-Modus. + Dieser Raum ist nicht im Langsam-Modus. + Verwendung: %1$s <Benutzername> - Sendet einen Shoutout an den angegebenen Twitch-Benutzer. + Shoutout an %1$s gesendet + Shoutout konnte nicht gesendet werden - %1$s + Schildmodus wurde aktiviert. + Schildmodus wurde deaktiviert. + Schildmodus konnte nicht aktualisiert werden - %1$s + Du kannst dir nicht selbst flüstern. + Aufgrund von Twitch-Einschränkungen benötigst du jetzt eine verifizierte Telefonnummer, um Flüsternachrichten zu senden. Du kannst eine Telefonnummer in den Twitch-Einstellungen hinzufügen. https://www.twitch.tv/settings/security + Der Empfänger erlaubt keine Flüsternachrichten von Fremden oder von dir direkt. + Du wirst von Twitch ratenbegrenzt. Versuche es in ein paar Sekunden erneut. + Du darfst pro Tag maximal 40 verschiedene Empfänger anflüstern. Innerhalb dieses Tageslimits darfst du maximal 3 Flüsternachrichten pro Sekunde und maximal 100 Flüsternachrichten pro Minute senden. + Aufgrund von Twitch-Einschränkungen kann dieser Befehl nur vom Broadcaster verwendet werden. Bitte verwende stattdessen die Twitch-Website. + %1$s ist bereits Moderator dieses Kanals. + %1$s ist derzeit ein VIP, verwende /unvip und versuche diesen Befehl erneut. + %1$s ist kein Moderator dieses Kanals. + %1$s ist nicht in diesem Kanal gesperrt. + %1$s ist bereits in diesem Kanal gesperrt. + Du kannst %1$s %2$s nicht ausführen. + Es gab eine widersprüchliche Sperr-Aktion für diesen Benutzer. Bitte versuche es erneut. + Die Farbe muss eine der von Twitch unterstützten Farben sein (%1$s) oder ein hex code (#000000), wenn du Turbo oder Prime hast. + Du musst live streamen, um Werbung zu schalten. + Du musst warten, bis deine Abklingzeit abgelaufen ist, bevor du eine weitere Werbung schalten kannst. + Der Befehl muss eine gewünschte Werbelänge enthalten, die größer als null ist. + Du hast keinen aktiven Raid. + Ein Kanal kann sich nicht selbst raiden. + Der Broadcaster kann sich nicht selbst einen Shoutout geben. + Der Broadcaster streamt nicht live oder hat keinen oder keine Zuschauer. + Die Dauer liegt außerhalb des gültigen Bereichs: %1$s. + Die Nachricht wurde bereits verarbeitet. + Die Zielnachricht wurde nicht gefunden. + Deine Nachricht war zu lang. + Du wirst ratenbegrenzt. Versuche es gleich nochmal. + Der Zielbenutzer + Log-Betrachter + Anwendungsprotokolle anzeigen + Protokolle + Protokolle teilen + Protokolle anzeigen + Keine Protokolldateien verfügbar + Protokolle durchsuchen + + %1$d ausgewählt + %1$d ausgewählt + + Ausgewählte Protokolle kopieren + Auswahl aufheben + Absturz erkannt + Die App ist während deiner letzten Sitzung abgestürzt. + Thread: %1$s + Kopieren + Chat-Bericht + Tritt #flex3rs bei und bereitet eine Absturzzusammenfassung zum Senden vor + E-Mail-Bericht + Detaillierten Absturzbericht per E-Mail senden + Absturzbericht per E-Mail senden + Folgende Daten werden im Bericht enthalten sein: + Stack-Trace + Aktuelle Logdatei einschließen + Absturzberichte + Letzte Absturzberichte anzeigen + Keine Absturzberichte gefunden + Absturzbericht + Absturzbericht teilen + Löschen + Diesen Absturzbericht löschen? + Alle löschen + Alle Absturzberichte löschen? + Nach unten scrollen + Verlauf anzeigen + Keine Nachrichten entsprechen den aktuellen Filtern diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml index 12cd63f48..13865560a 100644 --- a/app/src/main/res/values-en-rAU/strings.xml +++ b/app/src/main/res/values-en-rAU/strings.xml @@ -51,13 +51,59 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s + Upload + Copied to clipboard + Copy URL Retry Emotes reloaded Data loading failed: %1$s + DankChat Badges + Global Badges + Global FFZ Emotes + Global BTTV Emotes + Global 7TV Emotes + Channel Badges + FFZ Emotes + BTTV Emotes + 7TV Emotes + Twitch Emotes + Cheermotes + Recent Messages + %1$s (%2$s) + + First Time Chat + Elevated Chat + Gigantified Emote + Animated Message + Redeemed %1$s + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + + %1$d week + %1$d weeks + + %1$s %2$s + %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -142,6 +188,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Add a command Remove the command Trigger + This trigger is reserved by a built-in command + This trigger is already used by another command Command Custom commands Report @@ -164,12 +212,52 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/General About Appearance + Suggestions + Messages + Users + Emotes & Badges DankChat %1$s made by @flex3rs and contributors Show input Displays the input field to send messages Follow system default - True dark theme - Forces chat background color to black + Amoled dark mode + Pure black backgrounds for OLED screens + Accent colour + Follow system wallpaper + Blue + Teal + Green + Lime + Yellow + Orange + Red + Pink + Purple + Indigo + Brown + Grey + Colour style + System default + Use the default system colour palette + Tonal Spot + Calm and subdued colours + Neutral + Nearly monochrome, subtle tint + Vibrant + Bold and saturated colours + Expressive + Playful colours with shifted hues + Rainbow + Broad spectrum of hues + Fruit Salad + Playful, multi-coloured palette + Monochrome + Black, white, and grey only + Fidelity + Stays true to the accent colour + Content + Accent colour with analogous tertiary + More styles Display Show timed out messages Animate gifs @@ -180,8 +268,19 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Small Large Very large - Emote and user suggestions - Shows suggestions for emotes and active users while typing + Suggestions + Choose which suggestions to show while typing + Emotes + Users + Twitch commands + Supibot commands + Trigger with : + Trigger with @ + Trigger with / + Trigger with $ + Suggestion mode + Suggest matches as you type + Only suggest after a trigger character Load message history on start Message history Open dashboard @@ -189,7 +288,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Channel data Developer options Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -211,12 +310,19 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/User long click behaviour Regular click opens popup, long click mentions Regular click mentions, long click opens popup + Colourise nicknames + Assign a random colour to users without a set colour Force language to English Force TTS voice language to English instead of system default Visible third party emotes Twitch terms of service & user policy: Show chip actions Displays chips for toggling fullscreen, streams and adjusting chat modes + Show character counter + Displays code point count in the input field + Show clear input button + Show send button + Input Media uploader Configure uploader Recent uploads @@ -228,4 +334,569 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Light theme Allow unlisted emotes Disables filtering of unapproved or unlisted emotes + Toggle App Bar + Error: %s + Log out? + Remove this channel? + Remove channel \"%1$s\"? + Block channel \"%1$s\"? + Ban this user? + Delete this message? + Clear chat? + Copy message + Copy full message + Reply to message + Reply to original message + View thread + Copy message id + More… + Jump to message + Message not found + Message no longer in chat history + Message history + Global History + History: %1$s + Search messages… + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge Customizable actions for quick access to search, streams, and more + Tap here for more actions and to configure your action bar + You can customize which actions appear in your action bar here + Swipe down on the input to quickly hide it + Tap here to bring the input back + Next + Got it + Skip tour + You can add more channels here + + + Held a message for reason: %1$s. Allow will post it in chat. + Allow + Deny + Approved + Denied + Expired + Hey! Your message is being checked by mods and has not been sent. + Mods have accepted your message. + Mods have denied your message. + %1$s (level %2$d) + + matches %1$d blocked term %2$s + matches %1$d blocked terms %2$s + + Failed to %1$s AutoMod message - message has already been processed. + Failed to %1$s AutoMod message - you need to re-authenticate. + Failed to %1$s AutoMod message - you don\'t have permission to perform that action. + Failed to %1$s AutoMod message - target message not found. + Failed to %1$s AutoMod message - an unknown error occurred. + %1$s added %2$s as a blocked term on AutoMod. + %1$s added %2$s as a permitted term on AutoMod. + %1$s removed %2$s as a blocked term on AutoMod. + %1$s removed %2$s as a permitted term on AutoMod. + + + + You were timed out for %1$s + You were timed out for %1$s by %2$s + You were timed out for %1$s by %2$s: %3$s + %1$s timed out %2$s for %3$s + %1$s timed out %2$s for %3$s: %4$s + %1$s has been timed out for %2$s + You were banned + You were banned by %1$s + You were banned by %1$s: %2$s + %1$s banned %2$s + %1$s banned %2$s: %3$s + %1$s has been permanently banned + %1$s untimedout %2$s + %1$s unbanned %2$s + %1$s modded %2$s + %1$s unmodded %2$s + %1$s has added %2$s as a VIP of this channel + %1$s has removed %2$s as a VIP of this channel + %1$s has warned %2$s + %1$s has warned %2$s: %3$s + %1$s initiated a raid to %2$s + %1$s cancelled the raid to %2$s + %1$s deleted message from %2$s + %1$s deleted message from %2$s saying: %3$s + A message from %1$s was deleted + A message from %1$s was deleted saying: %2$s + %1$s cleared the chat + Chat has been cleared by a moderator + %1$s turned on emote-only mode + %1$s turned off emote-only mode + %1$s turned on followers-only mode + %1$s turned on followers-only mode (%2$s) + %1$s turned off followers-only mode + %1$s turned on unique-chat mode + %1$s turned off unique-chat mode + %1$s turned on slow mode + %1$s turned on slow mode (%2$s) + %1$s turned off slow mode + %1$s turned on subscribers-only mode + %1$s turned off subscribers-only mode + %1$s timed out %2$s for %3$s in %4$s + %1$s timed out %2$s for %3$s in %4$s: %5$s + %1$s untimedout %2$s in %3$s + %1$s banned %2$s in %3$s + %1$s banned %2$s in %3$s: %4$s + %1$s unbanned %2$s in %3$s + %1$s deleted message from %2$s in %3$s + %1$s deleted message from %2$s in %3$s saying: %4$s + %1$s%2$s + + \u0020(%1$d time) + \u0020(%1$d times) + + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + Emote only + Subscriber only + Slow mode + Slow mode (%1$s) + Unique chat (R9K) + Follower only + Follower only (%1$s) + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Activate Shield Mode? + This will apply the channel\'s pre-configured safety settings, which may include chat restrictions, AutoMod overrides, and chat verification requirements. + Activate Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + Add a channel to start chatting + No recent emotes + Show stream + Hide stream + Audio only + Exit audio only + Fullscreen + Exit fullscreen + Hide input + Show input + Channel swipe navigation + Switch channels by swiping on the chat + Channel moderation + Search messages + Last message + Toggle stream + Channel moderation + Fullscreen + Hide input + Configure actions + Debug + + Maximum of %1$d action + Maximum of %1$d actions + + DankChat + Let\'s get you set up. + Login with Twitch + Log in to send messages, use your emotes, receive whispers, and unlock all features. + You will be asked to grant several Twitch permissions at once so you won\'t need to re-authorize when using different features. DankChat only performs moderation and stream actions when you ask it to. + Login with Twitch + Login successful + Notifications + DankChat can notify you when someone mentions you in chat while the app is in the background. + Allow Notifications + Open Notification Settings + Without notifications, you won\'t know when someone mentions you in chat while the app is in the background. + Message History + DankChat loads historical messages from a third-party service on startup. To get the messages, DankChat sends the names of the channels you have open to that service. The service temporarily stores messages for channels you (and others) visit to provide the service.\n\nYou can change this later in the settings or learn more at https://recent-messages.robotty.de/ + Enable + Disable + Continue + Get Started + Skip + Save + Message id copied + Close the emote menu + Emotes + Reply + Send announcement Data loading failed with multiple errors:\n%1$s + Reconnected + Failed to load FFZ emotes (Error %1$s) + Failed to load BTTV emotes (Error %1$s) + Failed to load 7TV emotes (Error %1$s) + %1$s switched the active 7TV Emote Set to \"%2$s\". + %1$s added 7TV Emote %2$s. + %1$s renamed 7TV Emote %2$s to %3$s. + %1$s removed 7TV Emote %2$s. + Reply Thread + Are you sure you want to remove channel \"%1$s\"? + Confirm channel block + Are you sure you want to block channel \"%1$s\"? + Host + Reset + OAuth token + Verify & Save + Only Client IDs that work with Twitch\'s Helix API are supported + Show required scopes + Required scopes + Missing scopes + Some scopes required by DankChat are missing in the token and some functionality might not work as expected. Do you want to continue using this token?\nMissing: %1$s + Continue + Missing scopes: %1$s + Error: %1$s + Token can\'t be empty + Token is invalid + Moderator + Lead Moderator + Predicted "%1$s" + Tier %1$s + Components + Load message history after a reconnect + Attempts to fetch missed messages that were not received during connection drops + Ignores URLs in TTS + Ignore URLs + Ignores emotes and emojis in TTS + Ignore emotes + Volume + Audio ducking + Lower other audio volume while TTS is speaking + Custom recent messages host + Fetch stream information + Periodically fetches stream information of open channels. Required to start embedded stream. + Disable input if not connected to chat + Enable repeated sending + Enables continuous message sending while send button is held + Login Expired! + Your login token has expired! Please login again. + Login Again + Failed to verify the login token, check your connection. + Prevent stream reloads + Enables experimental prevention of stream reloads after orientation changes or re-opening DankChat. + Show changelogs after update + What\'s new + Custom login + Bypass Twitch command handling + Disables intercepting of Twitch commands and sends them to chat instead + Chat send protocol + Use Helix API for sending + Send chat messages via Twitch Helix API instead of IRC + 7TV live emote updates + Live emote updates background behavior + Updates stop after %1$s.\nLowering this number may increase battery life. + Updates are always active.\nAdding a timeout may increase battery life. + Updates are never active in the background. + Never active + 1 minute + 5 minutes + 30 minutes + 1 hour + Always active + Livestreams + Enable picture-in-picture mode + Allows streams to continue playing while the app is in the background + Reset Media Uploader Settings + Are you sure you want to reset the media uploader settings to default? + Reset + Clear Recent Uploads + Are you sure you want to clear the upload history? Your uploaded files won\'t be deleted. + Clear + Pattern + Case-sensitive + Highlights + Enabled + Notification + Edit message highlights + Highlights and Ignores + Username + Block + Replacement + Ignores + Edit message ignores + Messages + Users + Blacklisted Users + Twitch + Badges + Undo + Item removed + Unblocked user %1$s + Failed to unblock user %1$s + Badge + Failed to block user %1$s + Your username + Subscriptions and Events + Announcements + Watch Streaks + First Messages + Elevated Messages + Highlights redeemed with Channel Points + Replies + Custom + Creates notifications and highlights messages based on certain patterns. + Creates notifications and highlights messages from certain users. + Disable notifications and highlights from certain users (e.g. bots). + Create notifications and highlights messages from users based on badges. + Ignore messages based on certain patterns. + Ignore messages from certain users. + Manage blocked Twitch users. + Create notifications for whispers + Login Outdated! + Your login is outdated and does not have access to some functionality. Please login again. + Custom User Display + Remove Custom User Display + Alias + Custom Color + Custom Alias + Pick custom user color + Add a custom name and color for users + Replying to + Replying to @%1$s + Reply thread not found + Use emote + Copy + Open emote link + Emote image + Twitch Emote + Channel FFZ Emote + Global FFZ Emote + Channel BTTV Emote + Shared BTTV Emote + Global BTTV Emote + Channel 7TV Emote + Global 7TV Emote + Alias of %1$s + Created by %1$s + (Zero Width) + Emote copied + DankChat has been updated! + What\'s new in v%1$s: + Confirm login cancellation + Are you sure you want to cancel the login process? + Cancel login + Zoom out + Zoom in + Back + Shared Chat + Open source licenses + Show stream category + Also display stream category + Toggle input + Broadcaster + Admin + Staff + Moderator + Lead Moderator + Verified + VIP + Founder + Subscriber + Pick custom highlight color + Default + Choose Color + + + General + Auth + Enable Twitch EventSub + Uses EventSub for various real-time events instead of deprecated PubSub + Enable EventSub debug output + Prints debug output related to EventSub as system messages + Revoke token and restart + Invalidates the current token and restarts the app + Not logged in + Could not resolve channel ID for %1$s + Message was not sent + Message dropped: %1$s (%2$s) + Missing user:write:chat scope, please re-login + Not authorized to send messages in this channel + Message is too large + Rate limited, try again in a moment + Send failed: %1$s + + + You must be logged in to use the %1$s command + No user matching that username. + An unknown error has occurred. + You don\'t have permission to perform that action. + Missing required scope. Re-login with your account and try again. + Missing login credentials. Re-login with your account and try again. + Usage: /block <user> + You successfully blocked user %1$s + User %1$s couldn\'t be blocked, no user with that name found! + User %1$s couldn\'t be blocked, an unknown error occurred! + Usage: /unblock <user> + You successfully unblocked user %1$s + User %1$s couldn\'t be unblocked, no user with that name found! + User %1$s couldn\'t be unblocked, an unknown error occurred! + Channel is not live. + Uptime: %1$s + Commands available to you in this room: %1$s + Usage: %1$s <username> <message>. + Whisper sent. + Failed to send whisper - %1$s + Usage: %1$s <message> - Call attention to your message with a highlight. + Failed to send announcement - %1$s + This channel does not have any moderators. + The moderators of this channel are %1$s. + Failed to list moderators - %1$s + Usage: %1$s <username> - Grant moderation status to a user. + You have added %1$s as a moderator of this channel. + Failed to add channel moderator - %1$s + Usage: %1$s <username> - Revoke moderation status from a user. + You have removed %1$s as a moderator of this channel. + Failed to remove channel moderator - %1$s + This channel does not have any VIPs. + The VIPs of this channel are %1$s. + Failed to list VIPs - %1$s + Usage: %1$s <username> - Grant VIP status to a user. + You have added %1$s as a VIP of this channel. + Failed to add VIP - %1$s + Usage: %1$s <username> - Revoke VIP status from a user. + You have removed %1$s as a VIP of this channel. + Failed to remove VIP - %1$s + Usage: %1$s <username> [reason] - Permanently prevent a user from chatting. Reason is optional and will be shown to the target user and other moderators. Use /unban to remove a ban. + Failed to ban user - You cannot ban yourself. + Failed to ban user - You cannot ban the broadcaster. + Failed to ban user - %1$s + Usage: %1$s <username> - Removes a ban on a user. + Failed to unban user - %1$s + Usage: %1$s <username> [duration][time unit] [reason] - Temporarily prevent a user from chatting. Duration (optional, default: 10 minutes) must be a positive integer; time unit (optional, default: s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Reason is optional and will be shown to the target user and other moderators. + Failed to ban user - You cannot timeout yourself. + Failed to ban user - You cannot timeout the broadcaster. + Failed to timeout user - %1$s + Failed to delete chat messages - %1$s + Usage: /delete <msg-id> - Deletes the specified message. + Invalid msg-id: \"%1$s\". + Failed to delete chat messages - %1$s + Usage: /color <color> - Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + Your color has been changed to %1$s + Failed to change color to %1$s - %2$s + Successfully added a stream marker at %1$s%2$s. + Failed to create stream marker - %1$s + Usage: /commercial <length> - Starts a commercial with the specified duration for the current channel. Valid length options are 30, 60, 90, 120, 150, and 180 seconds. + + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + + Failed to start commercial - %1$s + Usage: /raid <username> - Raid a user. Only the broadcaster can start a raid. + Invalid username: %1$s + You started to raid %1$s. + Failed to start a raid - %1$s + You cancelled the raid. + Failed to cancel the raid - %1$s + Usage: %1$s [duration] - Enables followers-only mode (only followers may chat). Duration (optional, default: 0 minutes) must be a positive number followed by a time unit (m, h, d, w); maximum duration is 3 months. + This room is already in %1$s followers-only mode. + Failed to update chat settings - %1$s + This room is not in followers-only mode. + This room is already in emote-only mode. + This room is not in emote-only mode. + This room is already in subscribers-only mode. + This room is not in subscribers-only mode. + This room is already in unique-chat mode. + This room is not in unique-chat mode. + Usage: %1$s [duration] - Enables slow mode (limit how often users may send messages). Duration (optional, default: 30) must be a positive number of seconds; maximum is 120. + This room is already in %1$d-second slow mode. + This room is not in slow mode. + Usage: %1$s <username> - Sends a shoutout to the specified Twitch user. + Sent shoutout to %1$s + Failed to send shoutout - %1$s + Shield mode was activated. + Shield mode was deactivated. + Failed to update shield mode - %1$s + You cannot whisper yourself. + Due to Twitch restrictions, you are now required to have a verified phone number to send whispers. You can add a phone number in Twitch settings. https://www.twitch.tv/settings/security + The recipient doesn\'t allow whispers from strangers or you directly. + You are being rate-limited by Twitch. Try again in a few seconds. + You may only whisper a maximum of 40 unique recipients per day. Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute. + Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead. + %1$s is already a moderator of this channel. + %1$s is currently a VIP, /unvip them and retry this command. + %1$s is not a moderator of this channel. + %1$s is not banned from this channel. + %1$s is already banned in this channel. + You cannot %1$s %2$s. + There was a conflicting ban operation on this user. Please try again. + Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + You must be streaming live to run commercials. + You must wait until your cool-down period expires before you can run another commercial. + Command must include a desired commercial break length that is greater than zero. + You don\'t have an active raid. + A channel cannot raid itself. + The broadcaster may not give themselves a Shoutout. + The broadcaster is not streaming live or does not have one or more viewers. + The duration is out of the valid range: %1$s. + The message has already been processed. + The target message was not found. + Your message was too long. + You are being rate-limited. Try again in a moment. + The target user + Log viewer + View application logs + Logs + Share logs + View logs + No log files available + Search logs + + %1$d selected + %1$d selected + + Copy selected logs + Clear selection + Crash detected + The app crashed during your last session. + Thread: %1$s + Copy + Chat report + Joins #flex3rs and prepares a crash summary to send + Email report + Send a detailed crash report via email + Send crash report via email + The following data will be included in the report: + Stack trace + Include current log file + Crash reports + View recent crash reports + No crash reports found + Crash Report + Share crash report + Delete + Delete this crash report? + Clear all + Delete all crash reports? + Scroll to bottom + + Live with %1$d viewer for %2$s + Live with %1$d viewers for %2$s + + + Live with %1$d viewer in %2$s for %3$s + Live with %1$d viewers in %2$s for %3$s + + View history + No messages match the current filters diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index 683ad99d0..a8b800f5f 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -51,13 +51,59 @@ Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s + Upload + Copied to clipboard + Copy URL Retry Emotes reloaded Data loading failed: %1$s + DankChat Badges + Global Badges + Global FFZ Emotes + Global BTTV Emotes + Global 7TV Emotes + Channel Badges + FFZ Emotes + BTTV Emotes + 7TV Emotes + Twitch Emotes + Cheermotes + Recent Messages + %1$s (%2$s) + + First Time Chat + Elevated Chat + Gigantified Emote + Animated Message + Redeemed %1$s + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + + %1$d week + %1$d weeks + + %1$s %2$s + %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -142,6 +188,8 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Add a command Remove the command Trigger + This trigger is reserved by a built-in command + This trigger is already used by another command Command Custom commands Report @@ -164,12 +212,52 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/General About Appearance + Suggestions + Messages + Users + Emotes & Badges DankChat %1$s made by @flex3rs and contributors Show input Displays the input field to send messages Follow system default - True dark theme - Forces chat background color to black + Amoled dark mode + Pure black backgrounds for OLED screens + Accent colour + Follow system wallpaper + Blue + Teal + Green + Lime + Yellow + Orange + Red + Pink + Purple + Indigo + Brown + Grey + Colour style + System default + Use the default system colour palette + Tonal Spot + Calm and subdued colours + Neutral + Nearly monochrome, subtle tint + Vibrant + Bold and saturated colours + Expressive + Playful colours with shifted hues + Rainbow + Broad spectrum of hues + Fruit Salad + Playful, multi-coloured palette + Monochrome + Black, white, and grey only + Fidelity + Stays true to the accent colour + Content + Accent colour with analogous tertiary + More styles Display Show timed out messages Animate gifs @@ -180,8 +268,19 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Small Large Very large - Emote and user suggestions - Shows suggestions for emotes and active users while typing + Suggestions + Choose which suggestions to show while typing + Emotes + Users + Twitch commands + Supibot commands + Trigger with : + Trigger with @ + Trigger with / + Trigger with $ + Suggestion mode + Suggest matches as you type + Only suggest after a trigger character Load message history on start Message history Open dashboard @@ -189,7 +288,7 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Channel data Developer options Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -211,12 +310,19 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/User long click behaviour Regular click opens popup, long click mentions Regular click mentions, long click opens popup + Colourise nicknames + Assign a random colour to users without a set colour Force language to English Force TTS voice language to English instead of system default Visible third party emotes Twitch terms of service & user policy: Show chip actions Displays chips for toggling fullscreen, streams and adjusting chat modes + Show character counter + Displays code point count in the input field + Show clear input button + Show send button + Input Media uploader Configure uploader Recent uploads @@ -229,4 +335,568 @@ Check this guide for help: https://wiki.chatterino.com/Image%20Uploader/Allow unlisted emotes Disables filtering of unapproved or unlisted emotes Custom Colour + Toggle App Bar + Error: %s + Log out? + Remove this channel? + Remove channel \"%1$s\"? + Block channel \"%1$s\"? + Ban this user? + Delete this message? + Clear chat? + Copy message + Copy full message + Reply to message + Reply to original message + View thread + Copy message id + More… + Jump to message + Message not found + Message no longer in chat history + Message history + Global History + History: %1$s + Search messages… + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge Customisable actions for quick access to search, streams, and more + Tap here for more actions and to configure your action bar + You can customise which actions appear in your action bar here + Swipe down on the input to quickly hide it + Tap here to bring the input back + Next + Got it + Skip tour + You can add more channels here + + + Held a message for reason: %1$s. Allow will post it in chat. + Allow + Deny + Approved + Denied + Expired + Hey! Your message is being checked by mods and has not been sent. + Mods have accepted your message. + Mods have denied your message. + %1$s (level %2$d) + + matches %1$d blocked term %2$s + matches %1$d blocked terms %2$s + + Failed to %1$s AutoMod message - message has already been processed. + Failed to %1$s AutoMod message - you need to re-authenticate. + Failed to %1$s AutoMod message - you don\'t have permission to perform that action. + Failed to %1$s AutoMod message - target message not found. + Failed to %1$s AutoMod message - an unknown error occurred. + %1$s added %2$s as a blocked term on AutoMod. + %1$s added %2$s as a permitted term on AutoMod. + %1$s removed %2$s as a blocked term on AutoMod. + %1$s removed %2$s as a permitted term on AutoMod. + + + + You were timed out for %1$s + You were timed out for %1$s by %2$s + You were timed out for %1$s by %2$s: %3$s + %1$s timed out %2$s for %3$s + %1$s timed out %2$s for %3$s: %4$s + %1$s has been timed out for %2$s + You were banned + You were banned by %1$s + You were banned by %1$s: %2$s + %1$s banned %2$s + %1$s banned %2$s: %3$s + %1$s has been permanently banned + %1$s untimedout %2$s + %1$s unbanned %2$s + %1$s modded %2$s + %1$s unmodded %2$s + %1$s has added %2$s as a VIP of this channel + %1$s has removed %2$s as a VIP of this channel + %1$s has warned %2$s + %1$s has warned %2$s: %3$s + %1$s initiated a raid to %2$s + %1$s cancelled the raid to %2$s + %1$s deleted message from %2$s + %1$s deleted message from %2$s saying: %3$s + A message from %1$s was deleted + A message from %1$s was deleted saying: %2$s + %1$s cleared the chat + Chat has been cleared by a moderator + %1$s turned on emote-only mode + %1$s turned off emote-only mode + %1$s turned on followers-only mode + %1$s turned on followers-only mode (%2$s) + %1$s turned off followers-only mode + %1$s turned on unique-chat mode + %1$s turned off unique-chat mode + %1$s turned on slow mode + %1$s turned on slow mode (%2$s) + %1$s turned off slow mode + %1$s turned on subscribers-only mode + %1$s turned off subscribers-only mode + %1$s timed out %2$s for %3$s in %4$s + %1$s timed out %2$s for %3$s in %4$s: %5$s + %1$s untimedout %2$s in %3$s + %1$s banned %2$s in %3$s + %1$s banned %2$s in %3$s: %4$s + %1$s unbanned %2$s in %3$s + %1$s deleted message from %2$s in %3$s + %1$s deleted message from %2$s in %3$s saying: %4$s + %1$s%2$s + + \u0020(%1$d time) + \u0020(%1$d times) + + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + Emote only + Subscriber only + Slow mode + Slow mode (%1$s) + Unique chat (R9K) + Follower only + Follower only (%1$s) + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Activate Shield Mode? + This will apply the channel\'s pre-configured safety settings, which may include chat restrictions, AutoMod overrides, and chat verification requirements. + Activate Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + Add a channel to start chatting + No recent emotes + Show stream + Hide stream + Audio only + Exit audio only + Fullscreen + Exit fullscreen + Hide input + Show input + Channel swipe navigation + Switch channels by swiping on the chat + Channel moderation + Search messages + Last message + Toggle stream + Channel moderation + Fullscreen + Hide input + Configure actions + Debug + + Maximum of %1$d action + Maximum of %1$d actions + + DankChat + Let\'s get you set up. + Login with Twitch + Log in to send messages, use your emotes, receive whispers, and unlock all features. + You will be asked to grant several Twitch permissions at once so you won\'t need to re-authorize when using different features. DankChat only performs moderation and stream actions when you ask it to. + Login with Twitch + Login successful + Notifications + DankChat can notify you when someone mentions you in chat while the app is in the background. + Allow Notifications + Open Notification Settings + Without notifications, you won\'t know when someone mentions you in chat while the app is in the background. + Message History + DankChat loads historical messages from a third-party service on startup. To get the messages, DankChat sends the names of the channels you have open to that service. The service temporarily stores messages for channels you (and others) visit to provide the service.\n\nYou can change this later in the settings or learn more at https://recent-messages.robotty.de/ + Enable + Disable + Continue + Get Started + Skip + Save + Message id copied + Close the emote menu + Emotes + Reply + Send announcement Data loading failed with multiple errors:\n%1$s + Reconnected + Failed to load FFZ emotes (Error %1$s) + Failed to load BTTV emotes (Error %1$s) + Failed to load 7TV emotes (Error %1$s) + %1$s switched the active 7TV Emote Set to \"%2$s\". + %1$s added 7TV Emote %2$s. + %1$s renamed 7TV Emote %2$s to %3$s. + %1$s removed 7TV Emote %2$s. + Reply Thread + Are you sure you want to remove channel \"%1$s\"? + Confirm channel block + Are you sure you want to block channel \"%1$s\"? + Host + Reset + OAuth token + Verify & Save + Only Client IDs that work with Twitch\'s Helix API are supported + Show required scopes + Required scopes + Missing scopes + Some scopes required by DankChat are missing in the token and some functionality might not work as expected. Do you want to continue using this token?\nMissing: %1$s + Continue + Missing scopes: %1$s + Error: %1$s + Token can\'t be empty + Token is invalid + Moderator + Lead Moderator + Predicted "%1$s" + Tier %1$s + Components + Load message history after a reconnect + Attempts to fetch missed messages that were not received during connection drops + Ignores URLs in TTS + Ignore URLs + Ignores emotes and emojis in TTS + Ignore emotes + Volume + Audio ducking + Lower other audio volume while TTS is speaking + Custom recent messages host + Fetch stream information + Periodically fetches stream information of open channels. Required to start embedded stream. + Disable input if not connected to chat + Enable repeated sending + Enables continuous message sending while send button is held + Login Expired! + Your login token has expired! Please login again. + Login Again + Failed to verify the login token, check your connection. + Prevent stream reloads + Enables experimental prevention of stream reloads after orientation changes or re-opening DankChat. + Show changelogs after update + What\'s new + Custom login + Bypass Twitch command handling + Disables intercepting of Twitch commands and sends them to chat instead + Chat send protocol + Use Helix API for sending + Send chat messages via Twitch Helix API instead of IRC + 7TV live emote updates + Live emote updates background behavior + Updates stop after %1$s.\nLowering this number may increase battery life. + Updates are always active.\nAdding a timeout may increase battery life. + Updates are never active in the background. + Never active + 1 minute + 5 minutes + 30 minutes + 1 hour + Always active + Livestreams + Enable picture-in-picture mode + Allows streams to continue playing while the app is in the background + Reset Media Uploader Settings + Are you sure you want to reset the media uploader settings to default? + Reset + Clear Recent Uploads + Are you sure you want to clear the upload history? Your uploaded files won\'t be deleted. + Clear + Pattern + Case-sensitive + Highlights + Enabled + Notification + Edit message highlights + Highlights and Ignores + Username + Block + Replacement + Ignores + Edit message ignores + Messages + Users + Blacklisted Users + Twitch + Badges + Undo + Item removed + Unblocked user %1$s + Failed to unblock user %1$s + Badge + Failed to block user %1$s + Your username + Subscriptions and Events + Announcements + Watch Streaks + First Messages + Elevated Messages + Highlights redeemed with Channel Points + Replies + Custom + Creates notifications and highlights messages based on certain patterns. + Creates notifications and highlights messages from certain users. + Disable notifications and highlights from certain users (e.g. bots). + Create notifications and highlights messages from users based on badges. + Ignore messages based on certain patterns. + Ignore messages from certain users. + Manage blocked Twitch users. + Create notifications for whispers + Login Outdated! + Your login is outdated and does not have access to some functionality. Please login again. + Custom User Display + Remove Custom User Display + Alias + Custom Alias + Pick custom user color + Add a custom name and color for users + Replying to + Replying to @%1$s + Reply thread not found + Use emote + Copy + Open emote link + Emote image + Twitch Emote + Channel FFZ Emote + Global FFZ Emote + Channel BTTV Emote + Shared BTTV Emote + Global BTTV Emote + Channel 7TV Emote + Global 7TV Emote + Alias of %1$s + Created by %1$s + (Zero Width) + Emote copied + DankChat has been updated! + What\'s new in v%1$s: + Confirm login cancellation + Are you sure you want to cancel the login process? + Cancel login + Zoom out + Zoom in + Back + Shared Chat + Open source licenses + Show stream category + Also display stream category + Toggle input + Broadcaster + Admin + Staff + Moderator + Lead Moderator + Verified + VIP + Founder + Subscriber + Pick custom highlight color + Default + Choose Color + + + General + Auth + Enable Twitch EventSub + Uses EventSub for various real-time events instead of deprecated PubSub + Enable EventSub debug output + Prints debug output related to EventSub as system messages + Revoke token and restart + Invalidates the current token and restarts the app + Not logged in + Could not resolve channel ID for %1$s + Message was not sent + Message dropped: %1$s (%2$s) + Missing user:write:chat scope, please re-login + Not authorized to send messages in this channel + Message is too large + Rate limited, try again in a moment + Send failed: %1$s + + + You must be logged in to use the %1$s command + No user matching that username. + An unknown error has occurred. + You don\'t have permission to perform that action. + Missing required scope. Re-login with your account and try again. + Missing login credentials. Re-login with your account and try again. + Usage: /block <user> + You successfully blocked user %1$s + User %1$s couldn\'t be blocked, no user with that name found! + User %1$s couldn\'t be blocked, an unknown error occurred! + Usage: /unblock <user> + You successfully unblocked user %1$s + User %1$s couldn\'t be unblocked, no user with that name found! + User %1$s couldn\'t be unblocked, an unknown error occurred! + Channel is not live. + Uptime: %1$s + Commands available to you in this room: %1$s + Usage: %1$s <username> <message>. + Whisper sent. + Failed to send whisper - %1$s + Usage: %1$s <message> - Call attention to your message with a highlight. + Failed to send announcement - %1$s + This channel does not have any moderators. + The moderators of this channel are %1$s. + Failed to list moderators - %1$s + Usage: %1$s <username> - Grant moderation status to a user. + You have added %1$s as a moderator of this channel. + Failed to add channel moderator - %1$s + Usage: %1$s <username> - Revoke moderation status from a user. + You have removed %1$s as a moderator of this channel. + Failed to remove channel moderator - %1$s + This channel does not have any VIPs. + The VIPs of this channel are %1$s. + Failed to list VIPs - %1$s + Usage: %1$s <username> - Grant VIP status to a user. + You have added %1$s as a VIP of this channel. + Failed to add VIP - %1$s + Usage: %1$s <username> - Revoke VIP status from a user. + You have removed %1$s as a VIP of this channel. + Failed to remove VIP - %1$s + Usage: %1$s <username> [reason] - Permanently prevent a user from chatting. Reason is optional and will be shown to the target user and other moderators. Use /unban to remove a ban. + Failed to ban user - You cannot ban yourself. + Failed to ban user - You cannot ban the broadcaster. + Failed to ban user - %1$s + Usage: %1$s <username> - Removes a ban on a user. + Failed to unban user - %1$s + Usage: %1$s <username> [duration][time unit] [reason] - Temporarily prevent a user from chatting. Duration (optional, default: 10 minutes) must be a positive integer; time unit (optional, default: s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Reason is optional and will be shown to the target user and other moderators. + Failed to ban user - You cannot timeout yourself. + Failed to ban user - You cannot timeout the broadcaster. + Failed to timeout user - %1$s + Failed to delete chat messages - %1$s + Usage: /delete <msg-id> - Deletes the specified message. + Invalid msg-id: \"%1$s\". + Failed to delete chat messages - %1$s + Usage: /color <color> - Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + Your color has been changed to %1$s + Failed to change color to %1$s - %2$s + Successfully added a stream marker at %1$s%2$s. + Failed to create stream marker - %1$s + Usage: /commercial <length> - Starts a commercial with the specified duration for the current channel. Valid length options are 30, 60, 90, 120, 150, and 180 seconds. + + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + + Failed to start commercial - %1$s + Usage: /raid <username> - Raid a user. Only the broadcaster can start a raid. + Invalid username: %1$s + You started to raid %1$s. + Failed to start a raid - %1$s + You cancelled the raid. + Failed to cancel the raid - %1$s + Usage: %1$s [duration] - Enables followers-only mode (only followers may chat). Duration (optional, default: 0 minutes) must be a positive number followed by a time unit (m, h, d, w); maximum duration is 3 months. + This room is already in %1$s followers-only mode. + Failed to update chat settings - %1$s + This room is not in followers-only mode. + This room is already in emote-only mode. + This room is not in emote-only mode. + This room is already in subscribers-only mode. + This room is not in subscribers-only mode. + This room is already in unique-chat mode. + This room is not in unique-chat mode. + Usage: %1$s [duration] - Enables slow mode (limit how often users may send messages). Duration (optional, default: 30) must be a positive number of seconds; maximum is 120. + This room is already in %1$d-second slow mode. + This room is not in slow mode. + Usage: %1$s <username> - Sends a shoutout to the specified Twitch user. + Sent shoutout to %1$s + Failed to send shoutout - %1$s + Shield mode was activated. + Shield mode was deactivated. + Failed to update shield mode - %1$s + You cannot whisper yourself. + Due to Twitch restrictions, you are now required to have a verified phone number to send whispers. You can add a phone number in Twitch settings. https://www.twitch.tv/settings/security + The recipient doesn\'t allow whispers from strangers or you directly. + You are being rate-limited by Twitch. Try again in a few seconds. + You may only whisper a maximum of 40 unique recipients per day. Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute. + Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead. + %1$s is already a moderator of this channel. + %1$s is currently a VIP, /unvip them and retry this command. + %1$s is not a moderator of this channel. + %1$s is not banned from this channel. + %1$s is already banned in this channel. + You cannot %1$s %2$s. + There was a conflicting ban operation on this user. Please try again. + Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + You must be streaming live to run commercials. + You must wait until your cool-down period expires before you can run another commercial. + Command must include a desired commercial break length that is greater than zero. + You don\'t have an active raid. + A channel cannot raid itself. + The broadcaster may not give themselves a Shoutout. + The broadcaster is not streaming live or does not have one or more viewers. + The duration is out of the valid range: %1$s. + The message has already been processed. + The target message was not found. + Your message was too long. + You are being rate-limited. Try again in a moment. + The target user + Log viewer + View application logs + Logs + Share logs + View logs + No log files available + Search logs + + %1$d selected + %1$d selected + + Copy selected logs + Clear selection + Crash detected + The app crashed during your last session. + Thread: %1$s + Copy + Chat report + Joins #flex3rs and prepares a crash summary to send + Email report + Send a detailed crash report via email + Send crash report via email + The following data will be included in the report: + Stack trace + Include current log file + Crash reports + View recent crash reports + No crash reports found + Crash Report + Share crash report + Delete + Delete this crash report? + Clear all + Delete all crash reports? + Scroll to bottom + + Live with %1$d viewer for %2$s + Live with %1$d viewers for %2$s + + + Live with %1$d viewer in %2$s for %3$s + Live with %1$d viewers in %2$s for %3$s + + View history + No messages match the current filters diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b2b118f8e..f306618cd 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -48,20 +48,66 @@ Disconnected Not logged in Reply - You have new mentions + Send announcement You have new mentions %1$s just mentioned you in #%2$s You were mentioned in #%1$s Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s + Upload + Copied to clipboard + Copy URL Retry Emotes reloaded Data loading failed: %1$s Data loading failed with multiple errors:\n%1$s + DankChat Badges + Global Badges + Global FFZ Emotes + Global BTTV Emotes + Global 7TV Emotes + Channel Badges + FFZ Emotes + BTTV Emotes + 7TV Emotes + Twitch Emotes + Cheermotes + Recent Messages + %1$s (%2$s) + + First Time Chat + Elevated Chat + Gigantified Emote + Animated Message + Redeemed %1$s + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + + %1$d week + %1$d weeks + + %1$s %2$s + %1$s %2$s %3$s Paste Channel name + Channel is already added Recent Subs Channel @@ -152,6 +198,8 @@ Add a command Remove the command Trigger + This trigger is reserved by a built-in command + This trigger is already used by another command Command Custom commands Report @@ -191,12 +239,52 @@ General About Appearance + Suggestions + Messages + Users + Emotes & Badges DankChat %1$s made by @flex3rs and contributors Show input Displays the input field to send messages Follow system default - True dark theme - Forces chat background color to black + Amoled dark mode + Pure black backgrounds for OLED screens + Accent color + Follow system wallpaper + Blue + Teal + Green + Lime + Yellow + Orange + Red + Pink + Purple + Indigo + Brown + Gray + Color style + System default + Use the default system color palette + Tonal Spot + Calm and subdued colors + Neutral + Nearly monochrome, subtle tint + Vibrant + Bold and saturated colors + Expressive + Playful colors with shifted hues + Rainbow + Broad spectrum of hues + Fruit Salad + Playful, multi-colored palette + Monochrome + Black, white, and gray only + Fidelity + Stays true to the accent color + Content + Accent color with analogous tertiary + More styles Display Components Show timed out messages @@ -208,8 +296,19 @@ Small Large Very large - Emote and user suggestions - Shows suggestions for emotes and active users while typing + Suggestions + Choose which suggestions to show while typing + Emotes + Users + Twitch commands + Supibot commands + Trigger with : + Trigger with @ + Trigger with / + Trigger with $ + Suggestion mode + Suggest matches as you type + Only suggest after a trigger character Load message history on start Load message history after a reconnect Attempts to fetch missed messages that were not received during connection drops @@ -219,7 +318,7 @@ Channel data Developer options Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar and collect crash reports locally Timestamp format Enable TTS Reads out messages of the active channel @@ -233,6 +332,9 @@ Ignore URLs Ignores emotes and emojis in TTS Ignore emotes + Volume + Audio ducking + Lower other audio volume while TTS is speaking TTS Checkered Lines Separate each line with different background brightness @@ -245,12 +347,19 @@ User long click behavior Regular click opens popup, long click mentions Regular click mentions, long click opens popup + Colorize nicknames + Assign a random color to users without a set color Force language to English Force TTS voice language to English instead of system default Visible third party emotes Twitch terms of service & user policy: Show chip actions Displays chips for toggling fullscreen, streams and adjusting chat modes + Show character counter + Displays code point count in the input field + Show clear input button + Show send button + Input Media uploader Configure uploader Recent uploads @@ -279,6 +388,9 @@ Custom login Bypass Twitch command handling Disables intercepting of Twitch commands and sends them to chat instead + Chat send protocol + Use Helix API for sending + Send chat messages via Twitch Helix API instead of IRC 7TV live emote updates Live emote updates background behavior Updates stop after %1$s.\nLowering this number may increase battery life. @@ -325,6 +437,7 @@ Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points @@ -351,9 +464,22 @@ Copy message Copy full message Reply to message + Reply to original message View thread Copy message id More… + Jump to message + Message no longer in chat history + Message history + Global History + History: %1$s + Search messages… + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge Replying to @%1$s Reply thread not found Message not found @@ -410,4 +536,374 @@ Pick custom highlight color Default Choose Color + Toggle App Bar + Error: %s + Log out? + Remove this channel? + Remove channel \"%1$s\"? + Block channel \"%1$s\"? + Ban this user? + Delete this message? + Clear chat? + Customizable actions for quick access to search, streams, and more + Tap here for more actions and to configure your action bar + You can customize which actions appear in your action bar here + Swipe down on the input to quickly hide it + Tap here to bring the input back + Next + Got it + Skip tour + You can add more channels here + + + Held a message for reason: %1$s. Allow will post it in chat. + Allow + Deny + Approved + Denied + Expired + Hey! Your message is being checked by mods and has not been sent. + Mods have accepted your message. + Mods have denied your message. + %1$s (level %2$d) + + matches %1$d blocked term %2$s + matches %1$d blocked terms %2$s + + Failed to %1$s AutoMod message - message has already been processed. + Failed to %1$s AutoMod message - you need to re-authenticate. + Failed to %1$s AutoMod message - you don\'t have permission to perform that action. + Failed to %1$s AutoMod message - target message not found. + Failed to %1$s AutoMod message - an unknown error occurred. + %1$s added %2$s as a blocked term on AutoMod. + %1$s added %2$s as a permitted term on AutoMod. + %1$s removed %2$s as a blocked term on AutoMod. + %1$s removed %2$s as a permitted term on AutoMod. + + + + You were timed out for %1$s + You were timed out for %1$s by %2$s + You were timed out for %1$s by %2$s: %3$s + %1$s timed out %2$s for %3$s + %1$s timed out %2$s for %3$s: %4$s + %1$s has been timed out for %2$s + You were banned + You were banned by %1$s + You were banned by %1$s: %2$s + %1$s banned %2$s + %1$s banned %2$s: %3$s + %1$s has been permanently banned + %1$s untimedout %2$s + %1$s unbanned %2$s + %1$s modded %2$s + %1$s unmodded %2$s + %1$s has added %2$s as a VIP of this channel + %1$s has removed %2$s as a VIP of this channel + %1$s has warned %2$s + %1$s has warned %2$s: %3$s + %1$s initiated a raid to %2$s + %1$s canceled the raid to %2$s + %1$s deleted message from %2$s + %1$s deleted message from %2$s saying: %3$s + A message from %1$s was deleted + A message from %1$s was deleted saying: %2$s + %1$s cleared the chat + Chat has been cleared by a moderator + %1$s turned on emote-only mode + %1$s turned off emote-only mode + %1$s turned on followers-only mode + %1$s turned on followers-only mode (%2$s) + %1$s turned off followers-only mode + %1$s turned on unique-chat mode + %1$s turned off unique-chat mode + %1$s turned on slow mode + %1$s turned on slow mode (%2$s) + %1$s turned off slow mode + %1$s turned on subscribers-only mode + %1$s turned off subscribers-only mode + %1$s timed out %2$s for %3$s in %4$s + %1$s timed out %2$s for %3$s in %4$s: %5$s + %1$s untimedout %2$s in %3$s + %1$s banned %2$s in %3$s + %1$s banned %2$s in %3$s: %4$s + %1$s unbanned %2$s in %3$s + %1$s deleted message from %2$s in %3$s + %1$s deleted message from %2$s in %3$s saying: %4$s + %1$s%2$s + + \u0020(%1$d time) + \u0020(%1$d times) + + + + Backspace + Send a whisper + Whispering @%1$s + New whisper + Send whisper to + Username + Start + + + Emote only + Subscriber only + Slow mode + Slow mode (%1$s) + Unique chat (R9K) + Follower only + Follower only (%1$s) + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Activate Shield Mode? + This will apply the channel\'s pre-configured safety settings, which may include chat restrictions, AutoMod overrides, and chat verification requirements. + Activate Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Add a channel to start chatting + No recent emotes + + + Show stream + Hide stream + Audio only + Exit audio only + Fullscreen + Exit fullscreen + Hide input + Show input + Channel swipe navigation + Switch channels by swiping on the chat + Channel moderation + + + Search messages + Last message + Toggle stream + Channel moderation + Fullscreen + Hide input + Configure actions + Debug + + Maximum of %1$d action + Maximum of %1$d actions + + + + DankChat + Let\'s get you set up. + Login with Twitch + Log in to send messages, use your emotes, receive whispers, and unlock all features. + You will be asked to grant several Twitch permissions at once so you won\'t need to re-authorize when using different features. DankChat only performs moderation and stream actions when you ask it to. + Login with Twitch + Login successful + Notifications + DankChat can notify you when someone mentions you in chat while the app is in the background. + Allow Notifications + Open Notification Settings + Without notifications, you won\'t know when someone mentions you in chat while the app is in the background. + Message History + DankChat loads historical messages from a third-party service on startup. To get the messages, DankChat sends the names of the channels you have open to that service. The service temporarily stores messages for channels you (and others) visit to provide the service.\n\nYou can change this later in the settings or learn more at https://recent-messages.robotty.de/ + Enable + Disable + Continue + Get Started + Skip + + + General + Auth + Enable Twitch EventSub + Uses EventSub for various real-time events instead of deprecated PubSub + Enable EventSub debug output + Prints debug output related to EventSub as system messages + Revoke token and restart + Invalidates the current token and restarts the app + Not logged in + Could not resolve channel ID for %1$s + Message was not sent + Message dropped: %1$s (%2$s) + Missing user:write:chat scope, please re-login + Not authorized to send messages in this channel + Message is too large + Rate limited, try again in a moment + Send failed: %1$s + + + You must be logged in to use the %1$s command + No user matching that username. + An unknown error has occurred. + You don\'t have permission to perform that action. + Missing required scope. Re-login with your account and try again. + Missing login credentials. Re-login with your account and try again. + Usage: /block <user> + You successfully blocked user %1$s + User %1$s couldn\'t be blocked, no user with that name found! + User %1$s couldn\'t be blocked, an unknown error occurred! + Usage: /unblock <user> + You successfully unblocked user %1$s + User %1$s couldn\'t be unblocked, no user with that name found! + User %1$s couldn\'t be unblocked, an unknown error occurred! + Channel is not live. + Uptime: %1$s + Commands available to you in this room: %1$s + Usage: %1$s <username> <message>. + Whisper sent. + Failed to send whisper - %1$s + Usage: %1$s <message> - Call attention to your message with a highlight. + Failed to send announcement - %1$s + This channel does not have any moderators. + The moderators of this channel are %1$s. + Failed to list moderators - %1$s + Usage: %1$s <username> - Grant moderation status to a user. + You have added %1$s as a moderator of this channel. + Failed to add channel moderator - %1$s + Usage: %1$s <username> - Revoke moderation status from a user. + You have removed %1$s as a moderator of this channel. + Failed to remove channel moderator - %1$s + This channel does not have any VIPs. + The VIPs of this channel are %1$s. + Failed to list VIPs - %1$s + Usage: %1$s <username> - Grant VIP status to a user. + You have added %1$s as a VIP of this channel. + Failed to add VIP - %1$s + Usage: %1$s <username> - Revoke VIP status from a user. + You have removed %1$s as a VIP of this channel. + Failed to remove VIP - %1$s + Usage: %1$s <username> [reason] - Permanently prevent a user from chatting. Reason is optional and will be shown to the target user and other moderators. Use /unban to remove a ban. + Failed to ban user - You cannot ban yourself. + Failed to ban user - You cannot ban the broadcaster. + Failed to ban user - %1$s + Usage: %1$s <username> - Removes a ban on a user. + Failed to unban user - %1$s + Usage: %1$s <username> [duration][time unit] [reason] - Temporarily prevent a user from chatting. Duration (optional, default: 10 minutes) must be a positive integer; time unit (optional, default: s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Reason is optional and will be shown to the target user and other moderators. + Failed to ban user - You cannot timeout yourself. + Failed to ban user - You cannot timeout the broadcaster. + Failed to timeout user - %1$s + Failed to delete chat messages - %1$s + Usage: /delete <msg-id> - Deletes the specified message. + Invalid msg-id: \"%1$s\". + Failed to delete chat messages - %1$s + Usage: /color <color> - Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + Your color has been changed to %1$s + Failed to change color to %1$s - %2$s + Successfully added a stream marker at %1$s%2$s. + Failed to create stream marker - %1$s + Usage: /commercial <length> - Starts a commercial with the specified duration for the current channel. Valid length options are 30, 60, 90, 120, 150, and 180 seconds. + + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + + Failed to start commercial - %1$s + Usage: /raid <username> - Raid a user. Only the broadcaster can start a raid. + Invalid username: %1$s + You started to raid %1$s. + Failed to start a raid - %1$s + You cancelled the raid. + Failed to cancel the raid - %1$s + Usage: %1$s [duration] - Enables followers-only mode (only followers may chat). Duration (optional, default: 0 minutes) must be a positive number followed by a time unit (m, h, d, w); maximum duration is 3 months. + This room is already in %1$s followers-only mode. + Failed to update chat settings - %1$s + This room is not in followers-only mode. + This room is already in emote-only mode. + This room is not in emote-only mode. + This room is already in subscribers-only mode. + This room is not in subscribers-only mode. + This room is already in unique-chat mode. + This room is not in unique-chat mode. + Usage: %1$s [duration] - Enables slow mode (limit how often users may send messages). Duration (optional, default: 30) must be a positive number of seconds; maximum is 120. + This room is already in %1$d-second slow mode. + This room is not in slow mode. + Usage: %1$s <username> - Sends a shoutout to the specified Twitch user. + Sent shoutout to %1$s + Failed to send shoutout - %1$s + Shield mode was activated. + Shield mode was deactivated. + Failed to update shield mode - %1$s + You cannot whisper yourself. + Due to Twitch restrictions, you are now required to have a verified phone number to send whispers. You can add a phone number in Twitch settings. https://www.twitch.tv/settings/security + The recipient doesn\'t allow whispers from strangers or you directly. + You are being rate-limited by Twitch. Try again in a few seconds. + You may only whisper a maximum of 40 unique recipients per day. Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute. + Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead. + %1$s is already a moderator of this channel. + %1$s is currently a VIP, /unvip them and retry this command. + %1$s is not a moderator of this channel. + %1$s is not banned from this channel. + %1$s is already banned in this channel. + You cannot %1$s %2$s. + There was a conflicting ban operation on this user. Please try again. + Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + You must be streaming live to run commercials. + You must wait until your cool-down period expires before you can run another commercial. + Command must include a desired commercial break length that is greater than zero. + You don\'t have an active raid. + A channel cannot raid itself. + The broadcaster may not give themselves a Shoutout. + The broadcaster is not streaming live or does not have one or more viewers. + The duration is out of the valid range: %1$s. + The message has already been processed. + The target message was not found. + Your message was too long. + You are being rate-limited. Try again in a moment. + The target user + Log viewer + View application logs + Logs + Share logs + View logs + No log files available + Search logs + + %1$d selected + %1$d selected + + Copy selected logs + Clear selection + Crash detected + The app crashed during your last session. + Thread: %1$s + Copy + Chat report + Joins #flex3rs and prepares a crash summary to send + Email report + Send a detailed crash report via email + Send crash report via email + The following data will be included in the report: + Stack trace + Include current log file + Crash reports + View recent crash reports + No crash reports found + Crash Report + Share crash report + Delete + Delete this crash report? + Clear all + Delete all crash reports? + Scroll to bottom + View history + No messages match the current filters diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index aad74fc7f..29ed88c71 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -48,7 +48,7 @@ Desconectado Sesión no iniciada Responder - Tienes nuevas menciones + Send announcement Tienes nuevas menciones %1$s te ha mencionado en #%2$s Has sido mencionado en #%1$s Iniciando sesión como %1$s @@ -56,12 +56,62 @@ Copiado: %1$s Error al subir Error al subir: %1$s + Subir + Copiado al portapapeles + Copiar URL Reintentar Emoticonos actualizados Error al cargar datos: %1$s La carga de datos falló con múltiples errores:\n%1$s + Badges de DankChat + Badges globales + Emotes FFZ globales + Emotes BTTV globales + Emotes 7TV globales + Badges del canal + Emotes FFZ + Emotes BTTV + Emotes 7TV + Emotes de Twitch + Cheermotes + Mensajes recientes + %1$s (%2$s) + + Primer mensaje + Mensaje destacado + Emote gigante + Mensaje animado + Canjeado %1$s + + %1$d segundo + %1$d segundos + %1$d segundos + + + %1$d minuto + %1$d minutos + %1$d minutos + + + %1$d hora + %1$d horas + %1$d horas + + + %1$d día + %1$d días + %1$d días + + + %1$d semana + %1$d semanas + %1$d semanas + + %1$s %2$s + %1$s %2$s %3$s Pegar Nombre del canal + El canal ya está añadido Reciente Suscripciones Canal @@ -158,6 +208,8 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Añadir un comando Eliminar el comando Trigger + Este trigger está reservado por un comando integrado + Este trigger ya está siendo usado por otro comando Comando Comandos personalizados Reportar @@ -198,12 +250,52 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/General Acerca de Apariencia + Sugerencias + Mensajes + Usuarios + Emotes & Insignias DankChat %1$s creado por @flex3rs y contribuidores Mostrar entrada Muestra el campo de entrada para enviar mensajes Seguir opciones del sistema - Modo oscuro verdadero - Fuerza que el color de fondo del chat sea negro + Modo oscuro AMOLED + Fondos negros puros para pantallas OLED + Color de acento + Seguir fondo de pantalla del sistema + Azul + Verde azulado + Verde + Lima + Amarillo + Naranja + Rojo + Rosa + Morado + Índigo + Marrón + Gris + Estilo de color + Predeterminado del sistema + Usar la paleta de colores predeterminada del sistema + Tonal Spot + Colores tranquilos y tenues + Neutral + Casi monocromático, tinte sutil + Vibrant + Colores vivos y saturados + Expressive + Colores lúdicos con tonos cambiados + Rainbow + Amplio espectro de tonos + Fruit Salad + Paleta lúdica y multicolor + Monochrome + Solo negro, blanco y gris + Fidelity + Fiel al color de acento + Content + Color de acento con terciario análogo + Más estilos Mostrar Componentes Mostrar mensajes eliminados @@ -215,8 +307,19 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Pequeña Grande Muy grande - Sugerencias de emoticonos y usuarios - Muestra sugerencias para emoticonos y usuarios activos al escribir + Sugerencias + Elige qué sugerencias mostrar al escribir + Emotes + Usuarios + Comandos de Twitch + Comandos de Supibot + Activar con : + Activar con @ + Activar con / + Activar con $ + Modo de sugerencia + Sugerir coincidencias mientras escribes + Solo sugerir después de un carácter activador Cargar historial de mensajes al inicio Cargar el historial de mensajes después de una reconexión Intentos de recuperar los mensajes perdidos que no fueron recibidos durante caídas de conexión @@ -226,7 +329,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Datos del canal Opciones de desarrollador Modo depuración - Proporciona información para cualquier excepción que se encuentre + Mostrar acción de análisis de depuración en la barra de entrada y recopilar informes de crash localmente Formato del tiempo Activar TTS Lee en voz alta mensajes del canal activo @@ -240,6 +343,9 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Ignorar URLs Ignorar emotes y emojis en los TTS Ignorar emotes + Volumen + Atenuación de audio + Reducir el volumen de otras aplicaciones mientras TTS habla TTS (Síntesis de voz) Líneas alternadas Separar cada línea con diferente brillo de fondo @@ -252,12 +358,19 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Comportamiento pulsación larga sobre un usuario Un click normal abre popup, una pulsación larga menciona al usuario Un click normal menciona al usuario, una pulsación larga abre popup + Colorear apodos + Asignar un color aleatorio a los usuarios sin un color definido Forzar inglés como idioma Forzar el idioma de la voz TTS al inglés Emoticonos de terceros visibles Términos de servicio y política de usuario de Twitch Mostrar acciones chip Mostrar chips para acceder a pantalla completa, streams y ajustar modos de chat + Mostrar contador de caracteres + Muestra el recuento de puntos de código en el campo de entrada + Mostrar botón de borrar entrada + Mostrar botón de enviar + Entrada Subidor de multimedia Configurar subidor Subidas recientes @@ -286,6 +399,9 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Inicio de sesión personalizado Omitir la gestión de comandos de Twitch Deshabilita la interceptación de los comandos de Twitch y los envía al chat en su lugar + Protocolo de envío del chat + Usar Helix API para enviar + Enviar mensajes de chat mediante Twitch Helix API en lugar de IRC Actualizaciones de emoticonos 7TV en directo Frecuencia de actualizaciones de emoticonos Las actualizaciones se detienen después de %1$s.\nReducir este número puede aumentar la duración de la batería. @@ -332,6 +448,7 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Tu usuario Suscripciones y Eventos Anuncios + Rachas de visualización Primeros Mensajes Mensajes Elevados Destacados canjeados con Puntos Canal @@ -358,9 +475,22 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Copiar mensaje Copiar mensaje completo Responder mensaje + Responder al mensaje original Ver hilo Copiar id del mensaje Ver más… + Ir al mensaje + El mensaje ya no está en el historial del chat + Historial de mensajes + Historial global + Historial: %1$s + Buscar mensajes… + Filtrar por nombre de usuario + Mensajes con enlaces + Mensajes con emotes + Filtrar por nombre de insignia + Usuario + Insignia Respondiendo a @%1$s Respuesta a hilo no encontrada Mensaje no encontrado @@ -391,15 +521,18 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Chat compartido En directo con %1$d espectador por %2$s + En directo con %1$d espectadores por %2$s En directo con %1$d espectadores por %2$s %d mes + %d meses %d meses Licencias de software libre En directo con %1$d espectador en %2$s durante %3$s + En directo con %1$d espectadores en %2$s durante %3$s En directo con %1$d espectadores en %2$s durante %3$s Mostrar categoría del stream @@ -417,4 +550,379 @@ Mira esta guía para más ayuda: https://wiki.chatterino.com/Image%20Uploader/Elegir color de resaltado personalizado Predeterminado Elegir color + Alternar barra de aplicación + Error: %s + ¿Cerrar sesión? + ¿Eliminar este canal? + ¿Eliminar el canal \"%1$s\"? + ¿Bloquear el canal \"%1$s\"? + ¿Banear este usuario? + ¿Eliminar este mensaje? + ¿Limpiar chat? + Acciones personalizables para acceso rápido a búsqueda, transmisiones y más + Toca aquí para más acciones y para configurar tu barra de acciones + Puedes personalizar qué acciones aparecen en tu barra de acciones aquí + Desliza hacia abajo en la entrada para ocultarla rápidamente + Toca aquí para recuperar la entrada + Siguiente + Entendido + Omitir tour + Puedes añadir más canales aquí + + + Mensaje retenido por motivo: %1$s. Permitir lo publicará en el chat. + Permitir + Denegar + Aprobado + Denegado + Expirado + Hey! Tu mensaje está siendo revisado por los mods y no se ha enviado. + Los mods han aceptado tu mensaje. + Los mods han rechazado tu mensaje. + %1$s (nivel %2$d) + + coincide con %1$d término bloqueado %2$s + coincide con %1$d términos bloqueados %2$s + coincide con %1$d términos bloqueados %2$s + + Error al %1$s mensaje de AutoMod - el mensaje ya ha sido procesado. + Error al %1$s mensaje de AutoMod - necesitas volver a autenticarte. + Error al %1$s mensaje de AutoMod - no tienes permiso para realizar esa acción. + Error al %1$s mensaje de AutoMod - mensaje objetivo no encontrado. + Error al %1$s mensaje de AutoMod - ocurrió un error desconocido. + %1$s añadió %2$s como término bloqueado en AutoMod. + %1$s añadió %2$s como término permitido en AutoMod. + %1$s eliminó %2$s como término bloqueado de AutoMod. + %1$s eliminó %2$s como término permitido de AutoMod. + + + Fuiste expulsado temporalmente por %1$s + Fuiste expulsado temporalmente por %1$s por %2$s + Fuiste expulsado temporalmente por %1$s por %2$s: %3$s + %1$s expulsó temporalmente a %2$s por %3$s + %1$s expulsó temporalmente a %2$s por %3$s: %4$s + %1$s ha sido expulsado temporalmente por %2$s + Fuiste baneado + Fuiste baneado por %1$s + Fuiste baneado por %1$s: %2$s + %1$s baneó a %2$s + %1$s baneó a %2$s: %3$s + %1$s ha sido baneado permanentemente + %1$s levantó la expulsión temporal de %2$s + %1$s desbaneó a %2$s + %1$s nombró moderador a %2$s + %1$s removió de moderador a %2$s + %1$s ha añadido a %2$s como VIP de este canal + %1$s ha removido a %2$s como VIP de este canal + %1$s ha advertido a %2$s + %1$s ha advertido a %2$s: %3$s + %1$s inició un raid a %2$s + %1$s canceló el raid a %2$s + %1$s eliminó un mensaje de %2$s + %1$s eliminó un mensaje de %2$s diciendo: %3$s + Un mensaje de %1$s fue eliminado + Un mensaje de %1$s fue eliminado diciendo: %2$s + %1$s limpió el chat + El chat ha sido limpiado por un moderador + %1$s activó el modo emote-only + %1$s desactivó el modo emote-only + %1$s activó el modo followers-only + %1$s activó el modo followers-only (%2$s) + %1$s desactivó el modo followers-only + %1$s activó el modo unique-chat + %1$s desactivó el modo unique-chat + %1$s activó el modo slow + %1$s activó el modo slow (%2$s) + %1$s desactivó el modo slow + %1$s activó el modo subscribers-only + %1$s desactivó el modo subscribers-only + %1$s expulsó temporalmente a %2$s por %3$s en %4$s + %1$s expulsó temporalmente a %2$s por %3$s en %4$s: %5$s + %1$s levantó la expulsión temporal de %2$s en %3$s + %1$s baneó a %2$s en %3$s + %1$s baneó a %2$s en %3$s: %4$s + %1$s desbaneó a %2$s en %3$s + %1$s eliminó un mensaje de %2$s en %3$s + %1$s eliminó un mensaje de %2$s en %3$s diciendo: %4$s + %1$s%2$s + + \u0020(%1$d vez) + \u0020(%1$d veces) + \u0020(%1$d veces) + + + + Retroceso + Enviar un susurro + Susurrando a @%1$s + Nuevo susurro + Enviar susurro a + Nombre de usuario + Enviar + + + Solo emotes + Solo suscriptores + Modo lento + Modo lento (%1$s) + Chat único (R9K) + Solo seguidores + Solo seguidores (%1$s) + Personalizado + Cualquiera + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + ¿Activar el modo escudo? + Esto aplicará las configuraciones de seguridad preconfiguradas del canal, que pueden incluir restricciones de chat, ajustes de AutoMod y requisitos de verificación. + Activar Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Añade un canal para empezar a chatear + No hay emotes recientes + + + Mostrar stream + Ocultar stream + Solo audio + Salir de solo audio + Pantalla completa + Salir de pantalla completa + Ocultar entrada + Mostrar entrada + Navegación por deslizamiento de canales + Cambiar de canal deslizando en el chat + Moderación del canal + + + Buscar mensajes + Último mensaje + Alternar stream + Moderación del canal + Pantalla completa + Ocultar entrada + Configurar acciones + Depuración + + Máximo de %1$d acción + Máximo de %1$d acciones + Máximo de %1$d acciones + + + + DankChat + Vamos a configurar todo. + Iniciar sesión con Twitch + Inicia sesión para enviar mensajes, usar tus emotes, recibir susurros y desbloquear todas las funciones. + Se te pedirá que concedas varios permisos de Twitch a la vez para que no tengas que volver a autorizar cuando uses distintas funciones. DankChat solo realiza acciones de moderación y de stream cuando tú se lo pides. + Iniciar sesión con Twitch + Inicio de sesión exitoso + Notificaciones + DankChat puede notificarte cuando alguien te menciona en el chat mientras la app está en segundo plano. + Permitir notificaciones + Abrir ajustes de notificaciones + Sin notificaciones, no sabrás cuando alguien te menciona en el chat mientras la app está en segundo plano. + Historial de mensajes + DankChat carga mensajes históricos de un servicio externo al iniciar. Para obtener los mensajes, DankChat envía los nombres de los canales abiertos a ese servicio. El servicio almacena temporalmente los mensajes de los canales visitados.\n\nPuedes cambiar esto más tarde en los ajustes o saber más en https://recent-messages.robotty.de/ + Activar + Desactivar + Continuar + Comenzar + Omitir + + + General + Autenticación + Activar Twitch EventSub + Usa EventSub para varios eventos en tiempo real en lugar del obsoleto PubSub + Activar salida de depuración de EventSub + Muestra información de depuración relacionada con EventSub como mensajes del sistema + Revocar token y reiniciar + Invalida el token actual y reinicia la aplicación + No has iniciado sesión + No se pudo resolver el ID del canal para %1$s + El mensaje no se envió + Mensaje descartado: %1$s (%2$s) + Falta el permiso user:write:chat, inicia sesión de nuevo + No tienes autorización para enviar mensajes en este canal + El mensaje es demasiado grande + Límite de frecuencia alcanzado, inténtalo de nuevo en un momento + Error de envío: %1$s + + + Debes iniciar sesión para usar el comando %1$s + No se encontró ningún usuario con ese nombre. + Ha ocurrido un error desconocido. + No tienes permiso para realizar esa acción. + Falta un permiso requerido. Vuelve a iniciar sesión con tu cuenta e inténtalo de nuevo. + Faltan las credenciales de inicio de sesión. Vuelve a iniciar sesión con tu cuenta e inténtalo de nuevo. + Uso: /block <usuario> + Has bloqueado correctamente al usuario %1$s + No se pudo bloquear al usuario %1$s, no se encontró ningún usuario con ese nombre. + No se pudo bloquear al usuario %1$s, ocurrió un error desconocido. + Uso: /unblock <usuario> + Has desbloqueado correctamente al usuario %1$s + No se pudo desbloquear al usuario %1$s, no se encontró ningún usuario con ese nombre. + No se pudo desbloquear al usuario %1$s, ocurrió un error desconocido. + El canal no está en directo. + Tiempo en directo: %1$s + Comandos disponibles para ti en esta sala: %1$s + Uso: %1$s <nombre de usuario> <mensaje>. + Susurro enviado. + Error al enviar el susurro - %1$s + Uso: %1$s <mensaje> - Llama la atención sobre tu mensaje con un resaltado. + Error al enviar el anuncio - %1$s + Este canal no tiene moderadores. + Los moderadores de este canal son %1$s. + Error al listar los moderadores - %1$s + Uso: %1$s <nombre de usuario> - Otorga el estado de moderador a un usuario. + Has añadido a %1$s como moderador de este canal. + Error al añadir moderador del canal - %1$s + Uso: %1$s <nombre de usuario> - Revoca el estado de moderador de un usuario. + Has eliminado a %1$s como moderador de este canal. + Error al eliminar moderador del canal - %1$s + Este canal no tiene VIPs. + Los VIPs de este canal son %1$s. + Error al listar los VIPs - %1$s + Uso: %1$s <nombre de usuario> - Otorga el estado de VIP a un usuario. + Has añadido a %1$s como VIP de este canal. + Error al añadir VIP - %1$s + Uso: %1$s <nombre de usuario> - Revoca el estado de VIP de un usuario. + Has eliminado a %1$s como VIP de este canal. + Error al eliminar VIP - %1$s + Uso: %1$s <nombre de usuario> [motivo] - Impide permanentemente que un usuario chatee. El motivo es opcional y se mostrará al usuario objetivo y a otros moderadores. Usa /unban para eliminar un baneo. + Error al banear al usuario - No puedes banearte a ti mismo. + Error al banear al usuario - No puedes banear al broadcaster. + Error al banear al usuario - %1$s + Uso: %1$s <nombre de usuario> - Elimina el baneo de un usuario. + Error al desbanear al usuario - %1$s + Uso: %1$s <nombre de usuario> [duración][unidad de tiempo] [motivo] - Impide temporalmente que un usuario chatee. La duración (opcional, por defecto: 10 minutos) debe ser un entero positivo; la unidad de tiempo (opcional, por defecto: s) debe ser s, m, h, d o w; la duración máxima es de 2 semanas. El motivo es opcional y se mostrará al usuario objetivo y a otros moderadores. + Error al banear al usuario - No puedes ponerte un timeout a ti mismo. + Error al banear al usuario - No puedes poner un timeout al broadcaster. + Error al aplicar timeout al usuario - %1$s + Error al eliminar los mensajes del chat - %1$s + Uso: /delete <msg-id> - Elimina el mensaje especificado. + msg-id no válido: \"%1$s\". + Error al eliminar los mensajes del chat - %1$s + Uso: /color <color> - El color debe ser uno de los colores admitidos por Twitch (%1$s) o un hex code (#000000) si tienes Turbo o Prime. + Tu color ha sido cambiado a %1$s + Error al cambiar el color a %1$s - %2$s + Marcador de stream añadido correctamente en %1$s%2$s. + Error al crear el marcador de stream - %1$s + Uso: /commercial <duración> - Inicia un anuncio comercial con la duración especificada para el canal actual. Las duraciones válidas son 30, 60, 90, 120, 150 y 180 segundos. + + Iniciando pausa comercial de %1$d segundos. Recuerda que sigues en directo y no todos los espectadores recibirán el anuncio. Puedes iniciar otro anuncio en %2$d segundos. + Iniciando pausa comercial de %1$d segundos. Recuerda que sigues en directo y no todos los espectadores recibirán el anuncio. Puedes iniciar otro anuncio en %2$d segundos. + Iniciando pausa comercial de %1$d segundos. Recuerda que sigues en directo y no todos los espectadores recibirán el anuncio. Puedes iniciar otro anuncio en %2$d segundos. + + Error al iniciar el anuncio comercial - %1$s + Uso: /raid <nombre de usuario> - Raidea a un usuario. Solo el broadcaster puede iniciar un raid. + Nombre de usuario no válido: %1$s + Has empezado a raidear a %1$s. + Error al iniciar un raid - %1$s + Has cancelado el raid. + Error al cancelar el raid - %1$s + Uso: %1$s [duración] - Activa el modo solo seguidores (solo los seguidores pueden chatear). La duración (opcional, por defecto: 0 minutos) debe ser un número positivo seguido de una unidad de tiempo (m, h, d, w); la duración máxima es de 3 meses. + Esta sala ya está en modo solo seguidores de %1$s. + Error al actualizar la configuración del chat - %1$s + Esta sala no está en modo solo seguidores. + Esta sala ya está en modo solo emotes. + Esta sala no está en modo solo emotes. + Esta sala ya está en modo solo suscriptores. + Esta sala no está en modo solo suscriptores. + Esta sala ya está en modo de chat único. + Esta sala no está en modo de chat único. + Uso: %1$s [duración] - Activa el modo lento (limita la frecuencia con la que los usuarios pueden enviar mensajes). La duración (opcional, por defecto: 30) debe ser un número positivo de segundos; máximo 120. + Esta sala ya está en modo lento de %1$d segundos. + Esta sala no está en modo lento. + Uso: %1$s <nombre de usuario> - Envía un shoutout al usuario de Twitch especificado. + Shoutout enviado a %1$s + Error al enviar el shoutout - %1$s + El modo escudo ha sido activado. + El modo escudo ha sido desactivado. + Error al actualizar el modo escudo - %1$s + No puedes susurrarte a ti mismo. + Debido a restricciones de Twitch, ahora necesitas tener un número de teléfono verificado para enviar susurros. Puedes añadir un número de teléfono en la configuración de Twitch. https://www.twitch.tv/settings/security + El destinatario no permite susurros de desconocidos o de ti directamente. + Twitch te ha limitado la frecuencia. Inténtalo de nuevo en unos segundos. + Solo puedes susurrar a un máximo de 40 destinatarios únicos por día. Dentro del límite diario, puedes enviar un máximo de 3 susurros por segundo y un máximo de 100 susurros por minuto. + Debido a restricciones de Twitch, este comando solo puede ser usado por el broadcaster. Por favor, usa la página web de Twitch en su lugar. + %1$s ya es moderador de este canal. + %1$s es actualmente un VIP, usa /unvip y vuelve a intentar este comando. + %1$s no es moderador de este canal. + %1$s no está baneado de este canal. + %1$s ya está baneado en este canal. + No puedes %1$s %2$s. + Hubo una operación de baneo en conflicto con este usuario. Por favor, inténtalo de nuevo. + El color debe ser uno de los colores admitidos por Twitch (%1$s) o un hex code (#000000) si tienes Turbo o Prime. + Debes estar en directo para ejecutar anuncios comerciales. + Debes esperar a que termine tu periodo de enfriamiento antes de ejecutar otro anuncio comercial. + El comando debe incluir una duración de pausa comercial deseada que sea mayor que cero. + No tienes un raid activo. + Un canal no puede raidearse a sí mismo. + El broadcaster no puede darse un Shoutout a sí mismo. + El broadcaster no está en directo o no tiene uno o más espectadores. + La duración está fuera del rango válido: %1$s. + El mensaje ya ha sido procesado. + No se encontró el mensaje objetivo. + Tu mensaje era demasiado largo. + Se te ha limitado la frecuencia. Inténtalo de nuevo en un momento. + El usuario objetivo + Visor de registros + Ver los registros de la aplicación + Registros + Compartir registros + Ver registros + No hay archivos de registro disponibles + Buscar en los registros + + %1$d seleccionados + %1$d seleccionados + %1$d seleccionados + + Copiar registros seleccionados + Borrar selección + Crash detectado + La aplicación se bloqueó durante tu última sesión. + Hilo: %1$s + Copiar + Informe por chat + Se une a #flex3rs y prepara un resumen del crash para enviar + Informe por correo + Enviar un informe de crash detallado por correo electrónico + Enviar informe de crash por correo electrónico + Los siguientes datos se incluirán en el informe: + Traza de pila + Incluir archivo de registro actual + Informes de crash + Ver informes de crash recientes + No se encontraron informes de crash + Informe de crash + Compartir informe de crash + Eliminar + ¿Eliminar este informe de crash? + Borrar todo + ¿Eliminar todos los informes de crash? + Desplazar hacia abajo + Carga completada: %1$s + Ver historial + No hay mensajes que coincidan con los filtros actuales diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index e9bb93b5f..0d3c50b0d 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -384,4 +384,5 @@ %d ماه %d ماه + تاریخچه: %1$s diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 098521678..56de640cc 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -37,30 +37,78 @@ Kolmannen osapuolen ylläpidon tarjoaa %1$s, käytä omalla vastuullasi. Mukautettu kuvan lataus Viesti kopioitu + Viestin tunnus kopioitu Virhetiedot kopioitu Pysäytä FeelsDankMan DankChat on käynnissä taustalla Avaa hymiö-valikko + Sulje hymiö-valikko + Hymiöt Kirjaudu sisään Twitch.tv:hen Aloita chattailu Yhteys katkaistu Ei kirjautuneena Vastaa - Sinulla on uusia mainintoja + Send announcement Sinulla on uusia mainintoja %1$s mainitsi sinut kanavalla #%2$s Sinut mainittiin kanavalla #%1$s Sisäänkirjautuminen nimellä %1$s Sisäänkirjautuminen epäonnistui Kopioitu: %1$s + Lataus valmis: %1$s Virhe lähetyksen aikana Virhe lähetyksen aikana: %1$s + Lataa + Kopioitu leikepöydälle + Kopioi URL Yritä uudelleen Emotet on ladattu uudelleen Tietojen lataaminen epäonnistui: %1$s Tiedon lataaminen epäonnistui useilla virheillä:\n%1$s + DankChat-merkit + Globaalit merkit + Globaalit FFZ-emotet + Globaalit BTTV-emotet + Globaalit 7TV-emotet + Kanavan merkit + FFZ-emotet + BTTV-emotet + 7TV-emotet + Twitch-emotet + Cheermotit + Viimeaikaiset viestit + %1$s (%2$s) + + Ensimmäinen viesti + Korostettu viesti + Jättimäinen emote + Animoitu viesti + Lunastettu %1$s + %1$d sekunti + %1$d sekuntia + + + %1$d minuutti + %1$d minuuttia + + + %1$d tunti + %1$d tuntia + + + %1$d päivä + %1$d päivää + + + %1$d viikko + %1$d viikkoa + + %1$s %2$s + %1$s %2$s %3$s Liitä Kanavan nimi + Kanava on jo lisätty Viimeisimmät Tilaukset Kanava @@ -77,6 +125,10 @@ FFZ-emoteiden lataaminen epäonnistui (Virhe %1$s) BTTV-emoteiden lataaminen epäonnistui (Virhe %1$s) 7TV-emoteiden lataaminen epäonnistui (Virhe %1$s) + %1$s vaihtoi aktiivisen 7TV-emotesarjan sarjaan \"%2$s\". + %1$s lisäsi 7TV-emotin %2$s. + %1$s nimesi 7TV-emotin %2$s uudelleen nimellä %3$s. + %1$s poisti 7TV-emotin %2$s. < Viesti poistettu > Regex Lisää merkintä @@ -92,6 +144,7 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Kieltäydy Lisää Maininnat / kuiskaukset + Vastausketju Kuiskaukset %1$s lähetti sinulle kuiskauksen Maininnat @@ -150,6 +203,9 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Minuuttia Lisää komento Poista komento + Laukaisin + Tämä laukaisin on varattu sisäänrakennetulle komennolle + Tämä laukaisin on jo toisen komennon käytössä Komento Mukautetut komennot Ilmoita @@ -160,7 +216,21 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Tyhjennä lataukset Hosti Palauta + OAuth-tunnus + Vahvista & tallenna + Vain Twitch Helix API:n kanssa toimivat Client ID:t ovat tuettuja + Näytä vaaditut oikeudet + Vaaditut oikeudet + Puuttuvat oikeudet + Joitain DankChatin vaatimia oikeuksia puuttuu tunnuksesta, eivätkä kaikki toiminnot välttämättä toimi odotetusti. Haluatko jatkaa tällä tunnuksella?\nPuuttuvat: %1$s + Jatka + Puuttuvat oikeudet: %1$s + Virhe: %1$s + Tunnus ei voi olla tyhjä + Tunnus on virheellinen Moderaattori + Päämoderattori + Ennusti \"%1$s\" Taso %1$s Näytä aikaleimat Maininnan muoto @@ -173,14 +243,54 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Ilmoitukset Chatti Yleinen + Ehdotukset + Viestit + Käyttäjät + Emojit ja merkit Tietoja Ulkoasu DankChat %1$s on tehnyt @flex3rs ja avustajat Näytä syöttö Näyttää viestien lähettämisen syöttökentän Noudata järjestelmän oletusta - Todellinen tumma tila - Asettaa chatin taustan väriksi mustan + AMOLED-tumma tila + Täysin musta tausta OLED-näytöille + Korostusväri + Seuraa järjestelmän taustakuvaa + Sininen + Sinivihreä + Vihreä + Limenvihreä + Keltainen + Oranssi + Punainen + Vaaleanpunainen + Violetti + Indigo + Ruskea + Harmaa + Värien tyyli + Järjestelmän oletus + Käytä järjestelmän oletusväripalettia + Tonal Spot + Rauhalliset ja hillityt värit + Neutral + Lähes yksivärinen, hienovarainen sävy + Vibrant + Rohkeat ja kylläiset värit + Expressive + Leikkisät värit siirretyillä sävyillä + Rainbow + Laaja sävyjen kirjo + Fruit Salad + Leikkisä, monivärinen paletti + Monochrome + Vain musta, valkoinen ja harmaa + Fidelity + Pysyy uskollisena korostusvärille + Content + Korostusväri analogisella tertiäärivärillä + Lisää tyylejä Näyttö Komponentit Näytä jäähyviestit @@ -192,16 +302,29 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Pieni Suuri Hyvin suuri - Hymiö ja käyttäjä ehdotukset - Näyttää ehdotuksia hymiöille ja aktiivisille käyttäjille kirjoittaessasi + Ehdotukset + Valitse mitkä ehdotukset näytetään kirjoittaessa + Emojit + Käyttäjät + Twitch-komennot + Supibot-komennot + Aktivoi merkillä : + Aktivoi merkillä @ + Aktivoi merkillä / + Aktivoi merkillä $ + Ehdotustila + Ehdota osumia kirjoittaessa + Ehdota vain laukaisumerkin jälkeen Lataa viestihistoria käynnistyessä + Lataa viestihistoria uudelleenyhdistyksen jälkeen + Yrittää hakea puuttuvat viestit, joita ei vastaanotettu yhteyskatkosten aikana Viestihistoria Avaa kojelauta Lue lisää palvelusta ja poista viestihistoria käytöstä omalla kanavallasi Kanavatiedot Kehittäjävaihtoehdot Virheenkorjaustila - Antaa tietoa mahdollisista kiinni jääneistä poikkeuksista + Näytä virheenkorjausanalytiikkatoiminto syöttöpalkissa ja kerää kaatumisraportit paikallisesti Aikaleiman muoto Ota TTS käyttöön Lukee ääneen aktiivisen kanavan viestit @@ -212,6 +335,12 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Lukee käyttäjän ja viestin Viestin muoto Ohittaa URL-osoitteet TTS:ssä + Ohita URL-osoitteet + Ohittaa emotet ja emojit TTS:ssä + Ohita emotet + Äänenvoimakkuus + Äänen vaimennus + Hiljennä muiden sovellusten ääntä TTS:n puhuessa TTS Ruudulliset viivat Erota kukin rivi erillaisella taustan kirkkaudella @@ -224,28 +353,110 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Käyttäjänimen pitkä painallus Tavallinen painallus popup-ikkuna ja pitkä painallus maininnat Tavallinen painallus maininnat ja pitkä painallus popup-ikkuna + Väritä nimimerkit + Määritä satunnainen väri käyttäjille, joilla ei ole asetettua väriä Aseta kieli englanniksi + Pakottaa TTS-äänen kieleksi englannin järjestelmän oletuksen sijaan Näkyvät kolmannen osapuolen hymiöt + Twitchin käyttöehdot ja käyttäjäsäännöt: + Näytä pikatoiminnot + Näyttää pikatoiminnot koko näytön, lähetysten ja chatti-tilojen hallintaan + Näytä merkkimäärälaskuri + Näyttää koodipisteiden määrän syöttökentässä + Näytä syötteen tyhjennys -painike + Näytä lähetyspainike + Syöte Median lähettäjä + Määritä lähettäjä + Viimeaikaiset lataukset + Käyttäjien ohituslista + Lista käyttäjistä/tileistä, jotka ohitetaan Työkalut Teema Tumma teema Vaalea teema + Salli listaamattomat emotet + Poistaa hyväksymättömien tai listaamattomien emotien suodatuksen + Mukautettu viestihistoriapalvelin + Hae lähetystiedot + Hakee säännöllisesti avointen kanavien lähetystiedot. Vaaditaan upotetun lähetyksen käynnistämiseen. + Poista syöttö käytöstä, jos chattiin ei ole yhteyttä + Ota toistuva lähetys käyttöön + Mahdollistaa jatkuvan viestien lähettämisen lähetyspainiketta painettaessa Kirjautuminen vanhentunut! + Kirjautumistunnuksesi on vanhentunut! Kirjaudu uudelleen. + Kirjaudu uudelleen + Kirjautumistunnuksen vahvistaminen epäonnistui, tarkista yhteytesi. + Estä lähetyksen uudelleenlataukset + Ottaa käyttöön kokeellisen lähetyksen uudelleenlatausten eston suunnanvaihdon tai DankChatin uudelleen avaamisen jälkeen. + Näytä muutosloki päivityksen jälkeen + Uutta + Mukautettu kirjautuminen + Ohita Twitch-komentojen käsittely + Poistaa Twitch-komentojen haltuunoton käytöstä ja lähettää ne chattiin sellaisenaan + Chatin lähetysprotokolla + Käytä Helix API:a lähettämiseen + Lähetä chat-viestit Twitch Helix API:n kautta IRC:n sijaan + 7TV reaaliaikaiset emotepäivitykset + Reaaliaikaisten emotepäivitysten taustakäyttäytyminen + Päivitykset pysähtyvät %1$s jälkeen.\nTämän arvon pienentäminen voi parantaa akun kestoa. + Päivitykset ovat aina aktiivisia.\nAikakatkaisun lisääminen voi parantaa akun kestoa. + Päivitykset eivät ole koskaan aktiivisia taustalla. + Ei koskaan aktiivinen + 1 minuutti + 5 minuuttia + 30 minuuttia + 1 tunti + Aina aktiivinen + Suoratoistot + Ota kuva kuvassa -tila käyttöön + Sallii lähetysten jatkuvan toiston sovelluksen ollessa taustalla + Palauta median lähettäjän asetukset + Haluatko varmasti palauttaa median lähettäjän asetukset oletuksiksi? Palauta + Tyhjennä viimeaikaiset lataukset + Haluatko varmasti tyhjentää lataushistorian? Ladattuja tiedostoja ei poisteta. Tyhjennä + Kaava + Kirjainkokoriippuvainen Kohokohdat Käytössä Ilmoitus + Muokkaa viestien korostuksia + Korostukset ja ohitukset Käyttäjänimi Estää + Korvaus + Ohitukset + Muokkaa viestien ohituksia Viestit Käyttäjät + Estolistalla olevat käyttäjät Twitch + Merkit Kumoa + Kohde poistettu + Käyttäjän %1$s esto poistettu + Käyttäjän %1$s eston poistaminen epäonnistui + Merkki + Käyttäjän %1$s estäminen epäonnistui Käyttäjänimesi + Tilaukset ja tapahtumat + Ilmoitukset + Katseluputket Ensimmäiset viestit + Korostetut viestit + Kanavapisteiden korostukset + Vastaukset Mukautettu + Luo ilmoituksia ja korostaa viestejä tiettyjen kaavojen perusteella. + Luo ilmoituksia ja korostaa viestejä tietyiltä käyttäjiltä. + Poista ilmoitukset ja korostukset käytöstä tietyiltä käyttäjiltä (esim. botit). + Luo ilmoituksia ja korostaa viestejä käyttäjiltä merkkien perusteella. + Ohita viestejä tiettyjen kaavojen perusteella. + Ohita viestejä tietyiltä käyttäjiltä. + Hallitse estettyjä Twitch-käyttäjiä. + Luo ilmoitukset kuiskauksista Kirjautuminen Vanhentunut! Kirjautumisesi on vanhentunut eikä sillä ole pääsyä joihinkin toimintoihin. Kirjaudu sisään uudelleen. Mukautettu Käyttäjänäkymä @@ -255,4 +466,451 @@ Voit oppia lisää palvelusta ja poistaa viestihistorian käytöstä omalla kana Mukautettu Alias Valitse käyttäjän mukautettu väri Lisää mukautettu nimi ja väri käyttäjille + Vastataan käyttäjälle + Vastataan käyttäjälle @%1$s + Näytä/piilota sovelluspalkki + Virhe: %s + Kirjaudutaanko ulos? + Poistetaanko tämä kanava? + Poistetaanko kanava \"%1$s\"? + Estetäänkö kanava \"%1$s\"? + Estetäänkö tämä käyttäjä? + Poistetaanko tämä viesti? + Tyhjennetäänkö chat? + Kopioi viesti + Kopioi koko viesti + Vastaa viestiin + Vastaa alkuperäiseen viestiin + Näytä ketju + Kopioi viestin tunnus + Lisää… + Siirry viestiin + Viestiä ei löytynyt + Viesti ei ole enää chat-historiassa + Viestihistoria + Yleinen historia + Historia: %1$s + Hae viestejä… + Suodata käyttäjänimen mukaan + Linkkejä sisältävät viestit + Emojeja sisältävät viestit + Suodata merkin nimen mukaan + Käyttäjä + Merkki + Vastausketjua ei löytynyt + Käytä emotea + Kopioi + Avaa emote-linkki + Emote-kuva + Twitch-emote + Kanavan FFZ-emote + Globaali FFZ-emote + Kanavan BTTV-emote + Jaettu BTTV-emote + Globaali BTTV-emote + Kanavan 7TV-emote + Globaali 7TV-emote + Alias emotelle %1$s + Tekijä: %1$s + (Nollaleveys) + Emote kopioitu + DankChat on päivitetty! + Uutta versiossa v%1$s: + Vahvista kirjautumisen peruutus + Haluatko varmasti peruuttaa kirjautumisen? + Peruuta kirjautuminen + Loitonna + Lähennä + Takaisin + Jaettu chat + + Livenä %1$d katsojalla %2$s ajan + Livenä %1$d katsojalla %2$s ajan + + + %d kuukausi + %d kuukautta + + Avoimen lähdekoodin lisenssit + + Livenä %1$d katsojalla kategoriassa %2$s %3$s ajan + Livenä %1$d katsojalla kategoriassa %2$s %3$s ajan + + Näytä lähetyksen kategoria + Näyttää myös lähetyksen kategorian + Vaihda syöttö + Lähettäjä + Ylläpitäjä + Henkilökunta + Moderaattori + Päämoderattori + Vahvistettu + VIP + Perustaja + Tilaaja + Valitse mukautettu korostusväri + Oletus + Valitse väri + + + Viesti pidätetty syystä: %1$s. Salliminen julkaisee sen chatissa. + Salli + Hylkää + Hyväksytty + Hylätty + Vanhentunut + Hei! Viestisi on modien tarkistettavana eikä sitä ole vielä lähetetty. + Modit ovat hyväksyneet viestisi. + Modit ovat hylänneet viestisi. + %1$s (taso %2$d) + + vastaa %1$d estettyä termiä %2$s + vastaa %1$d estettyä termiä %2$s + + AutoMod-viestin %1$s epäonnistui - viesti on jo käsitelty. + AutoMod-viestin %1$s epäonnistui - sinun täytyy kirjautua uudelleen. + AutoMod-viestin %1$s epäonnistui - sinulla ei ole oikeutta suorittaa tätä toimintoa. + AutoMod-viestin %1$s epäonnistui - kohdeviestiä ei löytynyt. + AutoMod-viestin %1$s epäonnistui - tuntematon virhe tapahtui. + %1$s lisäsi %2$s estetyksi termiksi AutoModissa. + %1$s lisäsi %2$s sallituksi termiksi AutoModissa. + %1$s poisti %2$s estettynä terminä AutoModista. + %1$s poisti %2$s sallittuna terminä AutoModista. + + + Sinut asetettiin jäähylle %1$s ajaksi + Sinut asetettiin jäähylle %1$s ajaksi käyttäjän %2$s toimesta + Sinut asetettiin jäähylle %1$s ajaksi käyttäjän %2$s toimesta: %3$s + %1$s asetti käyttäjän %2$s jäähylle %3$s ajaksi + %1$s asetti käyttäjän %2$s jäähylle %3$s ajaksi: %4$s + %1$s on asetettu jäähylle %2$s ajaksi + Sinut estettiin + Sinut estettiin käyttäjän %1$s toimesta + Sinut estettiin käyttäjän %1$s toimesta: %2$s + %1$s esti käyttäjän %2$s + %1$s esti käyttäjän %2$s: %3$s + %1$s on estetty pysyvästi + %1$s poisti jäähyn käyttäjältä %2$s + %1$s poisti eston käyttäjältä %2$s + %1$s ylenti käyttäjän %2$s moderaattoriksi + %1$s poisti moderaattorin käyttäjältä %2$s + %1$s lisäsi käyttäjän %2$s tämän kanavan VIP-jäseneksi + %1$s poisti käyttäjän %2$s tämän kanavan VIP-jäsenyydestä + %1$s varoitti käyttäjää %2$s + %1$s varoitti käyttäjää %2$s: %3$s + %1$s aloitti raidin kanavalle %2$s + %1$s peruutti raidin kanavalle %2$s + %1$s poisti viestin käyttäjältä %2$s + %1$s poisti viestin käyttäjältä %2$s sanoen: %3$s + Viesti käyttäjältä %1$s poistettiin + Viesti käyttäjältä %1$s poistettiin sanoen: %2$s + %1$s tyhjesi chatin + Moderaattori on tyhjentänyt chatin + %1$s otti käyttöön vain hymiöt -tilan + %1$s poisti käytöstä vain hymiöt -tilan + %1$s otti käyttöön vain seuraajat -tilan + %1$s otti käyttöön vain seuraajat -tilan (%2$s) + %1$s poisti käytöstä vain seuraajat -tilan + %1$s otti käyttöön ainutlaatuinen chat -tilan + %1$s poisti käytöstä ainutlaatuinen chat -tilan + %1$s otti käyttöön hitaan tilan + %1$s otti käyttöön hitaan tilan (%2$s) + %1$s poisti käytöstä hitaan tilan + %1$s otti käyttöön vain tilaajat -tilan + %1$s poisti käytöstä vain tilaajat -tilan + %1$s asetti käyttäjän %2$s jäähylle %3$s ajaksi kanavalla %4$s + %1$s asetti käyttäjän %2$s jäähylle %3$s ajaksi kanavalla %4$s: %5$s + %1$s poisti jäähyn käyttäjältä %2$s kanavalla %3$s + %1$s esti käyttäjän %2$s kanavalla %3$s + %1$s esti käyttäjän %2$s kanavalla %3$s: %4$s + %1$s poisti eston käyttäjältä %2$s kanavalla %3$s + %1$s poisti viestin käyttäjältä %2$s kanavalla %3$s + %1$s poisti viestin käyttäjältä %2$s kanavalla %3$s sanoen: %4$s + %1$s%2$s + + \u0020(%1$d kerta) + \u0020(%1$d kertaa) + + + + Askelpalautin + Lähetä kuiskaus + Kuiskaus käyttäjälle @%1$s + Uusi kuiskaus + Lähetä kuiskaus käyttäjälle + Käyttäjänimi + Lähetä + + + Vain hymiöt + Vain tilaajat + Hidas tila + Hidas tila (%1$s) + Ainutlaatuinen chat (R9K) + Vain seuraajat + Vain seuraajat (%1$s) + Mukautettu + Mikä tahansa + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Ota suojatila käyttöön? + Tämä ottaa käyttöön kanavan esimääritetyt turvallisuusasetukset, jotka voivat sisältää chatin rajoituksia, AutoMod-asetuksia ja vahvistusvaatimuksia. + Ota käyttöön Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Lisää kanava aloittaaksesi keskustelun + Ei viimeaikaisia hymiöitä + + + Näytä lähetys + Piilota lähetys + Vain ääni + Poistu äänitilasta + Koko näyttö + Poistu koko näytöstä + Piilota syöttö + Näytä syöttö + Kanavien pyyhkäisynavigaatio + Vaihda kanavaa pyyhkäisemällä chatissa + Kanavan moderointi + + + Hae viestejä + Viimeisin viesti + Vaihda lähetys + Kanavan moderointi + Koko näyttö + Piilota syöttö + Muokkaa toimintoja + Virheenkorjaus + + Enintään %1$d toiminto + Enintään %1$d toimintoa + + + + DankChat + Aloitetaan käyttöönotto. + Kirjaudu Twitchillä + Kirjaudu sisään lähettääksesi viestejä, käyttääksesi hymiöitäsi, vastaanottaaksesi kuiskauksia ja avataksesi kaikki ominaisuudet. + Sinua pyydetään myöntämään useita Twitch-oikeuksia kerralla, joten sinun ei tarvitse valtuuttaa uudelleen eri ominaisuuksia käyttäessäsi. DankChat suorittaa moderointi- ja lähetystoimintoja vain silloin, kun pyydät sitä. + Kirjaudu Twitchillä + Kirjautuminen onnistui + Ilmoitukset + DankChat voi ilmoittaa sinulle, kun joku mainitsee sinut chatissa sovelluksen ollessa taustalla. + Salli ilmoitukset + Avaa ilmoitusasetukset + Ilman ilmoituksia et tiedä, kun joku mainitsee sinut chatissa sovelluksen ollessa taustalla. + Viestihistoria + DankChat lataa historiallisia viestejä kolmannen osapuolen palvelusta käynnistyksen yhteydessä. Viestien hakemiseksi DankChat lähettää avattujen kanavien nimet tälle palvelulle. Palvelu tallentaa tilapäisesti vierailtujen kanavien viestejä.\n\nVoit muuttaa tätä myöhemmin asetuksista tai lukea lisää osoitteessa https://recent-messages.robotty.de/ + Ota käyttöön + Poista käytöstä + Jatka + Aloita + Ohita + + + Mukautettavat toiminnot hakuun, suoratoistoihin ja muuhun nopeaan pääsyyn + Napauta tästä lisätoimintoja ja toimintopalkin määrittämistä varten + Voit mukauttaa mitkä toiminnot näkyvät toimintopalkissasi täältä + Pyyhkäise alas syöttökentällä piilottaaksesi sen nopeasti + Napauta tästä palauttaaksesi syöttökentän + Seuraava + Selvä + Ohita esittely + Voit lisätä lisää kanavia täältä + + + Yleiset + Todennus + Ota Twitch EventSub käyttöön + Käyttää EventSubia reaaliaikaisiin tapahtumiin vanhentuneen PubSubin sijaan + Ota EventSub-virheenkorjaustuloste käyttöön + Tulostaa EventSubiin liittyvää virheenkorjaustietoa järjestelmäviesteinä + Peruuta tunnus ja käynnistä uudelleen + Mitätöi nykyisen tunnuksen ja käynnistää sovelluksen uudelleen + Ei kirjautunut sisään + Kanavan tunnusta ei voitu selvittää kanavalle %1$s + Viestiä ei lähetetty + Viesti hylätty: %1$s (%2$s) + Puuttuva user:write:chat-oikeus, kirjaudu uudelleen + Ei oikeutta lähettää viestejä tällä kanavalla + Viesti on liian suuri + Nopeusrajoitus, yritä hetken kuluttua uudelleen + Lähetys epäonnistui: %1$s + + + Sinun täytyy olla kirjautuneena käyttääksesi komentoa %1$s + Käyttäjää tällä nimellä ei löytynyt. + Tuntematon virhe tapahtui. + Sinulla ei ole oikeutta suorittaa tätä toimintoa. + Puuttuva vaadittu oikeus. Kirjaudu uudelleen tililläsi ja yritä uudelleen. + Puuttuvat kirjautumistiedot. Kirjaudu uudelleen tililläsi ja yritä uudelleen. + Käyttö: /block <käyttäjä> + Estit onnistuneesti käyttäjän %1$s + Käyttäjää %1$s ei voitu estää, tällä nimellä ei löytynyt käyttäjää! + Käyttäjää %1$s ei voitu estää, tuntematon virhe tapahtui! + Käyttö: /unblock <käyttäjä> + Poistit onnistuneesti käyttäjän %1$s eston + Käyttäjän %1$s estoa ei voitu poistaa, tällä nimellä ei löytynyt käyttäjää! + Käyttäjän %1$s estoa ei voitu poistaa, tuntematon virhe tapahtui! + Kanava ei ole live-tilassa. + Lähetysaika: %1$s + Käytettävissäsi olevat komennot tässä huoneessa: %1$s + Käyttö: %1$s <käyttäjänimi> <viesti>. + Kuiskaus lähetetty. + Kuiskauksen lähetys epäonnistui - %1$s + Käyttö: %1$s <viesti> - Kiinnitä huomiota viestiisi korostuksella. + Ilmoituksen lähetys epäonnistui - %1$s + Tällä kanavalla ei ole moderaattoreita. + Tämän kanavan moderaattorit ovat %1$s. + Moderaattoreiden listaus epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> - Myönnä moderaattorin oikeudet käyttäjälle. + Lisäsit käyttäjän %1$s tämän kanavan moderaattoriksi. + Kanavan moderaattorin lisäys epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> - Poista moderaattorin oikeudet käyttäjältä. + Poistit käyttäjän %1$s tämän kanavan moderaattoreista. + Kanavan moderaattorin poisto epäonnistui - %1$s + Tällä kanavalla ei ole VIP-käyttäjiä. + Tämän kanavan VIP-käyttäjät ovat %1$s. + VIP-käyttäjien listaus epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> - Myönnä VIP-asema käyttäjälle. + Lisäsit käyttäjän %1$s tämän kanavan VIP-käyttäjäksi. + VIP-käyttäjän lisäys epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> - Poista VIP-asema käyttäjältä. + Poistit käyttäjän %1$s tämän kanavan VIP-käyttäjistä. + VIP-käyttäjän poisto epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> [syy] - Estä pysyvästi käyttäjää keskustelemasta. Syy on valinnainen ja näytetään kohdekäyttäjälle ja muille moderaattoreille. Käytä /unban poistaaksesi porttikiellon. + Käyttäjän porttikielto epäonnistui - Et voi antaa porttikieltoa itsellesi. + Käyttäjän porttikielto epäonnistui - Et voi antaa porttikieltoa lähettäjälle. + Käyttäjän porttikielto epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> - Poistaa käyttäjän porttikiellon. + Käyttäjän porttikiellon poisto epäonnistui - %1$s + Käyttö: %1$s <käyttäjänimi> [kesto][aikayksikkö] [syy] - Estä väliaikaisesti käyttäjää keskustelemasta. Kesto (valinnainen, oletus: 10 minuuttia) on positiivinen kokonaisluku; aikayksikkö (valinnainen, oletus: s) on s, m, h, d tai w; enimmäiskesto on 2 viikkoa. Syy on valinnainen ja näytetään kohdekäyttäjälle ja muille moderaattoreille. + Käyttäjän porttikielto epäonnistui - Et voi antaa aikakatkaisua itsellesi. + Käyttäjän porttikielto epäonnistui - Et voi antaa aikakatkaisua lähettäjälle. + Käyttäjän aikakatkaisu epäonnistui - %1$s + Keskusteluviestien poisto epäonnistui - %1$s + Käyttö: /delete <msg-id> - Poistaa määritetyn viestin. + Virheellinen msg-id: \"%1$s\". + Keskusteluviestien poisto epäonnistui - %1$s + Käyttö: /color <väri> - Värin on oltava yksi Twitchin tuetuista väreistä (%1$s) tai hex-koodi (#000000), jos sinulla on Turbo tai Prime. + Värisi vaihdettiin väriksi %1$s + Värin vaihto väriksi %1$s epäonnistui - %2$s + Lähetysmerkki lisätty onnistuneesti kohtaan %1$s%2$s. + Lähetysmerkin luonti epäonnistui - %1$s + Käyttö: /commercial <pituus> - Aloittaa mainoksen määritetyllä kestolla nykyisellä kanavalla. Kelvolliset pituudet ovat 30, 60, 90, 120, 150 ja 180 sekuntia. + + Aloitetaan %1$d sekunnin mainostauko. Muista, että olet edelleen live-tilassa eivätkä kaikki katsojat näe mainosta. Voit ajaa seuraavan mainoksen %2$d sekunnin kuluttua. + Aloitetaan %1$d sekunnin mainostauko. Muista, että olet edelleen live-tilassa eivätkä kaikki katsojat näe mainosta. Voit ajaa seuraavan mainoksen %2$d sekunnin kuluttua. + + Mainoksen aloitus epäonnistui - %1$s + Käyttö: /raid <käyttäjänimi> - Tee raid käyttäjälle. Vain lähettäjä voi aloittaa raidin. + Virheellinen käyttäjänimi: %1$s + Aloitit raidin käyttäjälle %1$s. + Raidin aloitus epäonnistui - %1$s + Peruit raidin. + Raidin peruutus epäonnistui - %1$s + Käyttö: %1$s [kesto] - Ottaa käyttöön vain seuraajat -tilan (vain seuraajat voivat keskustella). Kesto (valinnainen, oletus: 0 minuuttia) on positiivinen luku ja aikayksikkö (m, h, d, w); enimmäiskesto on 3 kuukautta. + Tämä huone on jo %1$s vain seuraajat -tilassa. + Keskusteluasetusten päivitys epäonnistui - %1$s + Tämä huone ei ole vain seuraajat -tilassa. + Tämä huone on jo vain emote -tilassa. + Tämä huone ei ole vain emote -tilassa. + Tämä huone on jo vain tilaajat -tilassa. + Tämä huone ei ole vain tilaajat -tilassa. + Tämä huone on jo ainutlaatuinen keskustelu -tilassa. + Tämä huone ei ole ainutlaatuinen keskustelu -tilassa. + Käyttö: %1$s [kesto] - Ottaa käyttöön hitaan tilan (rajoittaa viestien lähetystiheyttä). Kesto (valinnainen, oletus: 30) on positiivinen sekuntimäärä; enintään 120. + Tämä huone on jo %1$d sekunnin hitaassa tilassa. + Tämä huone ei ole hitaassa tilassa. + Käyttö: %1$s <käyttäjänimi> - Lähettää shoutoutin määritetylle Twitch-käyttäjälle. + Shoutout lähetetty käyttäjälle %1$s + Shoutoutin lähetys epäonnistui - %1$s + Suojatila aktivoitiin. + Suojatila poistettiin käytöstä. + Suojatilan päivitys epäonnistui - %1$s + Et voi kuiskata itsellesi. + Twitchin rajoitusten vuoksi kuiskausten lähettämiseen vaaditaan vahvistettu puhelinnumero. Voit lisätä puhelinnumeron Twitchin asetuksissa. https://www.twitch.tv/settings/security + Vastaanottaja ei salli kuiskauksia tuntemattomilta tai suoraan sinulta. + Twitch rajoittaa nopeuttasi. Yritä uudelleen muutaman sekunnin kuluttua. + Voit kuiskata enintään 40 eri vastaanottajalle päivässä. Päivärajan sisällä voit lähettää enintään 3 kuiskausta sekunnissa ja 100 kuiskausta minuutissa. + Twitchin rajoitusten vuoksi tätä komentoa voi käyttää vain lähettäjä. Käytä sen sijaan Twitchin verkkosivustoa. + %1$s on jo tämän kanavan moderaattori. + %1$s on tällä hetkellä VIP, käytä /unvip ja yritä tätä komentoa uudelleen. + %1$s ei ole tämän kanavan moderaattori. + %1$s ei ole porttikiellossa tällä kanavalla. + %1$s on jo porttikiellossa tällä kanavalla. + Et voi %1$s %2$s. + Tällä käyttäjällä oli ristiriitainen porttikieltotoiminto. Yritä uudelleen. + Värin on oltava yksi Twitchin tuetuista väreistä (%1$s) tai hex-koodi (#000000), jos sinulla on Turbo tai Prime. + Mainosten ajamiseen sinun on oltava live-lähetyksessä. + Sinun on odotettava jäähdytysjakson päättymistä ennen kuin voit ajaa uuden mainoksen. + Komennon on sisällettävä haluttu mainostauon pituus, joka on suurempi kuin nolla. + Sinulla ei ole aktiivista raidia. + Kanava ei voi raidata itseään. + Lähettäjä ei voi antaa shoutoutia itselleen. + Lähettäjä ei ole live-lähetyksessä tai hänellä ei ole yhtä tai useampaa katsojaa. + Kesto on kelvollisen alueen ulkopuolella: %1$s. + Viesti on jo käsitelty. + Kohdeviestiä ei löytynyt. + Viestisi oli liian pitkä. + Nopeuttasi rajoitetaan. Yritä uudelleen hetken kuluttua. + Kohdekäyttäjä + Lokien katselin + Näytä sovelluksen lokit + Lokit + Jaa lokit + Näytä lokit + Lokitiedostoja ei ole saatavilla + Hae lokeista + + %1$d valittu + %1$d valittu + + Kopioi valitut lokit + Tyhjennä valinta + Kaatuminen havaittu + Sovellus kaatui viimeisen istunnon aikana. + Säie: %1$s + Kopioi + Raportti chatissa + Liittyy kanavalle #flex3rs ja valmistelee kaatumisyhteenvedon lähetettäväksi + Sähköpostiraportti + Lähetä yksityiskohtainen kaatumisraportti sähköpostilla + Lähetä kaatumisraportti sähköpostilla + Seuraavat tiedot sisällytetään raporttiin: + Pinojälki + Sisällytä nykyinen lokitiedosto + Kaatumisraportit + Näytä viimeisimmät kaatumisraportit + Kaatumisraportteja ei löytynyt + Kaatumisraportti + Jaa kaatumisraportti + Poista + Poistetaanko tämä kaatumisraportti? + Tyhjennä kaikki + Poistetaanko kaikki kaatumisraportit? + Vieritä alas + Näytä historia + Ei viestejä nykyisillä suodattimilla diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 95cf40614..6484999b1 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -48,20 +48,71 @@ Déconnecté Non authentifié Répondre - Vous avez de nouvelles mentions + Send announcement Vous avez de nouvelles mentions %1$s vous a mentionné dans #%2$s Vous avez été mentionné dans #%1$s Authentification en tant que %1$s Echec de l\'authentification Copié: %1$s + Téléversement terminé : %1$s Erreur pendant l\'envoi Erreur pendant l\'envoi: %1$s + Téléverser + Copié dans le presse-papiers + Copier l\'URL Réessayer Emotes rechargées Echec du chargement des données: %1$s Le chargement des données a échoué avec plusieurs erreurs :\n%1$s + Badges DankChat + Badges globaux + Emotes FFZ globaux + Emotes BTTV globaux + Emotes 7TV globaux + Badges de chaîne + Emotes FFZ + Emotes BTTV + Emotes 7TV + Emotes Twitch + Cheermotes + Messages récents + %1$s (%2$s) + + Premier message + Message mis en avant + Emote géant + Message animé + Échangé %1$s + + %1$d seconde + %1$d secondes + %1$d secondes + + + %1$d minute + %1$d minutes + %1$d minutes + + + %1$d heure + %1$d heures + %1$d heures + + + %1$d jour + %1$d jours + %1$d jours + + + %1$d semaine + %1$d semaines + %1$d semaines + + %1$s %2$s + %1$s %2$s %3$s Coller Nom de la chaîne + La chaîne est déjà ajoutée Récentes Subs Chaîne @@ -157,6 +208,8 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Ajouter une commande Supprimer la commande Déclencheur + Ce déclencheur est réservé par une commande intégrée + Ce déclencheur est déjà utilisé par une autre commande Commande Commandes personnalisées Signaler @@ -196,12 +249,52 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Général À propos Apparence + Suggestions + Messages + Utilisateurs + Emotes & Badges DankChat %1$s créé par @flex3rs et d\'autres contributeurs Afficher la boite d\'entrée Affiche la boite pour envoyer des messages Suivre le système par défaut - Thème sombre authentique - Forcer la couleur de l\'arrière-plan du chat en noir + Mode sombre AMOLED + Arrière-plans noirs purs pour les écrans OLED + Couleur d\'accentuation + Suivre le fond d\'écran du système + Bleu + Sarcelle + Vert + Citron vert + Jaune + Orange + Rouge + Rose + Violet + Indigo + Marron + Gris + Style de couleur + Système par défaut + Utiliser la palette de couleurs système par défaut + Tonal Spot + Couleurs calmes et atténuées + Neutral + Presque monochrome, teinte subtile + Vibrant + Couleurs vives et saturées + Expressive + Couleurs ludiques aux teintes décalées + Rainbow + Large spectre de teintes + Fruit Salad + Palette ludique et multicolore + Monochrome + Noir, blanc et gris uniquement + Fidelity + Fidèle à la couleur d\'accentuation + Content + Couleur d\'accentuation avec tertiaire analogue + Plus de styles Afficher Fonctionnalités Afficher les messages supprimés @@ -213,8 +306,19 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Petit Grand Très grand - Suggestions d\'émoticônes et d\'utilisateurs - Propose des suggestions de nom d\'émotes lorsque vous tappez + Suggestions + Choisir les suggestions à afficher lors de la saisie + Emotes + Utilisateurs + Commandes Twitch + Commandes Supibot + Activer avec : + Activer avec @ + Activer avec / + Activer avec $ + Mode de suggestion + Suggérer des correspondances en tapant + Suggérer uniquement après un caractère déclencheur Charger les anciens messages au démarrage Charger l\'historique des messages après une reconnexion Tente de récupérer les messages manquants qui n\'ont pas été reçus pendant la connexion @@ -224,7 +328,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Données de la chaîne Options pour les développeurs Mode débug - Affiche des infos pour chaque exception interceptée + Afficher l\'action d\'analyse de débogage dans la barre de saisie et collecter les rapports de crash localement Format de l\'horodatage Activer le TTS Lis les messages à voix haute pour la chaîne actuelle @@ -238,6 +342,9 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Ignorer les URL Ignorer les emotes et les émojis en TTS Ignorer les emotes + Volume + Atténuation audio + Réduire le volume des autres applications pendant la lecture TTS TTS Couleurs de lignes alternées Sépare chaque ligne par un fond de luminosité différente @@ -250,12 +357,19 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Comportement lors d\'un long clic sur un utilisateur Un clic normal ouvre un popup, un clic long mentionne l\'utilisateur Un clic normal mentionne l\'utilisateur, un clic long ouvre un popup + Coloriser les pseudos + Attribuer une couleur aléatoire aux utilisateurs sans couleur définie Forcer la langue Anglaise Forcer le TTS à utiliser la langue Anglaise au lieu de la langue système Emotes d\'extension tiers visibles Conditions d\'utilisation de Twitch & politique d\'utilisation : Bouton d\'action flottant Affiche le bouton d\'action flottant pour activer/désactiver le plein écran, le live et ajuster les modes de discussion + Afficher le compteur de caractères + Affiche le nombre de points de code dans le champ de saisie + Afficher le bouton d\'effacement de la saisie + Afficher le bouton d\'envoi + Saisie Mise en ligne de fichiers Configurer la mise en ligne de fichiers personnalisé Fichiers récents @@ -284,6 +398,9 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Connexion personnalisée Contournez la commande Twitch Désactive l\'interception des commandes Twitch et les envoie dans le chat à la place + Protocole d\'envoi du chat + Utiliser Helix API pour l\'envoi + Envoyer les messages de chat via Twitch Helix API au lieu d\'IRC Mises à jour des emotes 7TV en direct Comportement de l\'arrière-plan des mises à jour des emotes en direct Les mises à jour s\'arrêtent après %1$s.\nRéduire ce nombre peut augmenter la durée de vie de la batterie. @@ -328,6 +445,7 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Votre nom d\'utilisateur Abonnements et Événements Annonces + Séries de visionnage Premier message Message mis en avant Message mis en avant avec des points de chaîne @@ -353,9 +471,22 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Copier le message Copier le message complet Réponse à un message + Répondre au message original Afficher le fil de discussion Copier l\'ID du message Plus… + Aller au message + Le message n\'est plus dans l\'historique du chat + Historique des messages + Historique global + Historique : %1$s + Rechercher des messages… + Filtrer par nom d\'utilisateur + Messages contenant des liens + Messages contenant des emotes + Filtrer par nom de badge + Utilisateur + Badge Répondre à @%1$s Sujet de réponse introuvable Message non trouvé @@ -386,18 +517,410 @@ Le service stocke temporairement les messages pour les chaînes que vous (et les Chat partagé En direct avec %1$d spectateur pendant %2$s + En direct avec %1$d spectateurs pendant %2$s En direct avec %1$d spectateurs pendant %2$s %d mois + %d mois %d mois Licences open source En direct avec %1$d spectateur sur %2$s depuis %3$s + En direct avec %1$d spectateurs sur %2$s depuis %3$s En direct avec %1$d spectateurs sur %2$s depuis %3$s Montrer la catégorie du live Montrer également la catégorie du live Activer/désactiver la saisie + Activer/désactiver la barre d\'application + Erreur : %s + Se déconnecter ? + Supprimer cette chaîne ? + Supprimer la chaîne \"%1$s\" ? + Bloquer la chaîne \"%1$s\" ? + Bannir cet utilisateur ? + Supprimer ce message ? + Effacer le chat ? + Actions personnalisables pour un accès rapide à la recherche, aux streams et plus encore + Appuyez ici pour plus d\'actions et pour configurer votre barre d\'actions + Vous pouvez personnaliser les actions qui apparaissent dans votre barre d\'actions ici + Balayez vers le bas sur la saisie pour la masquer rapidement + Appuyez ici pour récupérer la saisie + Suivant + Compris + Passer la visite + Vous pouvez ajouter plus de chaînes ici + + + Message retenu pour la raison : %1$s. Autoriser le publiera dans le chat. + Autoriser + Refuser + Approuvé + Refusé + Expiré + Hey ! Ton message est en cours de vérification par les mods et n\'a pas encore été envoyé. + Les mods ont accepté ton message. + Les mods ont refusé ton message. + %1$s (niveau %2$d) + + correspond à %1$d terme bloqué %2$s + correspond à %1$d termes bloqués %2$s + correspond à %1$d termes bloqués %2$s + + Échec de %1$s le message AutoMod - le message a déjà été traité. + Échec de %1$s le message AutoMod - vous devez vous réauthentifier. + Échec de %1$s le message AutoMod - vous n\'avez pas la permission d\'effectuer cette action. + Échec de %1$s le message AutoMod - message cible introuvable. + Échec de %1$s le message AutoMod - une erreur inconnue s\'est produite. + %1$s a ajouté %2$s comme terme bloqué sur AutoMod. + %1$s a ajouté %2$s comme terme autorisé sur AutoMod. + %1$s a supprimé %2$s comme terme bloqué d\'AutoMod. + %1$s a supprimé %2$s comme terme autorisé d\'AutoMod. + + + Vous avez été exclu temporairement pour %1$s + Vous avez été exclu temporairement pour %1$s par %2$s + Vous avez été exclu temporairement pour %1$s par %2$s : %3$s + %1$s a exclu temporairement %2$s pour %3$s + %1$s a exclu temporairement %2$s pour %3$s : %4$s + %1$s a été exclu temporairement pour %2$s + Vous avez été banni + Vous avez été banni par %1$s + Vous avez été banni par %1$s : %2$s + %1$s a banni %2$s + %1$s a banni %2$s : %3$s + %1$s a été banni définitivement + %1$s a levé l\'exclusion temporaire de %2$s + %1$s a débanni %2$s + %1$s a nommé %2$s modérateur + %1$s a retiré %2$s des modérateurs + %1$s a ajouté %2$s comme VIP de cette chaîne + %1$s a retiré %2$s comme VIP de cette chaîne + %1$s a averti %2$s + %1$s a averti %2$s : %3$s + %1$s a lancé un raid vers %2$s + %1$s a annulé le raid vers %2$s + %1$s a supprimé un message de %2$s + %1$s a supprimé un message de %2$s disant : %3$s + Un message de %1$s a été supprimé + Un message de %1$s a été supprimé disant : %2$s + %1$s a vidé le chat + Le chat a été vidé par un modérateur + %1$s a activé le mode emote-only + %1$s a désactivé le mode emote-only + %1$s a activé le mode followers-only + %1$s a activé le mode followers-only (%2$s) + %1$s a désactivé le mode followers-only + %1$s a activé le mode unique-chat + %1$s a désactivé le mode unique-chat + %1$s a activé le mode slow + %1$s a activé le mode slow (%2$s) + %1$s a désactivé le mode slow + %1$s a activé le mode subscribers-only + %1$s a désactivé le mode subscribers-only + %1$s a exclu temporairement %2$s pour %3$s dans %4$s + %1$s a exclu temporairement %2$s pour %3$s dans %4$s : %5$s + %1$s a levé l\'exclusion temporaire de %2$s dans %3$s + %1$s a banni %2$s dans %3$s + %1$s a banni %2$s dans %3$s : %4$s + %1$s a débanni %2$s dans %3$s + %1$s a supprimé un message de %2$s dans %3$s + %1$s a supprimé un message de %2$s dans %3$s disant : %4$s + %1$s%2$s + + \u0020(%1$d fois) + \u0020(%1$d fois) + \u0020(%1$d fois) + + + + Retour arrière + Envoyer un chuchotement + Chuchotement à @%1$s + Nouveau chuchotement + Envoyer un chuchotement à + Nom d\'utilisateur + Envoyer + + + Emotes uniquement + Abonnés uniquement + Mode lent + Mode lent (%1$s) + Chat unique (R9K) + Abonnés uniquement + Abonnés uniquement (%1$s) + Personnalisé + Tous + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Activer le mode bouclier ? + Cela appliquera les paramètres de sécurité préconfigurés du canal, pouvant inclure des restrictions de chat, des paramètres AutoMod et des exigences de vérification. + Activer Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Ajoutez une chaîne pour commencer à discuter + Aucun emote récent + + + Afficher le stream + Masquer le stream + Audio uniquement + Quitter le mode audio + Plein écran + Quitter le plein écran + Masquer la saisie + Afficher la saisie + Navigation par balayage des canaux + Changer de canal en balayant le chat + Modération du canal + + + Rechercher des messages + Dernier message + Basculer le stream + Modération du canal + Plein écran + Masquer la saisie + Configurer les actions + Débogage + + Maximum de %1$d action + Maximum de %1$d actions + Maximum de %1$d actions + + + + DankChat + Configurons tout ensemble. + Connexion avec Twitch + Connectez-vous pour envoyer des messages, utiliser vos emotes, recevoir des chuchotements et débloquer toutes les fonctionnalités. + Il vous sera demandé d\'accorder plusieurs autorisations Twitch en une seule fois afin que vous n\'ayez pas à réautoriser lorsque vous utilisez différentes fonctionnalités. DankChat n\'effectue des actions de modération et de stream que lorsque vous le lui demandez. + Connexion avec Twitch + Connexion réussie + Notifications + DankChat peut vous notifier quand quelqu\'un vous mentionne dans le chat alors que l\'application est en arrière-plan. + Autoriser les notifications + Ouvrir les paramètres de notification + Sans notifications, vous ne saurez pas quand quelqu\'un vous mentionne dans le chat alors que l\'application est en arrière-plan. + Historique des messages + DankChat charge l\'historique des messages depuis un service tiers au démarrage. Pour obtenir les messages, DankChat envoie les noms des chaînes ouvertes à ce service. Le service stocke temporairement les messages des chaînes visitées.\n\nVous pouvez changer cela plus tard dans les paramètres ou en savoir plus sur https://recent-messages.robotty.de/ + Activer + Désactiver + Continuer + Commencer + Passer + + + Général + Authentification + Activer Twitch EventSub + Utilise EventSub pour divers événements en temps réel au lieu de l\'ancien PubSub + Activer la sortie de débogage EventSub + Affiche les informations de débogage liées à EventSub sous forme de messages système + Révoquer le jeton et redémarrer + Invalide le jeton actuel et redémarre l\'application + Non connecté + Impossible de résoudre l\'ID du canal pour %1$s + Le message n\'a pas été envoyé + Message abandonné : %1$s (%2$s) + Permission user:write:chat manquante, veuillez vous reconnecter + Non autorisé à envoyer des messages dans ce canal + Le message est trop volumineux + Limite de débit atteinte, réessayez dans un instant + Échec de l\'envoi : %1$s + + + Vous devez être connecté pour utiliser la commande %1$s + Aucun utilisateur correspondant à ce nom. + Une erreur inconnue s\'est produite. + Vous n\'avez pas la permission d\'effectuer cette action. + Permission requise manquante. Reconnectez-vous avec votre compte et réessayez. + Identifiants de connexion manquants. Reconnectez-vous avec votre compte et réessayez. + Utilisation : /block <utilisateur> + Vous avez bloqué l\'utilisateur %1$s avec succès + L\'utilisateur %1$s n\'a pas pu être bloqué, aucun utilisateur trouvé avec ce nom ! + L\'utilisateur %1$s n\'a pas pu être bloqué, une erreur inconnue s\'est produite ! + Utilisation : /unblock <utilisateur> + Vous avez débloqué l\'utilisateur %1$s avec succès + L\'utilisateur %1$s n\'a pas pu être débloqué, aucun utilisateur trouvé avec ce nom ! + L\'utilisateur %1$s n\'a pas pu être débloqué, une erreur inconnue s\'est produite ! + La chaîne n\'est pas en direct. + Temps de diffusion : %1$s + Commandes disponibles dans ce salon : %1$s + Utilisation : %1$s <nom d\'utilisateur> <message>. + Chuchotement envoyé. + Échec de l\'envoi du chuchotement - %1$s + Utilisation : %1$s <message> - Attirez l\'attention sur votre message avec une mise en évidence. + Échec de l\'envoi de l\'annonce - %1$s + Cette chaîne n\'a aucun modérateur. + Les modérateurs de cette chaîne sont %1$s. + Échec du listage des modérateurs - %1$s + Utilisation : %1$s <nom d\'utilisateur> - Accorde le statut de modérateur à un utilisateur. + Vous avez ajouté %1$s comme modérateur de cette chaîne. + Échec de l\'ajout du modérateur - %1$s + Utilisation : %1$s <nom d\'utilisateur> - Retire le statut de modérateur d\'un utilisateur. + Vous avez retiré %1$s comme modérateur de cette chaîne. + Échec de la suppression du modérateur - %1$s + Cette chaîne n\'a aucun VIP. + Les VIPs de cette chaîne sont %1$s. + Échec du listage des VIPs - %1$s + Utilisation : %1$s <nom d\'utilisateur> - Accorde le statut VIP à un utilisateur. + Vous avez ajouté %1$s comme VIP de cette chaîne. + Échec de l\'ajout du VIP - %1$s + Utilisation : %1$s <nom d\'utilisateur> - Retire le statut VIP d\'un utilisateur. + Vous avez retiré %1$s comme VIP de cette chaîne. + Échec de la suppression du VIP - %1$s + Utilisation : %1$s <nom d\'utilisateur> [raison] - Empêche définitivement un utilisateur de discuter. La raison est facultative et sera affichée à l\'utilisateur ciblé et aux autres modérateurs. Utilisez /unban pour lever un bannissement. + Échec du bannissement - Vous ne pouvez pas vous bannir vous-même. + Échec du bannissement - Vous ne pouvez pas bannir le broadcaster. + Échec du bannissement de l\'utilisateur - %1$s + Utilisation : %1$s <nom d\'utilisateur> - Lève le bannissement d\'un utilisateur. + Échec du débannissement de l\'utilisateur - %1$s + Utilisation : %1$s <nom d\'utilisateur> [durée][unité de temps] [raison] - Empêche temporairement un utilisateur de discuter. La durée (facultative, par défaut : 10 minutes) doit être un entier positif ; l\'unité de temps (facultative, par défaut : s) doit être s, m, h, d ou w ; la durée maximale est de 2 semaines. La raison est facultative et sera affichée à l\'utilisateur ciblé et aux autres modérateurs. + Échec du bannissement - Vous ne pouvez pas vous mettre en timeout vous-même. + Échec du bannissement - Vous ne pouvez pas mettre le broadcaster en timeout. + Échec du timeout de l\'utilisateur - %1$s + Échec de la suppression des messages du chat - %1$s + Utilisation : /delete <msg-id> - Supprime le message spécifié. + msg-id invalide : \"%1$s\". + Échec de la suppression des messages du chat - %1$s + Utilisation : /color <couleur> - La couleur doit être l\'une des couleurs prises en charge par Twitch (%1$s) ou un hex code (#000000) si vous avez Turbo ou Prime. + Votre couleur a été changée en %1$s + Échec du changement de couleur en %1$s - %2$s + Marqueur de stream ajouté avec succès à %1$s%2$s. + Échec de la création du marqueur de stream - %1$s + Utilisation : /commercial <durée> - Lance une publicité avec la durée spécifiée pour la chaîne actuelle. Les durées valides sont 30, 60, 90, 120, 150 et 180 secondes. + + Lancement d\'une coupure publicitaire de %1$d secondes. N\'oubliez pas que vous êtes toujours en direct et que tous les spectateurs ne recevront pas la publicité. Vous pourrez lancer une autre publicité dans %2$d secondes. + Lancement d\'une coupure publicitaire de %1$d secondes. N\'oubliez pas que vous êtes toujours en direct et que tous les spectateurs ne recevront pas la publicité. Vous pourrez lancer une autre publicité dans %2$d secondes. + Lancement d\'une coupure publicitaire de %1$d secondes. N\'oubliez pas que vous êtes toujours en direct et que tous les spectateurs ne recevront pas la publicité. Vous pourrez lancer une autre publicité dans %2$d secondes. + + Échec du lancement de la publicité - %1$s + Utilisation : /raid <nom d\'utilisateur> - Raide un utilisateur. Seul le broadcaster peut lancer un raid. + Nom d\'utilisateur invalide : %1$s + Vous avez commencé un raid sur %1$s. + Échec du lancement du raid - %1$s + Vous avez annulé le raid. + Échec de l\'annulation du raid - %1$s + Utilisation : %1$s [durée] - Active le mode abonnés uniquement (seuls les abonnés peuvent discuter). La durée (facultative, par défaut : 0 minutes) doit être un nombre positif suivi d\'une unité de temps (m, h, d, w) ; la durée maximale est de 3 mois. + Ce salon est déjà en mode abonnés uniquement de %1$s. + Échec de la mise à jour des paramètres du chat - %1$s + Ce salon n\'est pas en mode abonnés uniquement. + Ce salon est déjà en mode emotes uniquement. + Ce salon n\'est pas en mode emotes uniquement. + Ce salon est déjà en mode abonnés payants uniquement. + Ce salon n\'est pas en mode abonnés payants uniquement. + Ce salon est déjà en mode chat unique. + Ce salon n\'est pas en mode chat unique. + Utilisation : %1$s [durée] - Active le mode lent (limite la fréquence d\'envoi des messages). La durée (facultative, par défaut : 30) doit être un nombre positif de secondes ; maximum 120. + Ce salon est déjà en mode lent de %1$d secondes. + Ce salon n\'est pas en mode lent. + Utilisation : %1$s <nom d\'utilisateur> - Envoie un shoutout à l\'utilisateur Twitch spécifié. + Shoutout envoyé à %1$s + Échec de l\'envoi du shoutout - %1$s + Le mode bouclier a été activé. + Le mode bouclier a été désactivé. + Échec de la mise à jour du mode bouclier - %1$s + Vous ne pouvez pas vous chuchoter à vous-même. + En raison des restrictions de Twitch, vous devez maintenant avoir un numéro de téléphone vérifié pour envoyer des chuchotements. Vous pouvez ajouter un numéro de téléphone dans les paramètres de Twitch. https://www.twitch.tv/settings/security + Le destinataire n\'accepte pas les chuchotements d\'inconnus ou de vous directement. + Vous êtes limité en débit par Twitch. Réessayez dans quelques secondes. + Vous ne pouvez chuchoter qu\'à un maximum de 40 destinataires uniques par jour. Dans cette limite quotidienne, vous pouvez envoyer un maximum de 3 chuchotements par seconde et un maximum de 100 chuchotements par minute. + En raison des restrictions de Twitch, cette commande ne peut être utilisée que par le broadcaster. Veuillez utiliser le site web de Twitch à la place. + %1$s est déjà modérateur de cette chaîne. + %1$s est actuellement VIP, utilisez /unvip puis réessayez cette commande. + %1$s n\'est pas modérateur de cette chaîne. + %1$s n\'est pas banni de cette chaîne. + %1$s est déjà banni de cette chaîne. + Vous ne pouvez pas %1$s %2$s. + Il y a eu une opération de bannissement en conflit sur cet utilisateur. Veuillez réessayer. + La couleur doit être l\'une des couleurs prises en charge par Twitch (%1$s) ou un hex code (#000000) si vous avez Turbo ou Prime. + Vous devez être en direct pour lancer des publicités. + Vous devez attendre la fin de votre période de récupération avant de pouvoir lancer une autre publicité. + La commande doit inclure une durée de coupure publicitaire souhaitée supérieure à zéro. + Vous n\'avez pas de raid actif. + Une chaîne ne peut pas se raider elle-même. + Le broadcaster ne peut pas se donner un Shoutout à lui-même. + Le broadcaster n\'est pas en direct ou n\'a pas un ou plusieurs spectateurs. + La durée est en dehors de la plage valide : %1$s. + Le message a déjà été traité. + Le message ciblé n\'a pas été trouvé. + Votre message était trop long. + Vous êtes limité en débit. Réessayez dans un instant. + L\'utilisateur ciblé + Visionneuse de journaux + Afficher les journaux de l\'application + Journaux + Partager les journaux + Voir les journaux + Aucun fichier journal disponible + Rechercher dans les journaux + + %1$d sélectionnés + %1$d sélectionnés + %1$d sélectionnés + + Copier les journaux sélectionnés + Effacer la sélection + Crash détecté + L\'application a planté lors de votre dernière session. + Thread : %1$s + Copier + Rapport par chat + Rejoint #flex3rs et prépare un résumé du crash à envoyer + Rapport par e-mail + Envoyer un rapport de crash détaillé par e-mail + Envoyer le rapport de crash par e-mail + Les données suivantes seront incluses dans le rapport : + Trace de la pile + Inclure le fichier journal actuel + Rapports de crash + Voir les rapports de crash récents + Aucun rapport de crash trouvé + Rapport de crash + Partager le rapport de crash + Supprimer + Supprimer ce rapport de crash ? + Tout effacer + Supprimer tous les rapports de crash ? + Défiler vers le bas + Badge + Admin + Diffuseur + Fondateur + Modérateur en chef + Modérateur + Staff + Abonné + Vérifié + VIP + Badges + Créez des notifications et mettez en surbrillance les messages des utilisateurs en fonction des badges. + Choisir une couleur + Choisir une couleur de surbrillance personnalisée + Par défaut + Voir l\'historique + Aucun message ne correspond aux filtres actuels diff --git a/app/src/main/res/values-hu-rHU/strings.xml b/app/src/main/res/values-hu-rHU/strings.xml index b53273ada..e1f014a79 100644 --- a/app/src/main/res/values-hu-rHU/strings.xml +++ b/app/src/main/res/values-hu-rHU/strings.xml @@ -48,20 +48,65 @@ Lecsatlakozva Nincs bejelentkezve Válasz - Új említéseid vannak + Send announcement Új említéseid vannak %1$s megemlített itt: #%2$s Megemlítették itt #%1$s Bejelentkezve mint %1$s Nem sikerült bejelentkezni Másolva: %1$s + Feltöltés kész: %1$s Hiba a feltöltés során Hiba a feltöltés során: %1$s + Feltöltés + Vágólapra másolva + URL másolása Újrapróbálkozás Hangulatjelek újratöltve Az adatbetöltés sikertelen: %1$s Az adatbetöltés több hibával is sikertelen:\n%1$s + DankChat jelvények + Globális jelvények + Globális FFZ emoték + Globális BTTV emoték + Globális 7TV emoték + Csatorna jelvények + FFZ emoték + BTTV emoték + 7TV emoték + Twitch emoték + Cheermote-ok + Legutóbbi üzenetek + %1$s (%2$s) + + Első üzenet + Kiemelt üzenet + Óriás emote + Animált üzenet + Beváltva: %1$s + %1$d másodperc + %1$d másodperc + + + %1$d perc + %1$d perc + + + %1$d óra + %1$d óra + + + %1$d nap + %1$d nap + + + %1$d hét + %1$d hét + + %1$s %2$s + %1$s %2$s %3$s Beillesztés Csatorna neve + A csatorna már hozzá van adva Legutóbbi Feliratkozói Csatorna @@ -152,6 +197,8 @@ Parancs hozzáadása Parancs eltávolítása Feltétel + Ez a feltétel egy beépített parancs által van lefoglalva + Ezt a feltételt már egy másik parancs használja Parancs Egyéni parancsok Jelentés @@ -189,14 +236,54 @@ Értesítések Chat Általános + Javaslatok + Üzenetek + Felhasználók + Emoték és jelvények Névjegy Kinézet DankChat %1$s készítette @flex3rs és a hozzájárulók Bemenet mutatása Megjeleníti a bemeneti területet az üzenetek küldésére Rendszer alapértelmezett követése - Igazi sötét téma - Fekete színre kényszeríti a chat hátterét + AMOLED sötét mód + Tiszta fekete háttér OLED kijelzőkhöz + Kiemelőszín + Rendszer háttérkép követése + Kék + Kékeszöld + Zöld + Lime + Sárga + Narancssárga + Piros + Rózsaszín + Lila + Indigó + Barna + Szürke + Színstílus + Rendszer alapértelmezett + A rendszer alapértelmezett színpalettájának használata + Tonal Spot + Nyugodt és visszafogott színek + Neutral + Szinte egyszínű, finom árnyalat + Vibrant + Merész és telített színek + Expressive + Játékos színek eltolt árnyalatokkal + Rainbow + Széles színspektrum + Fruit Salad + Játékos, többszínű paletta + Monochrome + Csak fekete, fehér és szürke + Fidelity + Hű marad a kiemelőszínhez + Content + Kiemelőszín analóg harmadlagos színnel + További stílusok Megjelenés Komponensek Ideiglenesen kitiltott üzenetek mutatása @@ -208,8 +295,19 @@ Kicsi Nagy Nagyon nagy - Hangulatjel és felhasználó javaslatok - Javaslatokat mutat hangulatjelekre és aktív felhasználókra gépelés közben + Javaslatok + Válaszd ki, milyen javaslatokat mutasson gépelés közben + Emoték + Felhasználók + Twitch parancsok + Supibot parancsok + Aktiválás a : karakterrel + Aktiválás a @ karakterrel + Aktiválás a / karakterrel + Aktiválás a $ karakterrel + Javaslat mód + Egyezések javaslása gépelés közben + Javaslat csak trigger karakter után Üzenet előzmények betöltése induláskor Üzenet előzmények betöltése újracsatlakozáskor Kihagyott üzenetek elragadásának próbálkozása amiket lehetett elkapni csatlakozás ingadozás közben @@ -219,7 +317,7 @@ Csatorna adatok Fejlesztői beállítások Hibakeresési mód - Információt biztosít bármilyen kivételre ami el lett kapva + Hibakeresési elemzési művelet megjelenítése a beviteli sávban és összeomlási jelentések helyi gyűjtése Időformátum TTS engedélyezése Üzeneteket olvas fel az aktív csatornáról @@ -233,6 +331,9 @@ URL-ek figyelmen kívül hagyása Hangulatjelek és emojik mellőzése a TTS-ben Hangulatjelek figyelmen kívül hagyása + Hangerő + Hangerő csökkentés + Más alkalmazások hangerejének csökkentése TTS lejátszás közben TTS Kockás vonalak Minden vonal szétválasztása különböző háttér fényerősséggel @@ -245,12 +346,19 @@ Felhasználó hosszú kattintás viselkedése Rendes kattintás felugró ablakot nyit meg, hosszú kattintás megemlít Rendes kattintás megemlít, hosszú kattintás felugró ablakot nyit meg + Beceneveinek színezése + Véletlenszerű szín hozzárendelése a beállított szín nélküli felhasználókhoz Angol nyelv kényszerítése A TTS hang kényszerítése angolra a rendszer alapértelmezett helyett Látható harmadik féltől származó hangulatjelek Twitch szolgáltatási feltételei & felhasználói szabályzata: Chip akciók megjelenítése Megjeleníti a teljes képernyő, a streamek és a csevegési módok beállításához szükséges chipeket + Karakterszámláló megjelenítése + Megjeleníti a kódpontok számát a beviteli mezőben + Beviteli mező törlése gomb megjelenítése + Küldés gomb megjelenítése + Bevitel Média feltöltő Feltöltő konfigurálása Legutóbbi feltőltések @@ -279,6 +387,9 @@ Egyéni bejelentkezés Twitch parancs kitérés kezelése Letiltja a Twitch parancsok elfogását és a chatbe küldi helyette + Chat küldési protokoll + Helix API használata küldéshez + Chat üzenetek küldése Twitch Helix API-n keresztül IRC helyett 7TV élő hangulatjel frissítések Élő hangulatjel frissítések háttér viselkedése A frissítések befejeződnek %1$s után.\nA szám csökkentésével az akkumulátor élettartamát növelheti. @@ -323,6 +434,7 @@ Felhasználóneved Feliratkozások és Események Bejelentések + Nézési sorozatok Első üzenet Kiemelt üzenetek Kiemelések kiváltása csatorna pontokkal @@ -348,9 +460,22 @@ Üzenet másolása Teljes üzenet másolása Válasz az üzenetre + Válasz az eredeti üzenetre Gondolatmenet megtekintése Üzenet id másolása Több… + Ugrás az üzenethez + Az üzenet már nincs a csevegési előzményekben + Üzenetelőzmények + Globális előzmények + Előzmények: %1$s + Üzenetek keresése… + Szűrés felhasználónév szerint + Linkeket tartalmazó üzenetek + Emote-okat tartalmazó üzenetek + Szűrés jelvénynév szerint + Felhasználó + Jelvény Válaszol neki @%1$s Gondolatmenet nem található Üzenet nem található @@ -395,4 +520,388 @@ Stream kategória mutatása Stream kategória megjelenítése Beviteli mező kapcsolása + Alkalmazássáv kapcsolása + Hiba: %s + Kijelentkezés? + Eltávolítod ezt a csatornát? + Eltávolítod a(z) \"%1$s\" csatornát? + Letiltod a(z) \"%1$s\" csatornát? + Kitiltod ezt a felhasználót? + Törlöd ezt az üzenetet? + Chat törlése? + Testreszabható műveletek a keresés, közvetítések és egyebek gyors eléréséhez + Koppintson ide további műveletekért és a műveletsáv beállításához + Itt testreszabhatja, mely műveletek jelenjenek meg a műveletsávban + Húzzon lefelé a beviteli mezőn a gyors elrejtéshez + Koppintson ide a beviteli mező visszaállításához + Következő + Értem + Bemutató kihagyása + Itt adhat hozzá további csatornákat + + + Üzenet visszatartva az alábbi okból: %1$s. Az engedélyezés közzéteszi a chatben. + Engedélyezés + Elutasítás + Jóváhagyva + Elutasítva + Lejárt + Hé! Az üzenetedet a modok ellenőrzik, és még nem lett elküldve. + A modok elfogadták az üzenetedet. + A modok elutasították az üzenetedet. + %1$s (%2$d. szint) + + egyezik %1$d blokkolt kifejezéssel: %2$s + egyezik %1$d blokkolt kifejezéssel: %2$s + + Nem sikerült %1$s az AutoMod üzenetet - az üzenet már feldolgozásra került. + Nem sikerült %1$s az AutoMod üzenetet - újra be kell jelentkezned. + Nem sikerült %1$s az AutoMod üzenetet - nincs jogosultságod ehhez a művelethez. + Nem sikerült %1$s az AutoMod üzenetet - a célüzenet nem található. + Nem sikerült %1$s az AutoMod üzenetet - ismeretlen hiba történt. + %1$s hozzáadta a(z) %2$s kifejezést blokkolt kifejezésként az AutoModhoz. + %1$s hozzáadta a(z) %2$s kifejezést engedélyezett kifejezésként az AutoModhoz. + %1$s eltávolította a(z) %2$s kifejezést blokkolt kifejezésként az AutoModból. + %1$s eltávolította a(z) %2$s kifejezést engedélyezett kifejezésként az AutoModból. + + + Ideiglenesen ki lettél tiltva %1$s időtartamra + Ideiglenesen ki lettél tiltva %1$s időtartamra %2$s által + Ideiglenesen ki lettél tiltva %1$s időtartamra %2$s által: %3$s + %1$s ideiglenesen kitiltotta %2$s felhasználót %3$s időtartamra + %1$s ideiglenesen kitiltotta %2$s felhasználót %3$s időtartamra: %4$s + %1$s ideiglenesen ki lett tiltva %2$s időtartamra + Ki lettél tiltva + Ki lettél tiltva %1$s által + Ki lettél tiltva %1$s által: %2$s + %1$s kitiltotta %2$s felhasználót + %1$s kitiltotta %2$s felhasználót: %3$s + %1$s véglegesen ki lett tiltva + %1$s feloldotta %2$s ideiglenes kitiltását + %1$s feloldotta %2$s kitiltását + %1$s moderátorrá tette %2$s felhasználót + %1$s eltávolította %2$s moderátori jogát + %1$s hozzáadta %2$s felhasználót a csatorna VIP-jeként + %1$s eltávolította %2$s felhasználót a csatorna VIP-jei közül + %1$s figyelmeztette %2$s felhasználót + %1$s figyelmeztette %2$s felhasználót: %3$s + %1$s raidet indított %2$s felé + %1$s visszavonta a raidet %2$s felé + %1$s törölte %2$s üzenetét + %1$s törölte %2$s üzenetét mondván: %3$s + %1$s üzenete törölve lett + %1$s üzenete törölve lett mondván: %2$s + %1$s törölte a chatet + Egy moderátor törölte a chatet + %1$s bekapcsolta a csak hangulatjel módot + %1$s kikapcsolta a csak hangulatjel módot + %1$s bekapcsolta a csak követők módot + %1$s bekapcsolta a csak követők módot (%2$s) + %1$s kikapcsolta a csak követők módot + %1$s bekapcsolta az egyedi chat módot + %1$s kikapcsolta az egyedi chat módot + %1$s bekapcsolta a lassú módot + %1$s bekapcsolta a lassú módot (%2$s) + %1$s kikapcsolta a lassú módot + %1$s bekapcsolta a csak feliratkozók módot + %1$s kikapcsolta a csak feliratkozók módot + %1$s ideiglenesen kitiltotta %2$s felhasználót %3$s időtartamra a(z) %4$s csatornán + %1$s ideiglenesen kitiltotta %2$s felhasználót %3$s időtartamra a(z) %4$s csatornán: %5$s + %1$s feloldotta %2$s ideiglenes kitiltását a(z) %3$s csatornán + %1$s kitiltotta %2$s felhasználót a(z) %3$s csatornán + %1$s kitiltotta %2$s felhasználót a(z) %3$s csatornán: %4$s + %1$s feloldotta %2$s kitiltását a(z) %3$s csatornán + %1$s törölte %2$s üzenetét a(z) %3$s csatornán + %1$s törölte %2$s üzenetét a(z) %3$s csatornán mondván: %4$s + %1$s%2$s + + \u0020(%1$d alkalommal) + \u0020(%1$d alkalommal) + + + + Törlés + Suttogás küldése + Suttogás @%1$s felhasználónak + Új suttogás + Suttogás küldése + Felhasználónév + Küldés + + + Csak emote + Csak feliratkozók + Lassú mód + Lassú mód (%1$s) + Egyedi chat (R9K) + Csak követők + Csak követők (%1$s) + Egyéni + Bármely + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Aktiválja a pajzs módot? + Ez alkalmazza a csatorna előre beállított biztonsági beállításait, amelyek tartalmazhatnak csevegési korlátozásokat, AutoMod beállításokat és ellenőrzési követelményeket. + Aktiválás Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Adj hozzá egy csatornát a csevegéshez + Nincsenek legutóbbi emoték + + + Közvetítés megjelenítése + Közvetítés elrejtése + Csak hang + Kilépés a hang módból + Teljes képernyő + Kilépés a teljes képernyőből + Bevitel elrejtése + Bevitel megjelenítése + Csatorna csúsztatásos navigáció + Csatornák váltása csúsztatással a chaten + Csatorna moderálás + + + Üzenetek keresése + Utolsó üzenet + Közvetítés váltása + Csatorna moderálás + Teljes képernyő + Bevitel elrejtése + Műveletek beállítása + Hibakeresés + + Maximum %1$d művelet + Maximum %1$d művelet + + + + DankChat + Állítsunk be mindent. + Bejelentkezés Twitch-csel + Jelentkezz be üzenetek küldéséhez, emoték használatához, suttogások fogadásához és az összes funkció feloldásához. + Több Twitch-engedély megadására lesz szükség egyszerre, így nem kell újra engedélyezned a különböző funkciók használatakor. A DankChat csak akkor végez moderálási és közvetítési műveleteket, amikor te kéred. + Bejelentkezés Twitch-csel + Sikeres bejelentkezés + Értesítések + A DankChat értesíthet, ha valaki megemlít a chatben, miközben az alkalmazás a háttérben fut. + Értesítések engedélyezése + Értesítési beállítások megnyitása + Értesítések nélkül nem fogod tudni, ha valaki megemlít a chatben, miközben az alkalmazás a háttérben fut. + Üzenetelőzmények + A DankChat induláskor betölti a korábbi üzeneteket egy harmadik féltől származó szolgáltatásból. Az üzenetek lekéréséhez a DankChat elküldi a megnyitott csatornák neveit ennek a szolgáltatásnak. A szolgáltatás ideiglenesen tárolja a meglátogatott csatornák üzeneteit.\n\nEzt később módosíthatod a beállításokban, vagy többet tudhatsz meg a https://recent-messages.robotty.de/ oldalon. + Engedélyezés + Letiltás + Tovább + Kezdjük + Kihagyás + + + Általános + Hitelesítés + Twitch EventSub engedélyezése + EventSub használata valós idejű eseményekhez az elavult PubSub helyett + EventSub hibakeresési kimenet engedélyezése + EventSubhoz kapcsolódó hibakeresési adatokat jelenít meg rendszerüzenetként + Token visszavonása és újraindítás + Érvényteleníti a jelenlegi tokent és újraindítja az alkalmazást + Nincs bejelentkezve + Nem sikerült feloldani a csatorna azonosítót ehhez: %1$s + Az üzenet nem lett elküldve + Üzenet eldobva: %1$s (%2$s) + Hiányzó user:write:chat jogosultság, kérjük jelentkezz be újra + Nincs jogosultságod üzeneteket küldeni ezen a csatornán + Az üzenet túl nagy + Sebességkorlát elérve, próbáld újra egy pillanat múlva + Küldés sikertelen: %1$s + + + Be kell jelentkezned a(z) %1$s parancs használatához + Nem található ilyen nevű felhasználó. + Ismeretlen hiba történt. + Nincs jogosultságod ehhez a művelethez. + Hiányzó jogosultság. Jelentkezz be újra a fiókoddal, és próbáld újra. + Hiányzó bejelentkezési adatok. Jelentkezz be újra a fiókoddal, és próbáld újra. + Használat: /block <felhasználó> + Sikeresen letiltottad %1$s felhasználót + %1$s felhasználót nem sikerült letiltani, nem található ilyen nevű felhasználó! + %1$s felhasználót nem sikerült letiltani, ismeretlen hiba történt! + Használat: /unblock <felhasználó> + Sikeresen feloldottad %1$s felhasználó tiltását + %1$s felhasználó tiltását nem sikerült feloldani, nem található ilyen nevű felhasználó! + %1$s felhasználó tiltását nem sikerült feloldani, ismeretlen hiba történt! + A csatorna nem élő. + Adásidő: %1$s + Ebben a szobában elérhető parancsok: %1$s + Használat: %1$s <felhasználónév> <üzenet>. + Suttogás elküldve. + Nem sikerült elküldeni a suttogást - %1$s + Használat: %1$s <üzenet> - Hívd fel a figyelmet az üzenetedre kiemelésssel. + Nem sikerült elküldeni a bejelentést - %1$s + Ennek a csatornának nincsenek moderátorai. + A csatorna moderátorai: %1$s. + Nem sikerült listázni a moderátorokat - %1$s + Használat: %1$s <felhasználónév> - Moderátori jogot ad egy felhasználónak. + Hozzáadtad %1$s-t a csatorna moderátoraként. + Nem sikerült hozzáadni a csatorna moderátort - %1$s + Használat: %1$s <felhasználónév> - Elveszi a moderátori jogot egy felhasználótól. + Eltávolítottad %1$s-t a csatorna moderátorai közül. + Nem sikerült eltávolítani a csatorna moderátort - %1$s + Ennek a csatornának nincsenek VIP-jei. + A csatorna VIP-jei: %1$s. + Nem sikerült listázni a VIP-eket - %1$s + Használat: %1$s <felhasználónév> - VIP státuszt ad egy felhasználónak. + Hozzáadtad %1$s-t a csatorna VIP-jeként. + Nem sikerült hozzáadni a VIP-et - %1$s + Használat: %1$s <felhasználónév> - Elveszi a VIP státuszt egy felhasználótól. + Eltávolítottad %1$s-t a csatorna VIP-jei közül. + Nem sikerült eltávolítani a VIP-et - %1$s + Használat: %1$s <felhasználónév> [indok] - Véglegesen letiltja a felhasználót a csevegésből. Az indok opcionális, és megjelenik a célfelhasználónak és a többi moderátornak. Használd a /unban parancsot a tiltás feloldásához. + Nem sikerült kitiltani a felhasználót - Nem tilthatod ki saját magadat. + Nem sikerült kitiltani a felhasználót - Nem tilthatod ki a műsorvezetőt. + Nem sikerült kitiltani a felhasználót - %1$s + Használat: %1$s <felhasználónév> - Feloldja a felhasználó tiltását. + Nem sikerült feloldani a felhasználó tiltását - %1$s + Használat: %1$s <felhasználónév> [időtartam][időegység] [indok] - Ideiglenesen letiltja a felhasználót a csevegésből. Az időtartam (opcionális, alapértelmezett: 10 perc) pozitív egész szám legyen; az időegység (opcionális, alapértelmezett: s) s, m, h, d, w egyike legyen; a maximális időtartam 2 hét. Az indok opcionális, és megjelenik a célfelhasználónak és a többi moderátornak. + Nem sikerült kitiltani a felhasználót - Nem adhatsz saját magadnak időkorlátot. + Nem sikerült kitiltani a felhasználót - Nem adhatsz a műsorvezetőnek időkorlátot. + Nem sikerült időkorlátot adni a felhasználónak - %1$s + Nem sikerült törölni a csevegési üzeneteket - %1$s + Használat: /delete <msg-id> - Törli a megadott üzenetet. + Érvénytelen msg-id: \"%1$s\". + Nem sikerült törölni a csevegési üzeneteket - %1$s + Használat: /color <szín> - A szín a Twitch által támogatott színek egyike (%1$s) vagy hex kód (#000000) legyen, ha rendelkezel Turbo vagy Prime előfizetéssel. + A színed megváltozott erre: %1$s + Nem sikerült megváltoztatni a színt erre: %1$s - %2$s + Sikeresen hozzáadva egy adásjelölő itt: %1$s%2$s. + Nem sikerült létrehozni az adásjelölőt - %1$s + Használat: /commercial <hossz> - Reklámot indít a megadott időtartammal az aktuális csatornán. Érvényes hosszok: 30, 60, 90, 120, 150 és 180 másodperc. + + %1$d másodperces reklámszünet indul. Ne feledd, hogy még mindig élőben vagy, és nem minden néző kap reklámot. Újabb reklámot %2$d másodperc múlva indíthatsz. + %1$d másodperces reklámszünet indul. Ne feledd, hogy még mindig élőben vagy, és nem minden néző kap reklámot. Újabb reklámot %2$d másodperc múlva indíthatsz. + + Nem sikerült elindítani a reklámot - %1$s + Használat: /raid <felhasználónév> - Raid egy felhasználóra. Csak a műsorvezető indíthat raidet. + Érvénytelen felhasználónév: %1$s + Raid indítva %1$s felé. + Nem sikerült elindítani a raidet - %1$s + Visszavontad a raidet. + Nem sikerült visszavonni a raidet - %1$s + Használat: %1$s [időtartam] - Bekapcsolja a csak követők módot (csak követők cseveghetnek). Az időtartam (opcionális, alapértelmezett: 0 perc) pozitív szám legyen időegységgel (m, h, d, w); a maximális időtartam 3 hónap. + Ez a szoba már %1$s csak követők módban van. + Nem sikerült frissíteni a csevegési beállításokat - %1$s + Ez a szoba nincs csak követők módban. + Ez a szoba már csak emote módban van. + Ez a szoba nincs csak emote módban. + Ez a szoba már csak feliratkozók módban van. + Ez a szoba nincs csak feliratkozók módban. + Ez a szoba már egyedi csevegés módban van. + Ez a szoba nincs egyedi csevegés módban. + Használat: %1$s [időtartam] - Bekapcsolja a lassú módot (korlátozza az üzenetküldés gyakoriságát). Az időtartam (opcionális, alapértelmezett: 30) pozitív szám legyen másodpercben; maximum 120. + Ez a szoba már %1$d másodperces lassú módban van. + Ez a szoba nincs lassú módban. + Használat: %1$s <felhasználónév> - Shoutoutot küld a megadott Twitch felhasználónak. + Shoutout elküldve %1$s számára + Nem sikerült elküldeni a shoutoutot - %1$s + A pajzs mód aktiválva lett. + A pajzs mód deaktiválva lett. + Nem sikerült frissíteni a pajzs módot - %1$s + Nem suttoghatsz saját magadnak. + A Twitch korlátozásai miatt suttogások küldéséhez hitelesített telefonszámra van szükség. Telefonszámot a Twitch beállításokban adhatsz hozzá. https://www.twitch.tv/settings/security + A címzett nem fogad suttogásokat idegenektől vagy közvetlenül tőled. + A Twitch korlátozza a sebességedet. Próbáld újra néhány másodperc múlva. + Naponta legfeljebb 40 egyedi címzettnek suttoghatsz. A napi limiten belül másodpercenként legfeljebb 3, percenként legfeljebb 100 suttogást küldhetsz. + A Twitch korlátozásai miatt ezt a parancsot csak a műsorvezető használhatja. Kérjük, használd helyette a Twitch weboldalt. + %1$s már moderátora ennek a csatornának. + %1$s jelenleg VIP, használd a /unvip parancsot, és próbáld újra. + %1$s nem moderátora ennek a csatornának. + %1$s nincs kitiltva ebből a csatornából. + %1$s már ki van tiltva ebből a csatornából. + Nem tudod %1$s %2$s. + Ütköző kitiltási művelet történt ennél a felhasználónál. Kérjük, próbáld újra. + A szín a Twitch által támogatott színek egyike (%1$s) vagy hex kód (#000000) legyen, ha rendelkezel Turbo vagy Prime előfizetéssel. + Reklámok futtatásához élőben kell közvetítened. + Meg kell várnod a hűtési időszak lejártát, mielőtt újabb reklámot futtathatnál. + A parancsnak tartalmaznia kell a kívánt reklámszünet hosszát, amelynek nagyobbnak kell lennie nullánál. + Nincs aktív raided. + Egy csatorna nem raidelhet saját magát. + A műsorvezető nem adhat shoutoutot saját magának. + A műsorvezető nem közvetít élőben, vagy nincs egy vagy több nézője. + Az időtartam az érvényes tartományon kívül esik: %1$s. + Az üzenet már feldolgozásra került. + A célüzenet nem található. + Az üzeneted túl hosszú volt. + Sebességkorlát alatt állsz. Próbáld újra egy pillanat múlva. + A célfelhasználó + Naplómegjelenítő + Alkalmazásnaplók megtekintése + Naplók + Naplók megosztása + Naplók megtekintése + Nincsenek elérhető naplófájlok + Keresés a naplókban + + %1$d kiválasztva + %1$d kiválasztva + + Kiválasztott naplók másolása + Kijelölés törlése + Összeomlás észlelve + Az alkalmazás összeomlott az utolsó munkamenet során. + Szál: %1$s + Másolás + Jelentés chaten + Csatlakozik a #flex3rs csatornához és előkészíti az összeomlás összefoglalóját küldésre + Jelentés e-mailben + Részletes összeomlási jelentés küldése e-mailben + Összeomlási jelentés küldése e-mailben + A következő adatok lesznek a jelentésben: + Veremnyom + Aktuális naplófájl csatolása + Összeomlási jelentések + Legutóbbi összeomlási jelentések megtekintése + Nem található összeomlási jelentés + Összeomlási jelentés + Összeomlási jelentés megosztása + Törlés + Törli ezt az összeomlási jelentést? + Összes törlése + Törli az összes összeomlási jelentést? + Görgetés az aljára + Jelvény + Admin + Közvetítő + Alapító + Vezető moderátor + Moderátor + Személyzet + Feliratkozó + Hitelesített + VIP + Jelvények + Értesítések létrehozása és üzenetek kiemelése jelvények alapján. + Szín kiválasztása + Egyéni kiemelési szín kiválasztása + Alapértelmezett + Előzmények megtekintése + Nincs a jelenlegi szűrőknek megfelelő üzenet diff --git a/app/src/main/res/values-in-rID/strings.xml b/app/src/main/res/values-in-rID/strings.xml index 9f769a20e..ef09d7a9e 100644 --- a/app/src/main/res/values-in-rID/strings.xml +++ b/app/src/main/res/values-in-rID/strings.xml @@ -58,4 +58,5 @@ Unggah url Bidang formulir Headers + Riwayat: %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a48e6ca9b..bc356a5ed 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -47,20 +47,71 @@ Disconnesso Non connesso Rispondi - Hai delle nuove menzioni + Send announcement Hai delle nuove menzioni %1$s ti ha appena menzionato in #%2$s Sei stato menzionato in #%1$s Accedendo come %1$s Impossibile accedere Copiato: %1$s + Caricamento completato: %1$s Errore durante il caricamento Errore durante il caricamento: %1$s + Carica + Copiato negli appunti + Copia URL Riprova Emote ricaricate Caricamento dei dati fallito: %1$s Caricamento dei dati fallito con diversi errori:\n%1$s + Badge DankChat + Badge globali + Emote FFZ globali + Emote BTTV globali + Emote 7TV globali + Badge del canale + Emote FFZ + Emote BTTV + Emote 7TV + Emote Twitch + Cheermote + Messaggi recenti + %1$s (%2$s) + + Primo messaggio + Messaggio elevato + Emote gigante + Messaggio animato + Riscattato %1$s + + %1$d secondo + %1$d secondi + %1$d secondi + + + %1$d minuto + %1$d minuti + %1$d minuti + + + %1$d ora + %1$d ore + %1$d ore + + + %1$d giorno + %1$d giorni + %1$d giorni + + + %1$d settimana + %1$d settimane + %1$d settimane + + %1$s %2$s + %1$s %2$s %3$s Incolla Nome del canale + Il canale è già stato aggiunto Recente Abbonati Canale @@ -151,6 +202,8 @@ Aggiungi un comando Rimuovi il comando Innesco + Questo innesco è riservato da un comando integrato + Questo innesco è già utilizzato da un altro comando Comando Comandi personalizzati Segnala @@ -190,12 +243,52 @@ Generale Info Aspetto + Suggerimenti + Messaggi + Utenti + Emote & Badge DankChat %1$s, sviluppata da @flex3rs e collaboratori Mostra input Mostra il campo di inserimento per inviare i messaggi Sistema di follow predefinito - Tema scuro - Forza il colore di sfondo della chat a nero + Modalità scura AMOLED + Sfondi neri puri per schermi OLED + Colore di accento + Segui lo sfondo di sistema + Blu + Verde acqua + Verde + Lime + Giallo + Arancione + Rosso + Rosa + Viola + Indaco + Marrone + Grigio + Stile colore + Predefinito di sistema + Usa la tavolozza colori predefinita del sistema + Tonal Spot + Colori calmi e tenui + Neutral + Quasi monocromatico, sfumatura sottile + Vibrant + Colori vivaci e saturi + Expressive + Colori giocosi con tonalità spostate + Rainbow + Ampio spettro di tonalità + Fruit Salad + Palette giocosa e multicolore + Monochrome + Solo nero, bianco e grigio + Fidelity + Fedele al colore di accento + Content + Colore di accento con terziario analogo + Altri stili Schermo Componenti Mostra messaggi silenziati @@ -207,8 +300,19 @@ Piccolo Grande Molto grande - Suggerimenti emote e utenti - Mostra suggerimenti per emote e utenti attivi, durante la digitazione + Suggerimenti + Scegli quali suggerimenti mostrare durante la digitazione + Emote + Utenti + Comandi Twitch + Comandi Supibot + Attiva con : + Attiva con @ + Attiva con / + Attiva con $ + Modalita di suggerimento + Suggerisci corrispondenze durante la digitazione + Suggerisci solo dopo un carattere di attivazione Carica cronologia messaggi, all\'avvio Carica la cronologia dei messaggi dopo una riconnessone Tenta di recuperare i messaggi persi durante un interruzione della connessione @@ -218,7 +322,7 @@ Dati del canale Opzioni per sviluppatori Modalità di debug - Fornisce informazioni per qualsiasi eccezione rilevata + Mostra l\'azione di analisi di debug nella barra di input e raccogli i report dei crash localmente Formato marca oraria Abilita TTS Legge i messaggi del canale attivo @@ -232,6 +336,9 @@ Ignora URL Ignora emote ed emoji in TTS Ignora emote + Volume + Attenuazione audio + Abbassa il volume delle altre app durante la riproduzione TTS TTS Righe a Scacchiera Separa ogni riga con una diversa luminosità dello sfondo @@ -244,12 +351,19 @@ Comportamento click prolungato utente Click regolare apre popup, click prolungato menziona Click regolare menziona, click prolungato apre popup + Colora i soprannomi + Assegna un colore casuale agli utenti senza un colore impostato Forza lingua in inglese Forza lingua TTS all\'inglese, invece della lingua predefinita di sistema Emote di terze parti visibili Termini di servizio e politica utenti di Twitch: Mostra azioni chip Mostra chip per attivare/disattivare lo schermo intero, live e regolare le modalità della chat + Mostra contatore caratteri + Visualizza il conteggio dei code point nel campo di immissione + Mostra pulsante cancella input + Mostra pulsante invio + Input Caricatore multimediale Configura caricatore Caricamenti recenti @@ -278,6 +392,9 @@ Login personalizzato Bypassa la gestione dei comandi di Twitch Disabilità l\'intercettazione dei comandi di Twitch inviandoli invece in chat + Protocollo di invio della chat + Usa Helix API per l\'invio + Invia messaggi in chat tramite Twitch Helix API invece di IRC Aggiornamenti Emote 7tv live Comportamento degli aggiornamenti in background delle emote live Gli aggiornamenti si fermano dopo %1$s.\nAbbassare questo numero può aumentare la durata della batteria. @@ -322,6 +439,7 @@ Il tuo nome utente Iscrizioni ed Eventi Annunci + Serie di visualizzazione Primi Messaggi Messaggi Elevati Evidenziazioni riscattate con Punti Canale @@ -347,9 +465,22 @@ Copia messaggio Copia il messaggio completo Rispondi al messaggio + Rispondi al messaggio originale Visualizza thread Copia id del messaggio Altro… + Vai al messaggio + Il messaggio non è più nella cronologia della chat + Cronologia messaggi + Cronologia globale + Cronologia: %1$s + Cerca messaggi… + Filtra per nome utente + Messaggi contenenti link + Messaggi contenenti emote + Filtra per nome distintivo + Utente + Distintivo Stai rispondendo a @%1$s Thread risposta non trovato Messaggio non trovato @@ -378,10 +509,412 @@ Ingrandisci Live con %1$d spettatore per %2$s + Live con %1$d spettatori per %2$s Live con %1$d spettatori per %2$s %d mese + %d mesi %d mesi + Mostra/nascondi barra app + Errore: %s + Disconnettersi? + Rimuovere questo canale? + Rimuovere il canale \"%1$s\"? + Bloccare il canale \"%1$s\"? + Bannare questo utente? + Eliminare questo messaggio? + Cancellare la chat? + Azioni personalizzabili per un accesso rapido a ricerca, streaming e altro + Tocca qui per altre azioni e per configurare la barra delle azioni + Puoi personalizzare quali azioni appaiono nella tua barra delle azioni qui + Scorri verso il basso sull\'input per nasconderlo rapidamente + Tocca qui per ripristinare l\'input + Avanti + Capito + Salta il tour + Puoi aggiungere altri canali qui + + + Messaggio trattenuto per motivo: %1$s. Consenti lo pubblicherà nella chat. + Consenti + Nega + Approvato + Negato + Scaduto + Ehi! Il tuo messaggio è in fase di verifica dai mod e non è stato ancora inviato. + I mod hanno accettato il tuo messaggio. + I mod hanno rifiutato il tuo messaggio. + %1$s (livello %2$d) + + corrisponde a %1$d termine bloccato %2$s + corrisponde a %1$d termini bloccati %2$s + corrisponde a %1$d termini bloccati %2$s + + Impossibile %1$s il messaggio AutoMod - il messaggio è già stato elaborato. + Impossibile %1$s il messaggio AutoMod - devi autenticarti di nuovo. + Impossibile %1$s il messaggio AutoMod - non hai il permesso di eseguire questa azione. + Impossibile %1$s il messaggio AutoMod - messaggio di destinazione non trovato. + Impossibile %1$s il messaggio AutoMod - si è verificato un errore sconosciuto. + %1$s ha aggiunto %2$s come termine bloccato su AutoMod. + %1$s ha aggiunto %2$s come termine consentito su AutoMod. + %1$s ha rimosso %2$s come termine bloccato da AutoMod. + %1$s ha rimosso %2$s come termine consentito da AutoMod. + + + Sei stato espulso temporaneamente per %1$s + Sei stato espulso temporaneamente per %1$s da %2$s + Sei stato espulso temporaneamente per %1$s da %2$s: %3$s + %1$s ha espulso temporaneamente %2$s per %3$s + %1$s ha espulso temporaneamente %2$s per %3$s: %4$s + %1$s è stato espulso temporaneamente per %2$s + Sei stato bannato + Sei stato bannato da %1$s + Sei stato bannato da %1$s: %2$s + %1$s ha bannato %2$s + %1$s ha bannato %2$s: %3$s + %1$s è stato bannato permanentemente + %1$s ha rimosso l\'espulsione temporanea di %2$s + %1$s ha sbannato %2$s + %1$s ha nominato %2$s moderatore + %1$s ha rimosso %2$s dai moderatori + %1$s ha aggiunto %2$s come VIP di questo canale + %1$s ha rimosso %2$s come VIP di questo canale + %1$s ha avvertito %2$s + %1$s ha avvertito %2$s: %3$s + %1$s ha avviato un raid verso %2$s + %1$s ha annullato il raid verso %2$s + %1$s ha eliminato un messaggio di %2$s + %1$s ha eliminato un messaggio di %2$s dicendo: %3$s + Un messaggio di %1$s è stato eliminato + Un messaggio di %1$s è stato eliminato dicendo: %2$s + %1$s ha svuotato la chat + La chat è stata svuotata da un moderatore + %1$s ha attivato la modalità emote-only + %1$s ha disattivato la modalità emote-only + %1$s ha attivato la modalità followers-only + %1$s ha attivato la modalità followers-only (%2$s) + %1$s ha disattivato la modalità followers-only + %1$s ha attivato la modalità unique-chat + %1$s ha disattivato la modalità unique-chat + %1$s ha attivato la modalità slow + %1$s ha attivato la modalità slow (%2$s) + %1$s ha disattivato la modalità slow + %1$s ha attivato la modalità subscribers-only + %1$s ha disattivato la modalità subscribers-only + %1$s ha espulso temporaneamente %2$s per %3$s in %4$s + %1$s ha espulso temporaneamente %2$s per %3$s in %4$s: %5$s + %1$s ha rimosso l\'espulsione temporanea di %2$s in %3$s + %1$s ha bannato %2$s in %3$s + %1$s ha bannato %2$s in %3$s: %4$s + %1$s ha sbannato %2$s in %3$s + %1$s ha eliminato un messaggio di %2$s in %3$s + %1$s ha eliminato un messaggio di %2$s in %3$s dicendo: %4$s + %1$s%2$s + + \u0020(%1$d volta) + \u0020(%1$d volte) + \u0020(%1$d volte) + + + + Cancella + Invia un sussurro + Sussurro a @%1$s + Nuovo sussurro + Invia sussurro a + Nome utente + Invia + + + Solo emote + Solo abbonati + Modalità lenta + Modalità lenta (%1$s) + Chat unica (R9K) + Solo follower + Solo follower (%1$s) + Personalizzato + Qualsiasi + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Attivare la modalità scudo? + Verranno applicate le impostazioni di sicurezza preconfigurate del canale, che possono includere restrizioni della chat, impostazioni AutoMod e requisiti di verifica. + Attiva Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Aggiungi un canale per iniziare a chattare + Nessuna emote recente + + + Mostra stream + Nascondi stream + Solo audio + Esci da solo audio + Schermo intero + Esci dallo schermo intero + Nascondi input + Mostra input + Navigazione canali a scorrimento + Cambia canale scorrendo sulla chat + Moderazione canale + + + Cerca messaggi + Ultimo messaggio + Attiva/disattiva stream + Moderazione canale + Schermo intero + Nascondi input + Configura azioni + Debug + + Massimo %1$d azione + Massimo %1$d azioni + Massimo %1$d azioni + + + + DankChat + Configuriamo tutto. + Accedi con Twitch + Accedi per inviare messaggi, usare le tue emote, ricevere sussurri e sbloccare tutte le funzionalità. + Ti verrà chiesto di concedere diversi permessi Twitch tutti insieme, così non dovrai autorizzare di nuovo quando usi funzionalità diverse. DankChat esegue azioni di moderazione e stream solo quando glielo chiedi. + Accedi con Twitch + Accesso riuscito + Notifiche + DankChat può avvisarti quando qualcuno ti menziona in chat mentre l\'app è in background. + Consenti notifiche + Apri impostazioni notifiche + Senza notifiche, non saprai quando qualcuno ti menziona in chat mentre l\'app è in background. + Cronologia messaggi + DankChat carica messaggi storici da un servizio di terze parti all\'avvio. Per ottenere i messaggi, DankChat invia i nomi dei canali aperti a questo servizio. Il servizio memorizza temporaneamente i messaggi dei canali visitati.\n\nPuoi cambiare questa impostazione nelle impostazioni o saperne di più su https://recent-messages.robotty.de/ + Attiva + Disattiva + Continua + Inizia + Salta + + + Generali + Autenticazione + Attiva Twitch EventSub + Usa EventSub per vari eventi in tempo reale al posto del deprecato PubSub + Attiva output di debug EventSub + Mostra informazioni di debug relative a EventSub come messaggi di sistema + Revoca token e riavvia + Invalida il token attuale e riavvia l\'applicazione + Non connesso + Impossibile risolvere l\'ID del canale per %1$s + Il messaggio non è stato inviato + Messaggio scartato: %1$s (%2$s) + Permesso user:write:chat mancante, effettua nuovamente l\'accesso + Non autorizzato a inviare messaggi in questo canale + Il messaggio è troppo grande + Limite di frequenza raggiunto, riprova tra un momento + Invio fallito: %1$s + + + Devi effettuare l\'accesso per usare il comando %1$s + Nessun utente corrispondente a quel nome. + Si è verificato un errore sconosciuto. + Non hai il permesso di eseguire questa azione. + Permesso richiesto mancante. Effettua nuovamente l\'accesso con il tuo account e riprova. + Credenziali di accesso mancanti. Effettua nuovamente l\'accesso con il tuo account e riprova. + Uso: /block <utente> + Hai bloccato con successo l\'utente %1$s + Impossibile bloccare l\'utente %1$s, nessun utente trovato con quel nome! + Impossibile bloccare l\'utente %1$s, si è verificato un errore sconosciuto! + Uso: /unblock <utente> + Hai sbloccato con successo l\'utente %1$s + Impossibile sbloccare l\'utente %1$s, nessun utente trovato con quel nome! + Impossibile sbloccare l\'utente %1$s, si è verificato un errore sconosciuto! + Il canale non è in diretta. + Tempo in diretta: %1$s + Comandi disponibili in questa stanza: %1$s + Uso: %1$s <nome utente> <messaggio>. + Sussurro inviato. + Invio del sussurro fallito - %1$s + Uso: %1$s <messaggio> - Attira l\'attenzione sul tuo messaggio con un\'evidenziazione. + Invio dell\'annuncio fallito - %1$s + Questo canale non ha moderatori. + I moderatori di questo canale sono %1$s. + Impossibile elencare i moderatori - %1$s + Uso: %1$s <nome utente> - Concedi lo stato di moderatore a un utente. + Hai aggiunto %1$s come moderatore di questo canale. + Impossibile aggiungere il moderatore del canale - %1$s + Uso: %1$s <nome utente> - Revoca lo stato di moderatore a un utente. + Hai rimosso %1$s come moderatore di questo canale. + Impossibile rimuovere il moderatore del canale - %1$s + Questo canale non ha VIP. + I VIP di questo canale sono %1$s. + Impossibile elencare i VIP - %1$s + Uso: %1$s <nome utente> - Concedi lo stato VIP a un utente. + Hai aggiunto %1$s come VIP di questo canale. + Impossibile aggiungere il VIP - %1$s + Uso: %1$s <nome utente> - Revoca lo stato VIP a un utente. + Hai rimosso %1$s come VIP di questo canale. + Impossibile rimuovere il VIP - %1$s + Uso: %1$s <nome utente> [motivo] - Impedisci permanentemente a un utente di chattare. Il motivo è facoltativo e verrà mostrato all\'utente interessato e agli altri moderatori. Usa /unban per rimuovere un ban. + Impossibile bannare l\'utente - Non puoi bannare te stesso. + Impossibile bannare l\'utente - Non puoi bannare il broadcaster. + Impossibile bannare l\'utente - %1$s + Uso: %1$s <nome utente> - Rimuove il ban di un utente. + Impossibile sbannare l\'utente - %1$s + Uso: %1$s <nome utente> [durata][unità di tempo] [motivo] - Impedisci temporaneamente a un utente di chattare. La durata (facoltativa, predefinita: 10 minuti) deve essere un intero positivo; l\'unità di tempo (facoltativa, predefinita: s) deve essere s, m, h, d o w; la durata massima è di 2 settimane. Il motivo è facoltativo e verrà mostrato all\'utente interessato e agli altri moderatori. + Impossibile bannare l\'utente - Non puoi mettere in timeout te stesso. + Impossibile bannare l\'utente - Non puoi mettere in timeout il broadcaster. + Impossibile mettere in timeout l\'utente - %1$s + Impossibile eliminare i messaggi della chat - %1$s + Uso: /delete <msg-id> - Elimina il messaggio specificato. + msg-id non valido: \"%1$s\". + Impossibile eliminare i messaggi della chat - %1$s + Uso: /color <colore> - Il colore deve essere uno dei colori supportati da Twitch (%1$s) o un hex code (#000000) se hai Turbo o Prime. + Il tuo colore è stato cambiato in %1$s + Impossibile cambiare il colore in %1$s - %2$s + Marcatore dello stream aggiunto con successo a %1$s%2$s. + Impossibile creare il marcatore dello stream - %1$s + Uso: /commercial <durata> - Avvia una pubblicità con la durata specificata per il canale attuale. Le durate valide sono 30, 60, 90, 120, 150 e 180 secondi. + + Avvio di una pausa pubblicitaria di %1$d secondi. Ricorda che sei ancora in diretta e non tutti gli spettatori riceveranno la pubblicità. Puoi avviare un\'altra pubblicità tra %2$d secondi. + Avvio di una pausa pubblicitaria di %1$d secondi. Ricorda che sei ancora in diretta e non tutti gli spettatori riceveranno la pubblicità. Puoi avviare un\'altra pubblicità tra %2$d secondi. + Avvio di una pausa pubblicitaria di %1$d secondi. Ricorda che sei ancora in diretta e non tutti gli spettatori riceveranno la pubblicità. Puoi avviare un\'altra pubblicità tra %2$d secondi. + + Impossibile avviare la pubblicità - %1$s + Uso: /raid <nome utente> - Effettua un raid su un utente. Solo il broadcaster può avviare un raid. + Nome utente non valido: %1$s + Hai avviato un raid su %1$s. + Impossibile avviare il raid - %1$s + Hai annullato il raid. + Impossibile annullare il raid - %1$s + Uso: %1$s [durata] - Attiva la modalità solo follower (solo i follower possono chattare). La durata (facoltativa, predefinita: 0 minuti) deve essere un numero positivo seguito da un\'unità di tempo (m, h, d, w); la durata massima è di 3 mesi. + Questa stanza è già in modalità solo follower di %1$s. + Impossibile aggiornare le impostazioni della chat - %1$s + Questa stanza non è in modalità solo follower. + Questa stanza è già in modalità solo emote. + Questa stanza non è in modalità solo emote. + Questa stanza è già in modalità solo abbonati. + Questa stanza non è in modalità solo abbonati. + Questa stanza è già in modalità chat unica. + Questa stanza non è in modalità chat unica. + Uso: %1$s [durata] - Attiva la modalità lenta (limita la frequenza con cui gli utenti possono inviare messaggi). La durata (facoltativa, predefinita: 30) deve essere un numero positivo di secondi; massimo 120. + Questa stanza è già in modalità lenta di %1$d secondi. + Questa stanza non è in modalità lenta. + Uso: %1$s <nome utente> - Invia uno shoutout all\'utente Twitch specificato. + Shoutout inviato a %1$s + Impossibile inviare lo shoutout - %1$s + La modalità scudo è stata attivata. + La modalità scudo è stata disattivata. + Impossibile aggiornare la modalità scudo - %1$s + Non puoi sussurrare a te stesso. + A causa delle restrizioni di Twitch, ora è necessario avere un numero di telefono verificato per inviare sussurri. Puoi aggiungere un numero di telefono nelle impostazioni di Twitch. https://www.twitch.tv/settings/security + Il destinatario non accetta sussurri da sconosciuti o da te direttamente. + Twitch sta limitando la tua frequenza di invio. Riprova tra qualche secondo. + Puoi sussurrare a un massimo di 40 destinatari unici al giorno. Entro il limite giornaliero, puoi inviare un massimo di 3 sussurri al secondo e un massimo di 100 sussurri al minuto. + A causa delle restrizioni di Twitch, questo comando può essere utilizzato solo dal broadcaster. Utilizza il sito web di Twitch. + %1$s è già moderatore di questo canale. + %1$s è attualmente un VIP, usa /unvip e riprova questo comando. + %1$s non è moderatore di questo canale. + %1$s non è bannato da questo canale. + %1$s è già bannato in questo canale. + Non puoi %1$s %2$s. + C\'è stata un\'operazione di ban in conflitto su questo utente. Riprova. + Il colore deve essere uno dei colori supportati da Twitch (%1$s) o un hex code (#000000) se hai Turbo o Prime. + Devi essere in diretta per avviare le pubblicità. + Devi attendere la scadenza del periodo di attesa prima di poter avviare un\'altra pubblicità. + Il comando deve includere una durata della pausa pubblicitaria desiderata maggiore di zero. + Non hai un raid attivo. + Un canale non può raidare se stesso. + Il broadcaster non può dare uno Shoutout a se stesso. + Il broadcaster non è in diretta o non ha uno o più spettatori. + La durata è al di fuori dell\'intervallo valido: %1$s. + Il messaggio è già stato elaborato. + Il messaggio di destinazione non è stato trovato. + Il tuo messaggio era troppo lungo. + La tua frequenza di invio è stata limitata. Riprova tra un momento. + L\'utente di destinazione + Visualizzatore log + Visualizza i log dell\'applicazione + Log + Condividi log + Visualizza log + Nessun file di log disponibile + Cerca nei log + + %1$d selezionati + %1$d selezionati + %1$d selezionati + + Copia log selezionati + Cancella selezione + Crash rilevato + L\'app si è bloccata durante l\'ultima sessione. + Thread: %1$s + Copia + Report via chat + Entra in #flex3rs e prepara un riepilogo del crash da inviare + Report via email + Invia un report dettagliato del crash via email + Invia report del crash via email + I seguenti dati saranno inclusi nel report: + Stack trace + Includi file di log corrente + Report dei crash + Visualizza i report dei crash recenti + Nessun report dei crash trovato + Report del crash + Condividi report del crash + Elimina + Eliminare questo report del crash? + Cancella tutto + Eliminare tutti i report dei crash? + Scorri verso il basso + Indietro + Salva + Badge + Admin + Broadcaster + Fondatore + Moderatore capo + Moderatore + Staff + Abbonato + Verificato + VIP + Badge + Crea notifiche e evidenzia i messaggi degli utenti in base ai badge. + Scegli colore + Scegli un colore di evidenziazione personalizzato + Predefinito + Licenze open source + Mostra categoria dello stream + Mostra anche la categoria dello stream + Chat condivisa + + In diretta con %1$d spettatore in %2$s da %3$s + In diretta con %1$d spettatori in %2$s da %3$s + In diretta con %1$d spettatori in %2$s da %3$s + + Visualizza cronologia + Nessun messaggio corrisponde ai filtri attuali diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index acd5ac3f3..6cd98efd5 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -47,20 +47,60 @@ 切断されました ログインされていません 返信 - 新しいメンションがあります + Send announcement 新しいメンションがあります %1$sが#%2$sであなたに返信しました #%1$s 内であなた宛の返信があります %1$sとしてログイン ログインに失敗しました コピーしました:%1$s + アップロード完了:%1$s アップロード中にエラー アップロード中にエラー:%1$s + アップロード + クリップボードにコピーしました + URLをコピー 再試行 エモートをリロードしました データの読み込みに失敗しました:%1$s 複数のエラーでデータの読み込みに失敗しました:\n%1$s + DankChat バッジ + グローバルバッジ + グローバル FFZ エモート + グローバル BTTV エモート + グローバル 7TV エモート + チャンネルバッジ + FFZ エモート + BTTV エモート + 7TV エモート + Twitch エモート + チアエモート + 最近のメッセージ + %1$s (%2$s) + + 初めてのチャット + ピン留めチャット + 巨大エモート + アニメーションメッセージ + 引き換え済み %1$s + %1$d秒 + + + %1$d分 + + + %1$d時間 + + + %1$d日 + + + %1$d週間 + + %1$s %2$s + %1$s %2$s %3$s 貼り付け チャンネル名 + チャンネルは既に追加されています 新着 サブスク チャンネル @@ -151,6 +191,8 @@ コマンドの追加 コマンドを削除 トリガー + このトリガーは組み込みコマンドによって予約されています + このトリガーは既に別のコマンドで使用されています コマンド カスタムコマンド 通報 @@ -188,14 +230,54 @@ 通知 チャット 一般 + サジェスト + メッセージ + ユーザー + エモートとバッジ このアプリについて 外観 DankChat %1$sは@flex3rsと複数のコントリビューターによって作成されました 入力を表示 メッセージを送信するための入力フィールドを表示します システムの既定値に設定 - トゥルーダークテーマ - チャット背景色を強制的に黒にする + AMOLEDダークモード + OLED画面向けの純粋な黒背景 + アクセントカラー + システムの壁紙に従う + + ティール + + ライム + + オレンジ + + ピンク + + インディゴ + + グレー + カラースタイル + システムデフォルト + システムのデフォルトカラーパレットを使用 + Tonal Spot + 落ち着いた控えめな色合い + Neutral + ほぼモノクロ、微かな色味 + Vibrant + 大胆で鮮やかな色合い + Expressive + 色相をずらした遊び心のある色 + Rainbow + 幅広い色相のスペクトル + Fruit Salad + 遊び心のあるマルチカラーパレット + Monochrome + 黒、白、グレーのみ + Fidelity + アクセントカラーに忠実 + Content + 類似色の第三色を伴うアクセントカラー + その他のスタイル 表示 コンポーネント タイムアウトメッセージを表示 @@ -207,8 +289,19 @@ 極大 - エモートとユーザーの候補 - 入力中にエモートとアクティブユーザーの候補を表示する + サジェスト + 入力中に表示するサジェストを選択 + エモート + ユーザー + Twitchコマンド + Supibotコマンド + : で呼び出す + @ で呼び出す + / で呼び出す + $ で呼び出す + サジェストモード + 入力中に候補を表示 + トリガー文字の後にのみ候補を表示 開始時にメッセージ履歴を読み込む 再接続後にメッセージ履歴を読み込む 接続が切断中に受信されずに失われたメッセージを取得しようとしています @@ -218,7 +311,7 @@ チャンネルデータ 開発者オプション デバッグモード - キャッチされた例外情報の提供 + 入力バーにデバッグ分析アクションを表示し、クラッシュレポートをローカルに収集 タイムスタンプ形式 TTSの有効化 アクティブなチャンネルのメッセージを読み込む @@ -232,6 +325,9 @@ URLを無視 TTSのエモートと絵文字を無視 エモートを無視 + 音量 + オーディオダッキング + TTS再生中に他のアプリの音量を下げる TTS ラインの色調整 ラインごとに背景の明るさを変更 @@ -244,12 +340,19 @@ ユーザーの長押しの動作 通常のクリックでポップアップを開き、長押しでメンション 通常のクリックでメンション、長押しでポップアップを開く + ニックネームに色を付ける + 色が設定されていないユーザーにランダムな色を割り当てる 強制的に言語を英語にする システムのデフォルトの代わりにTTS音声言語を英語にする サードパーティのエモートの表示 Twitch利用規約とユーザーポリシー: チップの動作を表示 全画面表示、ストリーム、チャットモードの調整のためのチップを表示します + 文字数カウンターを表示 + 入力フィールドにコードポイント数を表示します + 入力クリアボタンを表示 + 送信ボタンを表示 + 入力 メディアアップローダー アップローダーを設定 最近のアップロード @@ -278,6 +381,9 @@ カスタムログイン Twitchコマンドの処理をバイパスする Twitchコマンドの通信切断を無効にし、代わりにチャットに送信します + チャット送信プロトコル + Helix APIで送信する + IRCの代わりにTwitch Helix API経由でチャットメッセージを送信します 7TVライブエモートの更新 ライブエモートはバックグラウンドで更新中 %1$s後にアップデートを停止します。\n数字を下げることでバッテリー寿命を延ばせるかもしれません。 @@ -322,6 +428,7 @@ あなたのユーザー名 サブスクリプションとイベント お知らせ + 視聴ストリーク 最初のメッセージ メッセージのピン留め表示 チャンネルポイントで交換されたメッセージのハイライト @@ -347,9 +454,22 @@ メッセージをコピー メッセージ全体をコピー メッセージに返信 + 元のメッセージに返信 スレッドを表示 メッセージIDをコピー もっとみる… + メッセージに移動 + メッセージはチャット履歴にありません + メッセージ履歴 + グローバル履歴 + 履歴:%1$s + メッセージを検索… + ユーザー名でフィルター + リンクを含むメッセージ + エモートを含むメッセージ + バッジ名でフィルター + ユーザー + バッジ \@%1$sに返信 返信スレッドが見つかりません メッセージが見つかりません @@ -382,4 +502,393 @@ %dヶ月 + アプリバーの切り替え + エラー: %s + ログアウトしますか? + このチャンネルを削除しますか? + チャンネル「%1$s」を削除しますか? + チャンネル「%1$s」をブロックしますか? + このユーザーをBANしますか? + このメッセージを削除しますか? + チャットをクリアしますか? + 検索、配信などへのクイックアクセスのためのカスタマイズ可能なアクション + ここをタップしてその他のアクションやアクションバーの設定ができます + アクションバーに表示するアクションをここでカスタマイズできます + 入力欄を下にスワイプすると素早く非表示にできます + ここをタップして入力欄を元に戻します + 次へ + 了解 + ツアーをスキップ + ここでチャンネルを追加できます + + + 理由: %1$s でメッセージが保留されました。許可するとチャットに投稿されます。 + 許可 + 拒否 + 承認済み + 拒否済み + 期限切れ + おっと!あなたのメッセージはモデレーターが確認中で、まだ送信されていません。 + モデレーターがあなたのメッセージを承認しました。 + モデレーターがあなたのメッセージを拒否しました。 + %1$s (レベル %2$d) + + %1$d件のブロックされた用語 %2$s に一致 + + AutoModメッセージの%1$sに失敗しました - メッセージは既に処理されています。 + AutoModメッセージの%1$sに失敗しました - 再認証が必要です。 + AutoModメッセージの%1$sに失敗しました - この操作を行う権限がありません。 + AutoModメッセージの%1$sに失敗しました - 対象メッセージが見つかりません。 + AutoModメッセージの%1$sに失敗しました - 不明なエラーが発生しました。 + %1$sがAutoModで%2$sをブロック用語として追加しました。 + %1$sがAutoModで%2$sを許可用語として追加しました。 + %1$sがAutoModから%2$sをブロック用語として削除しました。 + %1$sがAutoModから%2$sを許可用語として削除しました。 + + + + あなたは%1$sタイムアウトされました + あなたは%2$sにより%1$sタイムアウトされました + あなたは%2$sにより%1$sタイムアウトされました: %3$s + %1$sが%2$sを%3$sタイムアウトしました + %1$sが%2$sを%3$sタイムアウトしました: %4$s + %1$sは%2$sタイムアウトされました + あなたはBANされました + あなたは%1$sによりBANされました + あなたは%1$sによりBANされました: %2$s + %1$sが%2$sをBANしました + %1$sが%2$sをBANしました: %3$s + %1$sは永久BANされました + %1$sが%2$sのタイムアウトを解除しました + %1$sが%2$sのBANを解除しました + %1$sが%2$sをモデレーターにしました + %1$sが%2$sのモデレーターを解除しました + %1$sが%2$sをこのチャンネルのVIPに追加しました + %1$sが%2$sをこのチャンネルのVIPから削除しました + %1$sが%2$sに警告しました + %1$sが%2$sに警告しました: %3$s + %1$sが%2$sへのレイドを開始しました + %1$sが%2$sへのレイドをキャンセルしました + %1$sが%2$sのメッセージを削除しました + %1$sが%2$sのメッセージを削除しました 内容: %3$s + %1$sのメッセージが削除されました + %1$sのメッセージが削除されました 内容: %2$s + %1$sがチャットを消去しました + モデレーターによってチャットが消去されました + %1$sがエモート限定モードをオンにしました + %1$sがエモート限定モードをオフにしました + %1$sがフォロワー限定モードをオンにしました + %1$sがフォロワー限定モードをオンにしました (%2$s) + %1$sがフォロワー限定モードをオフにしました + %1$sがユニークチャットモードをオンにしました + %1$sがユニークチャットモードをオフにしました + %1$sがスローモードをオンにしました + %1$sがスローモードをオンにしました (%2$s) + %1$sがスローモードをオフにしました + %1$sがサブスクライバー限定モードをオンにしました + %1$sがサブスクライバー限定モードをオフにしました + %1$sが%4$sで%2$sを%3$sタイムアウトしました + %1$sが%4$sで%2$sを%3$sタイムアウトしました: %5$s + %1$sが%3$sで%2$sのタイムアウトを解除しました + %1$sが%3$sで%2$sをBANしました + %1$sが%3$sで%2$sをBANしました: %4$s + %1$sが%3$sで%2$sのBANを解除しました + %1$sが%3$sで%2$sのメッセージを削除しました + %1$sが%3$sで%2$sのメッセージを削除しました 内容: %4$s + %1$s%2$s + + \u0020(%1$d回) + + + + バックスペース + ウィスパーを送信 + @%1$s にウィスパー中 + 新しいウィスパー + ウィスパーの送信先 + ユーザー名 + 送信 + + + エモートのみ + サブスクライバーのみ + スローモード + スローモード (%1$s) + ユニークチャット (R9K) + フォロワーのみ + フォロワーのみ (%1$s) + カスタム + すべて + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + シールドモードを有効にしますか? + チャンネルの事前設定された安全設定が適用されます。チャット制限、AutoMod設定、チャット認証要件が含まれる場合があります。 + 有効にする Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + チャンネルを追加してチャットを始めましょう + 最近使用したエモートはありません + + + 配信を表示 + 配信を非表示 + 音声のみ + 音声モードを終了 + 全画面 + 全画面を終了 + 入力欄を非表示 + 入力欄を表示 + チャンネルスワイプナビゲーション + チャットをスワイプしてチャンネルを切り替え + チャンネルモデレーション + + + メッセージを検索 + 最後のメッセージ + 配信を切り替え + チャンネルモデレーション + 全画面 + 入力欄を非表示 + アクションを設定 + デバッグ + + 最大%1$d個のアクション + + + + DankChat + セットアップを始めましょう。 + Twitchでログイン + ログインして、メッセージの送信、エモートの使用、ウィスパーの受信、すべての機能をお楽しみください。 + 複数のTwitch権限をまとめて許可するよう求められます。これにより、異なる機能を使うたびに再認証する必要がなくなります。DankChatがモデレーションや配信の操作を行うのは、あなたが指示したときだけです。 + Twitchでログイン + ログイン成功 + 通知 + DankChatは、アプリがバックグラウンドにある時にチャットで誰かがあなたをメンションした場合に通知できます。 + 通知を許可 + 通知設定を開く + 通知がないと、アプリがバックグラウンドにある時にチャットで誰かがあなたをメンションしても気づけません。 + メッセージ履歴 + DankChatは起動時にサードパーティサービスから過去のメッセージを読み込みます。 メッセージを取得するために、DankChatは開いているチャンネル名をそのサービスに送信します。 サービスは訪問されたチャンネルのメッセージを一時的に保存します。\n\nこれは後で設定から変更できます。詳細は https://recent-messages.robotty.de/ をご覧ください。 + 有効にする + 無効にする + 続ける + 始める + スキップ + + + 一般 + 認証 + Twitch EventSubを有効にする + 非推奨のPubSubの代わりにEventSubを使用してリアルタイムイベントを処理します + EventSubデバッグ出力を有効にする + EventSub関連のデバッグ情報をシステムメッセージとして表示します + トークンを失効させて再起動 + 現在のトークンを無効化してアプリを再起動します + ログインしていません + %1$s のチャンネルIDを解決できませんでした + メッセージは送信されませんでした + メッセージが破棄されました: %1$s (%2$s) + user:write:chat スコープがありません。再ログインしてください + このチャンネルでメッセージを送信する権限がありません + メッセージが大きすぎます + レート制限中です。しばらくしてから再試行してください + 送信失敗: %1$s + + + %1$s コマンドを使用するにはログインが必要です + そのユーザー名に一致するユーザーが見つかりません。 + 不明なエラーが発生しました。 + この操作を実行する権限がありません。 + 必要なスコープが不足しています。アカウントで再ログインしてやり直してください。 + ログイン情報がありません。アカウントで再ログインしてやり直してください。 + 使い方: /block <ユーザー> + ユーザー %1$s を正常にブロックしました + ユーザー %1$s をブロックできませんでした。その名前のユーザーが見つかりません! + ユーザー %1$s をブロックできませんでした。不明なエラーが発生しました! + 使い方: /unblock <ユーザー> + ユーザー %1$s のブロックを正常に解除しました + ユーザー %1$s のブロックを解除できませんでした。その名前のユーザーが見つかりません! + ユーザー %1$s のブロックを解除できませんでした。不明なエラーが発生しました! + チャンネルは配信中ではありません。 + 配信時間: %1$s + このルームで使用できるコマンド: %1$s + 使い方: %1$s <ユーザー名> <メッセージ>。 + ウィスパーを送信しました。 + ウィスパーの送信に失敗しました - %1$s + 使い方: %1$s <メッセージ> - ハイライトでメッセージに注目を集めます。 + アナウンスの送信に失敗しました - %1$s + このチャンネルにはモデレーターがいません。 + このチャンネルのモデレーターは %1$s です。 + モデレーターの一覧取得に失敗しました - %1$s + 使い方: %1$s <ユーザー名> - ユーザーにモデレーター権限を付与します。 + %1$s をこのチャンネルのモデレーターに追加しました。 + チャンネルモデレーターの追加に失敗しました - %1$s + 使い方: %1$s <ユーザー名> - ユーザーからモデレーター権限を剥奪します。 + %1$s をこのチャンネルのモデレーターから削除しました。 + チャンネルモデレーターの削除に失敗しました - %1$s + このチャンネルにはVIPがいません。 + このチャンネルのVIPは %1$s です。 + VIPの一覧取得に失敗しました - %1$s + 使い方: %1$s <ユーザー名> - ユーザーにVIPステータスを付与します。 + %1$s をこのチャンネルのVIPに追加しました。 + VIPの追加に失敗しました - %1$s + 使い方: %1$s <ユーザー名> - ユーザーからVIPステータスを剥奪します。 + %1$s をこのチャンネルのVIPから削除しました。 + VIPの削除に失敗しました - %1$s + 使い方: %1$s <ユーザー名> [理由] - ユーザーのチャットを永久に禁止します。理由は任意で、対象ユーザーと他のモデレーターに表示されます。BANを解除するには /unban を使用してください。 + ユーザーのBANに失敗しました - 自分自身をBANすることはできません。 + ユーザーのBANに失敗しました - 配信者をBANすることはできません。 + ユーザーのBANに失敗しました - %1$s + 使い方: %1$s <ユーザー名> - ユーザーのBANを解除します。 + ユーザーのBAN解除に失敗しました - %1$s + 使い方: %1$s <ユーザー名> [期間][時間単位] [理由] - ユーザーのチャットを一時的に禁止します。期間(任意、デフォルト: 10分)は正の整数、時間単位(任意、デフォルト: s)はs、m、h、d、wのいずれか、最大期間は2週間です。理由は任意で、対象ユーザーと他のモデレーターに表示されます。 + ユーザーのBANに失敗しました - 自分自身をタイムアウトすることはできません。 + ユーザーのBANに失敗しました - 配信者をタイムアウトすることはできません。 + ユーザーのタイムアウトに失敗しました - %1$s + チャットメッセージの削除に失敗しました - %1$s + 使い方: /delete <msg-id> - 指定されたメッセージを削除します。 + 無効なmsg-id: \"%1$s\"。 + チャットメッセージの削除に失敗しました - %1$s + 使い方: /color <色> - 色はTwitchがサポートする色(%1$s)またはTurboかPrimeをお持ちの場合はhex code(#000000)である必要があります。 + 色が %1$s に変更されました + 色を %1$s に変更できませんでした - %2$s + %1$s%2$s にストリームマーカーを正常に追加しました。 + ストリームマーカーの作成に失敗しました - %1$s + 使い方: /commercial <長さ> - 現在のチャンネルで指定した長さのCMを開始します。有効な長さは30、60、90、120、150、180秒です。 + + %1$d 秒のCM休憩を開始します。まだ配信中であり、すべての視聴者にCMが表示されるわけではないことにご注意ください。次のCMは %2$d 秒後に実行できます。 + + CMの開始に失敗しました - %1$s + 使い方: /raid <ユーザー名> - ユーザーをレイドします。配信者のみがレイドを開始できます。 + 無効なユーザー名: %1$s + %1$s へのレイドを開始しました。 + レイドの開始に失敗しました - %1$s + レイドをキャンセルしました。 + レイドのキャンセルに失敗しました - %1$s + 使い方: %1$s [期間] - フォロワー限定モードを有効にします(フォロワーのみチャット可能)。期間(任意、デフォルト: 0分)は正の数と時間単位(m、h、d、w)、最大期間は3か月です。 + このルームは既に %1$s のフォロワー限定モードです。 + チャット設定の更新に失敗しました - %1$s + このルームはフォロワー限定モードではありません。 + このルームは既にエモート限定モードです。 + このルームはエモート限定モードではありません。 + このルームは既にサブスクライバー限定モードです。 + このルームはサブスクライバー限定モードではありません。 + このルームは既にユニークチャットモードです。 + このルームはユニークチャットモードではありません。 + 使い方: %1$s [期間] - 低速モードを有効にします(メッセージ送信頻度を制限)。期間(任意、デフォルト: 30)は正の秒数、最大120です。 + このルームは既に %1$d 秒の低速モードです。 + このルームは低速モードではありません。 + 使い方: %1$s <ユーザー名> - 指定したTwitchユーザーにシャウトアウトを送信します。 + %1$s にシャウトアウトを送信しました + シャウトアウトの送信に失敗しました - %1$s + シールドモードが有効になりました。 + シールドモードが無効になりました。 + シールドモードの更新に失敗しました - %1$s + 自分自身にウィスパーすることはできません。 + Twitchの制限により、ウィスパーを送信するには認証済みの電話番号が必要です。電話番号はTwitchの設定で追加できます。https://www.twitch.tv/settings/security + 受信者は見知らぬ人またはあなたからのウィスパーを許可していません。 + Twitchによりレート制限されています。数秒後にもう一度お試しください。 + 1日に最大40人のユニークな受信者にウィスパーできます。1日の制限内で、1秒あたり最大3件、1分あたり最大100件のウィスパーを送信できます。 + Twitchの制限により、このコマンドは配信者のみが使用できます。代わりにTwitchのウェブサイトをご利用ください。 + %1$s は既にこのチャンネルのモデレーターです。 + %1$s は現在VIPです。/unvip してからこのコマンドを再試行してください。 + %1$s はこのチャンネルのモデレーターではありません。 + %1$s はこのチャンネルでBANされていません。 + %1$s は既にこのチャンネルでBANされています。 + %2$s を %1$s することはできません。 + このユーザーに対して競合するBAN操作がありました。もう一度お試しください。 + 色はTwitchがサポートする色(%1$s)またはTurboかPrimeをお持ちの場合はhex code(#000000)である必要があります。 + CMを実行するにはライブ配信中である必要があります。 + 次のCMを実行するにはクールダウン期間が終了するまで待つ必要があります。 + コマンドにはゼロより大きいCM休憩の長さを含める必要があります。 + アクティブなレイドがありません。 + チャンネルは自分自身をレイドできません。 + 配信者は自分自身にシャウトアウトすることはできません。 + 配信者がライブ配信中でないか、1人以上の視聴者がいません。 + 期間が有効な範囲外です: %1$s。 + メッセージは既に処理されています。 + 対象メッセージが見つかりませんでした。 + メッセージが長すぎます。 + レート制限されています。しばらくしてからもう一度お試しください。 + 対象ユーザー + ログビューア + アプリケーションログを表示 + ログ + ログを共有 + ログを表示 + ログファイルがありません + ログを検索 + + %1$d 件選択中 + + 選択したログをコピー + 選択を解除 + クラッシュを検出 + 前回のセッション中にアプリがクラッシュしました。 + スレッド: %1$s + コピー + チャットレポート + #flex3rsに参加し、送信用のクラッシュ概要を準備します + メールレポート + 詳細なクラッシュレポートをメールで送信 + クラッシュレポートをメールで送信 + 以下のデータがレポートに含まれます: + スタックトレース + 現在のログファイルを含める + クラッシュレポート + 最近のクラッシュレポートを表示 + クラッシュレポートが見つかりません + クラッシュレポート + クラッシュレポートを共有 + 削除 + このクラッシュレポートを削除しますか? + すべて消去 + すべてのクラッシュレポートを削除しますか? + 一番下にスクロール + 戻る + 保存 + バッジ + 管理者 + 配信者 + ファウンダー + リードモデレーター + モデレーター + スタッフ + サブスクライバー + 認証済み + VIP + バッジ + バッジに基づいてユーザーのメッセージの通知とハイライトを作成します。 + 色を選択 + カスタムハイライト色を選択 + デフォルト + オープンソースライセンス + 配信カテゴリを表示 + 配信カテゴリも表示する + 共有チャット + + %2$sで%1$d人の視聴者と%3$s配信中 + + 履歴を表示 + 現在のフィルターに一致するメッセージはありません diff --git a/app/src/main/res/values-kk-rKZ/strings.xml b/app/src/main/res/values-kk-rKZ/strings.xml index c3763616a..529487cd8 100644 --- a/app/src/main/res/values-kk-rKZ/strings.xml +++ b/app/src/main/res/values-kk-rKZ/strings.xml @@ -24,8 +24,10 @@ Арнасыды жұлу Арнасы бұғаттады Қосылған арнасыдылер жоқ + Сөйлесуді бастау үшін арна қосыңыз Шығыс растау Сен шығын келу сенімді болды ма? + Шығасыз ба? Шығу Файлдарды енгізу Фото істеу @@ -43,26 +45,74 @@ FeelsDankMan DankChat фондық тәртібіде істейді Эмодзилерді мәзір ашу + Эмоция мәзірін жабу + Соңғы эмоциялар жоқ + Эмоциялар Twitch.tv кіру Әңгімеге кірісу Ажыратты Жүйеге кірмеген Жауап беру - Сізде жаңа ескертулер бар + Send announcement Сізде жаңа ескертулер бар %1$s сізді #%2$s ішінде атап өтті Сіз #%1$s ішінде аталды %1$s ретінде кіру Жүйеге кіру мүмкін болмады Көшірілген: %1$s + Жүктеу аяқталды: %1$s Жүктеп салу кезінде қате Жүктеп салу кезінде қате: %1$s + Жүктеу + Алмасу буферіне көшірілді + URL көшіру Қайталап көріңіз Эмоталар қайта жүктелді Деректер жүктелмеді: %1$s Бірнеше қатемен деректерді жүктеу сәтсіз аяқталды:\n%1$s + DankChat белгілері + Жаһандық белгілер + Жаһандық FFZ эмоциялары + Жаһандық BTTV эмоциялары + Жаһандық 7TV эмоциялары + Арна белгілері + FFZ эмоциялары + BTTV эмоциялары + 7TV эмоциялары + Twitch эмоциялары + Чирмоттар + Соңғы хабарлар + %1$s (%2$s) + Алғашқы рет чат + Көтерілген чат + Алып эмоут + Анимациялық хабарлама + Пайдаланылды: %1$s + %1$d секунд + %1$d секунд + + + %1$d минут + %1$d минут + + + %1$d сағат + %1$d сағат + + + %1$d күн + %1$d күн + + + %1$d апта + %1$d апта + + %1$s %2$s + %1$s %2$s %3$s Қою Арна аты + Арна әлдеқашан қосылған Соңғы + Кері қарай Қосалқылар Арна Ғаламдық @@ -82,6 +132,85 @@ %1$s 7TV эмотиясы %2$s қосты. %1$s 7TV Emote %2$s атауын %3$s деп өзгертті. %1$s %2$s 7TV эмоджины жұлды + + Хабарлама себеп бойынша ұсталды: %1$s. Рұқсат ету оны чатта жариялайды. + Рұқсат ету + Бас тарту + Мақұлданды + Қабылданбады + Мерзімі өтті + Эй! Сіздің хабарламаңызды модераторлар тексеріп жатыр, ол әлі жіберілген жоқ. + Модераторлар сіздің хабарламаңызды қабылдады. + Модераторлар сіздің хабарламаңызды қабылдамады. + %1$s (деңгей %2$d) + + %1$d бұғатталған терминге сәйкес келеді %2$s + %1$d бұғатталған терминге сәйкес келеді %2$s + + AutoMod хабарламасын %1$s орындау сәтсіз аяқталды - хабарлама әлдеқашан өңделген. + AutoMod хабарламасын %1$s орындау сәтсіз аяқталды - қайта аутентификация қажет. + AutoMod хабарламасын %1$s орындау сәтсіз аяқталды - бұл әрекетті орындауға рұқсатыңыз жоқ. + AutoMod хабарламасын %1$s орындау сәтсіз аяқталды - мақсатты хабарлама табылмады. + AutoMod хабарламасын %1$s орындау сәтсіз аяқталды - белгісіз қате орын алды. + %1$s AutoMod жүйесінде %2$s бұғатталған термин ретінде қосты. + %1$s AutoMod жүйесінде %2$s рұқсат етілген термин ретінде қосты. + %1$s AutoMod жүйесінен %2$s бұғатталған терминін жойды. + %1$s AutoMod жүйесінен %2$s рұқсат етілген терминін жойды. + + Сіз %1$s уақытқа шектелдіңіз + Сізді %2$s %1$s уақытқа шектеді + Сізді %2$s %1$s уақытқа шектеді: %3$s + %1$s %2$s пайдаланушысын %3$s уақытқа шектеді + %1$s %2$s пайдаланушысын %3$s уақытқа шектеді: %4$s + %1$s %2$s уақытқа шектелді + Сізге тыйым салынды + Сізге %1$s тыйым салды + Сізге %1$s тыйым салды: %2$s + %1$s %2$s пайдаланушысына тыйым салды + %1$s %2$s пайдаланушысына тыйым салды: %3$s + %1$s пайдаланушысына біржола тыйым салынды + %1$s %2$s шектеуін алып тастады + %1$s %2$s тыйымын алды + %1$s %2$s пайдаланушысын модератор етті + %1$s %2$s пайдаланушысын модераторлықтан алды + %1$s %2$s пайдаланушысын осы арнаның VIP мүшесі ретінде қосты + %1$s %2$s пайдаланушысын осы арнаның VIP мүшесі ретінде алып тастады + %1$s %2$s пайдаланушысына ескерту жасады + %1$s %2$s пайдаланушысына ескерту жасады: %3$s + %1$s %2$s арнасына рейд бастады + %1$s %2$s арнасына рейдті тоқтатты + %1$s %2$s пайдаланушысының хабарламасын жойды + %1$s %2$s пайдаланушысының хабарламасын жойды: %3$s + %1$s пайдаланушысының хабарламасы жойылды + %1$s пайдаланушысының хабарламасы жойылды: %2$s + %1$s чатты тазартты + Чатты модератор тазартты + %1$s тек эмоция режимін қосты + %1$s тек эмоция режимін өшірді + %1$s тек ізбасарлар режимін қосты + %1$s тек ізбасарлар режимін қосты (%2$s) + %1$s тек ізбасарлар режимін өшірді + %1$s бірегей чат режимін қосты + %1$s бірегей чат режимін өшірді + %1$s баяу режимді қосты + %1$s баяу режимді қосты (%2$s) + %1$s баяу режимді өшірді + %1$s тек жазылушылар режимін қосты + %1$s тек жазылушылар режимін өшірді + + %1$s %2$s пайдаланушысын %4$s арнасында %3$s уақытқа шектеді + %1$s %2$s пайдаланушысын %4$s арнасында %3$s уақытқа шектеді: %5$s + %1$s %3$s арнасында %2$s шектеуін алып тастады + %1$s %3$s арнасында %2$s пайдаланушысына тыйым салды + %1$s %3$s арнасында %2$s пайдаланушысына тыйым салды: %4$s + %1$s %3$s арнасында %2$s тыйымын алды + %1$s %3$s арнасында %2$s пайдаланушысының хабарламасын жойды + %1$s %3$s арнасында %2$s пайдаланушысының хабарламасын жойды: %4$s + %1$s%2$s + + \u0020(%1$d рет) + \u0020(%1$d рет) + < Хабар жойылды > Regex Жазба қосу @@ -99,9 +228,12 @@ Арнаны жоюды растаңыз Бұл арнаны шынымен жойғыңыз келе ме? \"%1$s\" арнасын шынымен жойғыңыз келе ме? + Бұл арнаны жою керек пе? + \"%1$s\" арнасын жою керек пе? Жою Арна блогын растау \"%1$s\" арнасын шынымен блоктағыңыз келе ме? + \"%1$s\" арнасын бұғаттау керек пе? Блоктау Блоктан шығару Пайдаланушыны атап өту @@ -125,6 +257,62 @@ imgur.com немесе s-ul.eu сияқты мультимедиаларды кері жүктеу үшін реттелетін хост орнатуға болады. DankChat анықтама алу үшін Chatterino сияқты конфигурация пішімін пайдаланады.\nCheck бұл нұсқаулықты анықтама үшін пайдаланады: https://wiki.chatterino.com/Image%20Uploader/ Толық экранды ажырату Ағынды ажырату + Ағынды көрсету + Ағынды жасыру + Тек аудио + Аудио режимінен шығу + Толық экран + Толық экраннан шығу + Енгізуді жасыру + Енгізуді көрсету + Арна сырғыту навигациясы + Чатта сырғыту арқылы арналарды ауыстыру + Арна модерациясы + + Ең көбі %1$d әрекет + Ең көбі %1$d әрекет + + Хабарларды іздеу + Соңғы хабарлама + Ағынды қосу/өшіру + Арна модерациясы + Толық экран + Енгізуді жасыру + Әрекеттерді баптау + Жөндеу + Тек эмоция + Тек жазылушы + Баяу режим + Баяу режим (%1$s) + Бірегей чат (R9K) + Тек ізбасар + Тек жазылушылар (%1$s) + Реттелмелі + Кез келген + %1$dс + %1$dм + %1$dс + %1$dк + %1$dа + %1$dай + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Қалқан режимін іске қосу керек пе? + Бұл арнаның алдын ала конфигурацияланған қауіпсіздік параметрлерін қолданады, оның ішінде чат шектеулері, AutoMod параметрлері және тексеру талаптары болуы мүмкін. + Іске қосу Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Тіркелгі Қайта кіру Шығу @@ -135,12 +323,15 @@ Чат режимдері Тыйым салуды растау Сіз осы пайдаланушыға тыйым салғыңыз келетініне сенімдісіз бе? + Осы пайдаланушыға тыйым салу керек пе? Тыйым салу Уақытты растау Күту уақыты Хабарды жоюды растау Сіз бұл хабарламаны жойғыңыз келетініне сенімдісіз бе? + Бұл хабарламаны жою керек пе? Өшіру + Чатты тазарту керек пе? Чат режимдерін жаңарту Тек қана эмоция Тек жазылушы @@ -152,6 +343,8 @@ Пәрмен қосу Пәрменді жою Триггер + Бұл триггер кірістірілген пәрменмен сақталған + Бұл триггер басқа пәрменде қолданылуда Пәрмен Пайдаланушы командалары Есеп @@ -175,6 +368,7 @@ Токен бос болуы мүмкін емес Токен жарамсыз Модератор + Бас модератор Болжанған \"%1$s\" Деңгей %1$s Уақыт көрсеткілерін көрсету @@ -188,14 +382,57 @@ Ескертулер Чат Жалпы + Ұсыныстар + Хабарлар + Пайдаланушылар + Эмоттар мен белгілер + Ұсыныс режимі + Теру кезінде сәйкестіктерді ұсыну + Тек триггер таңбасынан кейін ұсыну Шамамен Сыртқы көрінісі DankChat %1$s жылғы @flex3rs және салымшылар Енгізуді көрсету Хабарларды жіберу үшін енгізу өрісін көрсетеді Жүйені әдепкі бақылау - Нағыз қараңғы тақырып - Фон түсін қара түске дейін мәжбүрлейді + Amoled қараңғы режим + OLED экрандар үшін таза қара фон + Акцент түсі + Жүйе тұсқағазына сәйкес + Көк + Көгілдір + Жасыл + Лайм + Сары + Қызғылт сары + Қызыл + Қызғылт + Күлгін + Индиго + Қоңыр + Сұр + Түс стилі + Жүйе әдепкісі + Жүйенің әдепкі түс палитрасын пайдалану + Tonal Spot + Тыныш және бәсең түстер + Neutral + Дерлік монохромды, сәл реңк + Vibrant + Жарқын және қанық түстер + Expressive + Ойнақы түстер ығысқан реңктермен + Rainbow + Реңктердің кең спектрі + Fruit Salad + Ойнақы, көп түсті палитра + Monochrome + Тек қара, ақ және сұр + Fidelity + Акцент түсіне адал қалады + Content + Акцент түсі ұқсас үштік түспен + Көбірек стильдер Дисплей Компоненттер Уақыт бойынша жазылған хабарларды көрсету @@ -207,8 +444,16 @@ Кіші Үлкен Өте үлкен - Эмоция және пайдаланушы ұсыныстары - Теру кезінде эмоциялар мен белсенді пайдаланушыларға арналған ұсыныстарды көрсетеді + Ұсыныстар + Теру кезінде қандай ұсыныстарды көрсету керектігін таңдаңыз + Эмоттар + : арқылы іске қосу + Пайдаланушылар + @ арқылы іске қосу + Twitch командалары + / арқылы іске қосу + Supibot командалары + $ арқылы іске қосу Хабар журналын бастауға жүктеу Қайта қосылғаннан кейін хабар журналын жүктеңіз Байланыс үзілген кезде қабылданбаған хабарларды алу әрекеті @@ -218,7 +463,7 @@ Арна деректері Әзірлеуші параметрлері Дебью режімі - Ұсталған кез келген ерекшеліктер туралы ақпарат береді + Енгізу жолағында жөндеу аналитикасы әрекетін көрсету және апат есептерін жергілікті түрде жинау Таймстам пішімі TTS қосу Белсенді арнаның хабарларын оқиды @@ -232,6 +477,9 @@ URL мекенжайын елемеу TTS-те эмоциялар мен эмотикондарды елемейді Эмоцияларды елемеу + Дыбыс деңгейі + Дыбысты басу + TTS сөйлеп жатқанда басқа қолданбалардың дыбысын азайту TTS Тексерілген жолдар Әр сызықты әр түрлі фондық жарықтықпен сеперациялау @@ -244,18 +492,50 @@ Пайдаланушының ұзақ басу әрекеті Тұрақты басу қалқымалы терезені ашады, ескертулерді ұзақ басыңыз Ескертулерді тұрақты басу, қалқымалы терезені ашу түймешігін ұзақ басыңыз + Лақап аттарды бояу + Түсі орнатылмаған пайдаланушыларға кездейсоқ түс тағайындау Ағылшын тіліне форс тілі Жүйелік жала жабудың орнына TTS дауыс тілін ағылшын тіліне мәжбүрлеу Көрінетін үшінші тарап эмоциясы Twitch қызмет көрсету шарттары & пайдаланушы саясаты: Чип әрекеттерін көрсету Толық экранды, ағындарды қосуға және чат режимдерін реттеуге арналған чиптерді көрсетеді + Таңбалар санағышын көрсету + Енгізу өрісінде код нүктелерінің санын көрсетеді + Енгізуді тазалау түймесін көрсету + Жіберу түймесін көрсету + Енгізу Медиа жүктеп салушы + Жүктеуді баптау + Соңғы жүктеулер + Пайдаланушыларды елемеу тізімі + Еленбейтін пайдаланушылар/аккаунттар тізімі + Құралдар + Тақырып + Қараңғы тақырып + Жарық тақырып + Тізімде жоқ эмоцияларға рұқсат ету + Мақұлданбаған немесе тізімде жоқ эмоцияларды сүзуді өшіреді + Реттелмелі соңғы хабарлар хосты + Ағын ақпаратын алу + Ашық арналардың ағын ақпаратын мерзімді түрде алады. Кірістірілген ағынды іске қосу үшін қажет. + Чатқа қосылмаған жағдайда енгізуді өшіру + Қайталанатын жіберуді қосу + Жіберу түймесі басылып тұрғанда үздіксіз хабар жіберуді қосады + Кіру мерзімі аяқталды! + Сіздің кіру таңбалауышыңыздың мерзімі аяқталды! Қайта кіріңіз. + Қайта кіру + Кіру таңбалауышын тексеру мүмкін болмады, қосылымыңызды тексеріңіз. + Ағынды қайта жүктеуді болдырмау + Бағдар өзгерістерінен немесе DankChat-ты қайта ашқаннан кейін ағынды қайта жүктеуді тәжірибелік түрде болдырмайды. Жаңартылғаннан кейін өзгерістер журналдарын көрсету Не жаңалық Арнайы кіру Twitch пәрменін өңдеуді айналып өтіңіз Twitch пәрмендерін ұстауды өшіреді және олардың орнына чатқа жібереді + Чат жіберу протоколы + Жіберу үшін Helix API пайдалану + Чат хабарламаларын IRC орнына Twitch Helix API арқылы жіберу 7TV тікелей эмоция жаңартулары Тікелей эмоция фондық әрекетті жаңартады Жаңартулар %1$s кейін тоқтайды.\nБұл санды азайту батареяның қызмет ету мерзімін ұзартуы мүмкін. @@ -270,7 +550,51 @@ Тікелей трансляциялар Суреттегі сурет режимін қосыңыз Қолданба фондық режимде болғанда, ағындарға ойнатуды жалғастыруға мүмкіндік береді + Медиа жүктеуші параметрлерін қалпына келтіру + Медиа жүктеуші параметрлерін әдепкіге қалпына келтіргіңіз келетініне сенімдісіз бе? + Қалпына келтіру + Соңғы жүктеулерді тазалау + Жүктеу тарихын тазалағыңыз келетініне сенімдісіз бе? Жүктелген файлдарыңыз жойылмайды. + Тазалау + Үлгі + Регистрді ескеру + Ерекшелеулер + Қосулы + Хабарландыру + Хабар ерекшелеулерін өңдеу + Ерекшелеулер мен елемеулер + Пайдаланушы аты + Блоктау + Ауыстыру + Елемеулер + Хабар елемеулерін өңдеу + Хабарлар + Пайдаланушылар + Қара тізімдегі пайдаланушылар + Twitch + Белгілер + Қайтару + Элемент жойылды + %1$s пайдаланушысы блоктан шығарылды + %1$s пайдаланушысын блоктан шығару сәтсіз аяқталды + Белгі + %1$s пайдаланушысын блоктау сәтсіз аяқталды + Сіздің пайдаланушы атыңыз + Жазылымдар мен оқиғалар + Хабарландырулар + Көру сериялары + Алғашқы хабарлар + Көтерілген хабарлар + Арна ұпайларымен сатып алынған ерекшелеулер Жауаптар + Реттелмелі + Белгілі бір үлгілер негізінде хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір пайдаланушылардың хабарлары үшін хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір пайдаланушылардан (мысалы, боттар) хабарландырулар мен ерекшелеулерді өшіреді. + Белгілер негізінде пайдаланушылардың хабарлары үшін хабарландырулар мен ерекшелеулер жасайды. + Белгілі бір үлгілер негізінде хабарларды елемейді. + Белгілі бір пайдаланушылардың хабарларын елемейді. + Блокталған Twitch пайдаланушыларын басқару. Жеке хабарламалар үшін хабарландырулар жасаңыз Кіру ескірген! Сіздің логиніңіз ескірген және кейбір функцияларға қол жеткізе алмайды. Қайтадан кіріңіз. @@ -285,10 +609,29 @@ Хабарды көшіру Толық хабарды көшіру Хабарға жауап беру + Түпнұсқа хабарға жауап беру Жіпті қарау Хабар идентификатын көшіру Қосымша… + Хабарламаға өту + Хабарлама чат тарихында жоқ + Хабар тарихы + Жаһандық тарих + Тарих: %1$s + Хабарларды іздеу… + Пайдаланушы аты бойынша сүзу + Сілтемелері бар хабарлар + Эмоциялары бар хабарлар + Белгі аты бойынша сүзу + Пайдаланушы + Белгі \@%1$s жауап беру + \@%1$s сыбырлау + Сыбыр жіберу + Жаңа сыбыр + Сыбыр жіберу + Пайдаланушы аты + Бастау Жауап беру жібі табылмады Хат табылмады Эмоцияны пайдалану @@ -315,8 +658,240 @@ Үлкейту Үлкейту Артқа + Ортақ чат Жанды вит %1$d қарау құралы үшін %2$s Жанды вит %1$d қарау құралы үшін %2$s + + %d ай + %d ай + + Ашық бастапқы код лицензиялары + + %2$s санатында %1$d көрермен %3$s уақыт бойы тікелей + %2$s санатында %1$d көрермен %3$s уақыт бойы тікелей + + Ағын санатын көрсету + Ағын санатын да көрсетеді + Енгізуді қосу/өшіру + Хабар таратушы + Әкімші + Қызметкер + Модератор + Бас модератор + Расталған + VIP + Негізін қалаушы + Жазылушы + Реттелмелі ерекшелеу түсін таңдау + Әдепкі + Түс таңдау + Қолданба тақтасын қосу/өшіру + Қате: %s + + DankChat + Бастау үшін баптаймыз. + Бастау + Twitch арқылы кіру + Хабарлар жіберу, эмоцияларды пайдалану, сыбырлар алу және барлық мүмкіндіктерді ашу үшін жүйеге кіріңіз. + Сізден бірнеше Twitch рұқсаттарын бірден беру сұралады, сондықтан әртүрлі мүмкіндіктерді пайдаланғанда қайта рұқсат берудің қажеті болмайды. DankChat модерация мен трансляция әрекеттерін тек сіз сұраған кезде ғана орындайды. + Twitch арқылы кіру + Кіру сәтті аяқталды + Өткізіп жіберу + Жалғастыру + Хабар тарихы + DankChat іске қосылған кезде үшінші тарап қызметінен тарихи хабарларды жүктейді. Хабарларды алу үшін DankChat сол қызметке ашылған арналардың атауларын жібереді. Қызмет көрсету үшін сіз (және басқалар) баратын арналар үшін хабарларды уақытша сақтайды.\n\nБұны кейінірек параметрлерден өзгертуге немесе https://recent-messages.robotty.de/ сайтында толығырақ білуге болады + Қосу + Өшіру + Хабарландырулар + DankChat қолданба фондық режимде болғанда біреу сізді чатта атап өткенде хабарландыру жасай алады. + Хабарландыруларға рұқсат ету + Хабарландыруларсыз қолданба фонда болғанда біреу сізді чатта атап өткенін білмейсіз. + Хабарландыру параметрлерін ашу + + Іздеу, ағындар және басқа мүмкіндіктерге жылдам қол жеткізу үшін реттелетін әрекеттер + Қосымша әрекеттер мен әрекеттер тақтасын баптау үшін мұнда басыңыз + Әрекеттер тақтасында қандай әрекеттер көрсетілетінін мұнда реттей аласыз + Енгізуді жылдам жасыру үшін төмен қарай сырғытыңыз + Енгізуді қайтару үшін мұнда басыңыз + Келесі + Түсіндім + Турды өткізу + Мұнда қосымша арналар қосуға болады + + + Жалпы + Аутентификация + Twitch EventSub қосу + Ескірген PubSub орнына нақты уақыттағы оқиғалар үшін EventSub пайдаланады + EventSub жөндеу шығысын қосу + EventSubқа қатысты жөндеу ақпаратын жүйелік хабарлар ретінде көрсетеді + Токенді қайтарып алу және қайта іске қосу + Ағымдағы токенді жарамсыз етіп, қолданбаны қайта іске қосады + Жүйеге кірілмеген + %1$s үшін арна ID анықталмады + Хабарлама жіберілмеді + Хабарлама тасталды: %1$s (%2$s) + user:write:chat рұқсаты жоқ, қайта кіріңіз + Бұл арнада хабарлама жіберуге рұқсат жоқ + Хабарлама тым үлкен + Жылдамдық шектелді, біраз уақыттан кейін қайталаңыз + Жіберу сәтсіз: %1$s + + + %1$s пәрменін пайдалану үшін жүйеге кіруіңіз керек + Бұл пайдаланушы атына сәйкес пайдаланушы табылмады. + Белгісіз қате орын алды. + Бұл әрекетті орындауға рұқсатыңыз жоқ. + Қажетті рұқсат жоқ. Аккаунтыңызбен қайта кіріп, қайталаңыз. + Кіру деректері жоқ. Аккаунтыңызбен қайта кіріп, қайталаңыз. + Қолданысы: /block <user> + %1$s пайдаланушысы сәтті бұғатталды + %1$s пайдаланушысын бұғаттау мүмкін емес, бұл атпен пайдаланушы табылмады! + %1$s пайдаланушысын бұғаттау мүмкін емес, белгісіз қате орын алды! + Қолданысы: /unblock <user> + %1$s пайдаланушысының бұғаты сәтті алынды + %1$s пайдаланушысының бұғатын алу мүмкін емес, бұл атпен пайдаланушы табылмады! + %1$s пайдаланушысының бұғатын алу мүмкін емес, белгісіз қате орын алды! + Арна тікелей эфирде емес. + Эфир уақыты: %1$s + Бұл бөлмеде сізге қолжетімді пәрмендер: %1$s + Қолданысы: %1$s <username> <message>. + Сыбыс жіберілді. + Сыбыс жіберу сәтсіз - %1$s + Қолданысы: %1$s <message> - Хабарламаңызды бөлектеу арқылы назар аударыңыз. + Хабарландыру жіберу сәтсіз - %1$s + Бұл арнада модераторлар жоқ. + Бұл арнаның модераторлары: %1$s. + Модераторлар тізімін алу сәтсіз - %1$s + Қолданысы: %1$s <username> - Пайдаланушыға модератор мәртебесін беру. + Сіз %1$s пайдаланушысын осы арнаның модераторы ретінде қостыңыз. + Арна модераторын қосу сәтсіз - %1$s + Қолданысы: %1$s <username> - Пайдаланушыдан модератор мәртебесін алу. + Сіз %1$s пайдаланушысын осы арнаның модераторлығынан алып тастадыңыз. + Арна модераторын алып тастау сәтсіз - %1$s + Бұл арнада VIP жоқ. + Бұл арнаның VIP-тері: %1$s. + VIP тізімін алу сәтсіз - %1$s + Қолданысы: %1$s <username> - Пайдаланушыға VIP мәртебесін беру. + Сіз %1$s пайдаланушысын осы арнаның VIP-і ретінде қостыңыз. + VIP қосу сәтсіз - %1$s + Қолданысы: %1$s <username> - Пайдаланушыдан VIP мәртебесін алу. + Сіз %1$s пайдаланушысын осы арнаның VIP-інен алып тастадыңыз. + VIP алып тастау сәтсіз - %1$s + Қолданысы: %1$s <username> [себеп] - Пайдаланушыға чатта жазуға тұрақты тыйым салу. Себеп міндетті емес және мақсатты пайдаланушыға мен басқа модераторларға көрсетіледі. Банды алу үшін /unban пайдаланыңыз. + Пайдаланушыны бандау сәтсіз - Өзіңізді бандай алмайсыз. + Пайдаланушыны бандау сәтсіз - Стримерді бандай алмайсыз. + Пайдаланушыны бандау сәтсіз - %1$s + Қолданысы: %1$s <username> - Пайдаланушыдан банды алу. + Пайдаланушыдан банды алу сәтсіз - %1$s + Қолданысы: %1$s <username> [ұзақтық][уақыт бірлігі] [себеп] - Пайдаланушыға чатта жазуға уақытша тыйым салу. Ұзақтық (міндетті емес, әдепкі: 10 минут) оң бүтін сан болуы керек; уақыт бірлігі (міндетті емес, әдепкі: s) s, m, h, d, w бірі болуы керек; ең ұзақ мерзім - 2 апта. Себеп міндетті емес және мақсатты пайдаланушыға мен басқа модераторларға көрсетіледі. + Пайдаланушыны бандау сәтсіз - Өзіңізге тайм-аут бере алмайсыз. + Пайдаланушыны бандау сәтсіз - Стримерге тайм-аут бере алмайсыз. + Пайдаланушыға тайм-аут беру сәтсіз - %1$s + Чат хабарламаларын жою сәтсіз - %1$s + Қолданысы: /delete <msg-id> - Көрсетілген хабарламаны жояды. + Жарамсыз msg-id: \"%1$s\". + Чат хабарламаларын жою сәтсіз - %1$s + Қолданысы: /color <color> - Түс Twitch қолдайтын түстердің бірі (%1$s) немесе Turbo не Prime болса hex code (#000000) болуы керек. + Сіздің түсіңіз %1$s болып өзгертілді + Түсті %1$s болып өзгерту сәтсіз - %2$s + %1$s%2$s уақытында стрим маркері сәтті қосылды. + Стрим маркерін жасау сәтсіз - %1$s + Қолданысы: /commercial <length> - Ағымдағы арна үшін көрсетілген ұзақтықтағы жарнаманы бастайды. Жарамды ұзақтық нұсқалары: 30, 60, 90, 120, 150 және 180 секунд. + + %1$d секундтық жарнама үзілісі басталды. Сіз әлі тікелей эфирде екеніңізді және барлық көрермендер жарнама алмайтынын есте сақтаңыз. Келесі жарнаманы %2$d секундтан кейін іске қоса аласыз. + %1$d секундтық жарнама үзілісі басталды. Сіз әлі тікелей эфирде екеніңізді және барлық көрермендер жарнама алмайтынын есте сақтаңыз. Келесі жарнаманы %2$d секундтан кейін іске қоса аласыз. + + Жарнаманы бастау сәтсіз - %1$s + Қолданысы: /raid <username> - Пайдаланушыға рейд жасау. Тек стример рейд бастай алады. + Жарамсыз пайдаланушы аты: %1$s + Сіз %1$s-ге рейд бастадыңыз. + Рейд бастау сәтсіз - %1$s + Сіз рейдті тоқтаттыңыз. + Рейдті тоқтату сәтсіз - %1$s + Қолданысы: %1$s [ұзақтық] - Тек жазылушылар режимін қосады (тек жазылушылар чатта жаза алады). Ұзақтық (міндетті емес, әдепкі: 0 минут) оң сан болуы керек, одан кейін уақыт бірлігі (m, h, d, w); ең ұзақ мерзім - 3 ай. + Бұл бөлме қазірдің өзінде %1$s тек жазылушылар режимінде. + Чат параметрлерін жаңарту сәтсіз - %1$s + Бұл бөлме тек жазылушылар режимінде емес. + Бұл бөлме қазірдің өзінде тек эмоция режимінде. + Бұл бөлме тек эмоция режимінде емес. + Бұл бөлме қазірдің өзінде тек жазылушылар режимінде. + Бұл бөлме тек жазылушылар режимінде емес. + Бұл бөлме қазірдің өзінде бірегей чат режимінде. + Бұл бөлме бірегей чат режимінде емес. + Қолданысы: %1$s [ұзақтық] - Баяу режимді қосады (пайдаланушылардың хабарлама жіберу жиілігін шектейді). Ұзақтық (міндетті емес, әдепкі: 30) оң секунд саны болуы керек; ең көбі 120. + Бұл бөлме қазірдің өзінде %1$d секундтық баяу режимде. + Бұл бөлме баяу режимде емес. + Қолданысы: %1$s <username> - Көрсетілген Twitch пайдаланушысына шаутаут жібереді. + %1$s пайдаланушысына шаутаут жіберілді + Шаутаут жіберу сәтсіз - %1$s + Қалқан режимі қосылды. + Қалқан режимі өшірілді. + Қалқан режимін жаңарту сәтсіз - %1$s + Өзіңізге сыбыс жібере алмайсыз. + Twitch шектеулеріне байланысты сыбыс жіберу үшін расталған телефон нөмірі қажет. Телефон нөмірін Twitch параметрлерінде қосуға болады. https://www.twitch.tv/settings/security + Алушы бөтен адамдардан немесе сізден тікелей сыбыстарға рұқсат бермейді. + Twitch сіздің жылдамдығыңызды шектеді. Бірнеше секундтан кейін қайталаңыз. + Күніне ең көбі 40 бірегей алушыға сыбыс жібере аласыз. Күндік лимит шегінде секундына ең көбі 3 сыбыс және минутына ең көбі 100 сыбыс жібере аласыз. + Twitch шектеулеріне байланысты бұл пәрменді тек стример пайдалана алады. Twitch веб-сайтын пайдаланыңыз. + %1$s қазірдің өзінде осы арнаның модераторы. + %1$s қазір VIP, /unvip жасап, пәрменді қайталаңыз. + %1$s осы арнаның модераторы емес. + %1$s осы арнада бандалмаған. + %1$s осы арнада бұрыннан бандалған. + Сіз %2$s үшін %1$s жасай алмайсыз. + Бұл пайдаланушыға қайшылықты бан операциясы болды. Қайталап көріңіз. + Түс Twitch қолдайтын түстердің бірі (%1$s) немесе Turbo не Prime болса hex code (#000000) болуы керек. + Жарнама іске қосу үшін тікелей эфирде болуыңыз керек. + Келесі жарнаманы іске қосу үшін күту кезеңінің аяқталуын күтуіңіз керек. + Пәрмен нөлден үлкен жарнама үзілісінің ұзақтығын қамтуы керек. + Сізде белсенді рейд жоқ. + Арна өзіне рейд жасай алмайды. + Стример өзіне шаутаут бере алмайды. + Стример тікелей эфирде емес немесе бір немесе одан көп көрермені жоқ. + Ұзақтық жарамды ауқымнан тыс: %1$s. + Хабарлама бұрыннан өңделген. + Мақсатты хабарлама табылмады. + Хабарламаңыз тым ұзын. + Жылдамдығыңыз шектелді. Біраз уақыттан кейін қайталаңыз. + Мақсатты пайдаланушы + Журнал көрсеткіш + Қолданба журналдарын көру + Журналдар + Журналдарды бөлісу + Журналдарды көру + Журнал файлдары жоқ + Журналдарды іздеу + + %1$d таңдалды + %1$d таңдалды + + Таңдалған журналдарды көшіру + Таңдауды тазалау + Апат анықталды + Қолданба соңғы сеанс кезінде апатқа ұшырады. + Ағын: %1$s + Көшіру + Чат есебі + #flex3rs арнасына қосылып, жіберу үшін апат жиынтығын дайындайды + Электрондық пошта есебі + Толық апат есебін электрондық пошта арқылы жіберу + Апат есебін электрондық пошта арқылы жіберу + Келесі деректер есепке қосылады: + Стек ізі + Ағымдағы журнал файлын қосу + Апат есептері + Соңғы апат есептерін көру + Апат есептері табылмады + Апат есебі + Апат есебін бөлісу + Жою + Бұл апат есебін жою керек пе? + Барлығын тазалау + Барлық апат есептерін жою керек пе? + Төменге айналдыру + Тарихты көру + Ағымдағы сүзгілерге сәйкес хабарлар жоқ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 32a155b1b..000000000 --- a/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - #543589 - #773031 - #004f57 - #2d5000 - #574500 - \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 04ada92fc..ad49ad795 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,24 +1,13 @@ - + - - - - - diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index ca6013e42..d8feef1b1 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -10,6 +10,7 @@ ଚ୍ୟାନେଲ୍ ଯୋଡନ୍ତୁ | ଚ୍ୟାନେଲର ନାମ ପରିବର୍ତ୍ତନ କରନ୍ତୁ | ଠିକ୍ ଅଛି + ସଞ୍ଚୟ କରନ୍ତୁ ବାତିଲ୍ କରନ୍ତୁ | ଖାରଜ କରନ୍ତୁ କପି କରନ୍ତୁ @@ -23,8 +24,10 @@ ଚ୍ୟାନେଲକୁ ହଟାନ୍ତୁ | ଚ୍ୟାନେଲ ଅବରୋଧିତ | କ chan ଣସି ଚ୍ୟାନେଲ୍ ଯୋଡି ନାହିଁ | + ଚାଟିଂ ଆରମ୍ଭ କରିବାକୁ ଏକ ଚ୍ୟାନେଲ ଯୋଡନ୍ତୁ ଲଗଆଉଟ୍ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଲଗଆଉଟ୍ କରିବାକୁ ଚାହୁଁଛନ୍ତି? + ଲଗଆଉଟ୍ କରିବେ? ପ୍ରସ୍ଥାନ କର ମିଡିଆ ଅପଲୋଡ୍ କରନ୍ତୁ | ଚିତ୍ର ନିଅ @@ -42,26 +45,74 @@ FeelsDankMan ପୃଷ୍ଠଭୂମିରେ ଚାଲୁଥିବା DankChat | ଇମୋଟ୍ ମେନୁ ଖୋଲ | + ଇମୋଟ୍ ମେନୁ ବନ୍ଦ କରନ୍ତୁ + କୌଣସି ସାମ୍ପ୍ରତିକ ଇମୋଟ୍ ନାହିଁ + ଇମୋଟ୍ Twitch.tv କୁ ଲଗ୍ଇନ୍ କରନ୍ତୁ | ଚାଟିଂ ଆରମ୍ଭ କରନ୍ତୁ | ବିଚ୍ଛିନ୍ନ ହୋଇଛି | ଲଗ୍ ଇନ୍ ହୋଇନାହିଁ | ପ୍ରତ୍ୟୁତ୍ତର - ତୁମର ନୂତନ ଉଲ୍ଲେଖ ଅଛି | + Send announcement ତୁମର ନୂତନ ଉଲ୍ଲେଖ ଅଛି | %1$s ତୁମକୁ କେବଳ ଉଲ୍ଲେଖ କରିଛି | #%2$s ଆପଣ ଏଥିରେ ଉଲ୍ଲେଖ କରିଛନ୍ତି | #%1$s ଯେପରି ଲଗ୍ ଇନ୍ କରୁଛି | %1$s ଲଗଇନ୍ କରିବାରେ ବିଫଳ | ନକଲ: %1$s + ଅପଲୋଡ୍ ସମ୍ପୂର୍ଣ୍ଣ: %1$s ଅପଲୋଡ୍ ସମୟରେ ତ୍ରୁଟି | ଅପଲୋଡ୍ ସମୟରେ ତ୍ରୁଟି: %1$s + ଅପଲୋଡ୍ + କ୍ଲିପବୋର୍ଡରେ କପି ହୋଇଛି + URL କପି କରନ୍ତୁ ପୁନ ry ଚେଷ୍ଟା କରନ୍ତୁ | ଇମୋଟ୍ ପୁନ o ଲୋଡ୍ ହୋଇଛି | ଡାଟା ଲୋଡିଂ ବିଫଳ ହେଲା: %1$s ଏକାଧିକ ତ୍ରୁଟି ସହିତ ଡାଟା ଲୋଡିଂ ବିଫଳ ହେଲା |:\n%1$s + DankChat ବ୍ୟାଜ୍ + ଗ୍ଲୋବାଲ୍ ବ୍ୟାଜ୍ + ଗ୍ଲୋବାଲ୍ FFZ ଇମୋଟ୍ + ଗ୍ଲୋବାଲ୍ BTTV ଇମୋଟ୍ + ଗ୍ଲୋବାଲ୍ 7TV ଇମୋଟ୍ + ଚ୍ୟାନେଲ ବ୍ୟାଜ୍ + FFZ ଇମୋଟ୍ + BTTV ଇମୋଟ୍ + 7TV ଇମୋଟ୍ + Twitch ଇମୋଟ୍ + ଚିୟରମୋଟ୍ + ସାମ୍ପ୍ରତିକ ବାର୍ତ୍ତା + %1$s (%2$s) + ପ୍ରଥମ ଥର ଚାଟ୍ + ଉଚ୍ଚତର ଚାଟ୍ + ବିଶାଳ ଇମୋଟ + ଆନିମେଟେଡ ବାର୍ତ୍ତା + ରିଡିମ କରାଯାଇଛି %1$s + %1$d ସେକେଣ୍ଡ + %1$d ସେକେଣ୍ଡ + + + %1$d ମିନିଟ୍ + %1$d ମିନିଟ୍ + + + %1$d ଘଣ୍ଟା + %1$d ଘଣ୍ଟା + + + %1$d ଦିନ + %1$d ଦିନ + + + %1$d ସପ୍ତାହ + %1$d ସପ୍ତାହ + + %1$s %2$s + %1$s %2$s %3$s ଲେପନ କରନ୍ତୁ | ଚ୍ୟାନେଲ ନାମ | + ଚ୍ୟାନେଲ ପୂର୍ବରୁ ଯୋଡ଼ା ହୋଇସାରିଛି ସମ୍ପ୍ରତି + ବ୍ୟାକସ୍ପେସ୍ ଗ୍ରାହକଗଣ | ଚ୍ୟାନେଲ୍‌ ଗ୍ଲୋବାଲ୍‌ @@ -69,10 +120,10 @@ ପୁନ-ସଂଯୋଗିତ | ଏହି ଚ୍ୟାନେଲ୍ ବିଦ୍ୟମାନ ନାହିଁ | ବିଚ୍ଛିନ୍ନ ହୋଇଛି | - ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପଲବ୍ଧ ନାହିଁ | (ତ୍ରୁଟି %1$s) - ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପଲବ୍ଧ ନାହିଁ | + ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପಲ ବ୍ଧ ନାହିଁ | (ତ୍ରୁଟି %1$s) + ବାର୍ତ୍ତା ଇତିହାସ ସେବା ଉପಲ ବ୍ଧ ନାହିଁ | ବାର୍ତ୍ତା ଇତିହାସ ସେବା ପୁନରୁଦ୍ଧାର, ବାର୍ତ୍ତା ଇତିହାସରେ ଫାଟ ଥାଇପାରେ | - ବାର୍ତ୍ତା ଇତିହାସ ଉପଲବ୍ଧ ନାହିଁ କାରଣ ଏହି ଚ୍ୟାନେଲ ସେବାରୁ ବାଦ ଦିଆଯାଇଛି | + ବାର୍ତ୍ତା ଇତିହାସ ଉପಲ ବ୍ଧ ନାହିଁ କାରଣ ଏହି ଚ୍ୟାନେଲ ସେବାରୁ ବାଦ ଦିଆଯାଇଛି | ଆପଣ କ historical ତିହାସିକ ବାର୍ତ୍ତା ଦେଖୁ ନାହାଁନ୍ତି କାରଣ ଆପଣ ସାମ୍ପ୍ରତିକ-ବାର୍ତ୍ତା ଏକୀକରଣରୁ ଚୟନ କରିଛନ୍ତି | FFZ ଇମୋଟସ୍ ଲୋଡ୍ କରିବାରେ ବିଫଳ (ଇ %1$s) BTTV ରେଟ୍ ଲୋଡ୍ କରିବାରେ ବିଫଳ | (ଇ %1$s) @@ -81,6 +132,85 @@ %1$s 7TV ଇମୋଟ ଯୋଡିଛି | %2$s. %1$s 7TV ଇମୋଟ ନାମ ପରିବର୍ତ୍ତନ କରନ୍ତୁ | %2$s କୁ %3$s. %1$s 7TV ଇମୋଟ ଯୋଡିଛି | %2$s. + + କାରଣ ପାଇଁ ଏକ ବାର୍ତ୍ତା ଧରାଯାଇଛି: %1$s। ଅନୁମତି ଦେଲେ ଏହା ଚାଟ୍ ରେ ପୋଷ୍ଟ ହେବ। + ଅନୁମତି ଦିଅନ୍ତୁ + ପ୍ରତ୍ୟାଖ୍ୟାନ କରନ୍ତୁ + ଅନୁମୋଦିତ + ପ୍ରତ୍ୟାଖ୍ୟାତ + ମିଆଦ ସମାପ୍ତ + ହେ! ଆପଣଙ୍କ ବାର୍ତ୍ତା ମଡ୍‌ମାନଙ୍କ ଦ୍ୱାରା ଯାଞ୍ଚ ହେଉଛି ଏବଂ ଏପର୍ଯ୍ୟନ୍ତ ପଠାଯାଇ ନାହିଁ। + ମଡ୍‌ମାନେ ଆପଣଙ୍କ ବାର୍ତ୍ତା ଗ୍ରହଣ କରିଛନ୍ତି। + ମଡ୍‌ମାନେ ଆପଣଙ୍କ ବାର୍ତ୍ତା ପ୍ରତ୍ୟାଖ୍ୟାନ କରିଛନ୍ତି। + %1$s (ସ୍ତର %2$d) + + %1$d ଅବରୋଧିତ ଶବ୍ଦ ସହ ମେଳ ଖାଉଛି %2$s + %1$d ଅବରୋଧିତ ଶବ୍ଦ ସହ ମେଳ ଖାଉଛି %2$s + + AutoMod ବାର୍ତ୍ତା %1$s କରିବାରେ ବିଫଳ - ବାର୍ତ୍ତା ପୂର୍ବରୁ ପ୍ରକ୍ରିୟା ହୋଇସାରିଛି। + AutoMod ବାର୍ତ୍ତା %1$s କରିବାରେ ବିଫଳ - ଆପଣଙ୍କୁ ପୁନ ry ପ୍ରମାଣୀକରଣ କରିବାକୁ ପଡିବ। + AutoMod ବାର୍ତ୍ତା %1$s କରିବାରେ ବିଫଳ - ଆପଣଙ୍କୁ ସେହି କାର୍ଯ୍ୟ କରିବାର ଅନୁମତି ନାହିଁ। + AutoMod ବାର୍ତ୍ତା %1$s କରିବାରେ ବିଫଳ - ଲକ୍ଷ୍ୟ ବାର୍ତ୍ତା ମିଳିଲା ନାହିଁ। + AutoMod ବାର୍ତ୍ତା %1$s କରିବାରେ ବିଫଳ - ଏକ ଅଜଣା ତ୍ରୁଟି ଘଟିଲା। + %1$s AutoMod ରେ %2$s କୁ ଅବରୋଧିତ ଶବ୍ଦ ଭାବେ ଯୋଡିଛି। + %1$s AutoMod ରେ %2$s କୁ ଅନୁମୋଦିତ ଶବ୍ଦ ଭାବେ ଯୋଡିଛି। + %1$s AutoMod ରୁ %2$s କୁ ଅବରୋଧିତ ଶବ୍ଦ ଭାବେ ହଟାଇଛି। + %1$s AutoMod ରୁ %2$s କୁ ଅନୁମୋଦିତ ଶବ୍ଦ ଭାବେ ହଟାଇଛି। + + ଆପଣଙ୍କୁ %1$s ପାଇଁ ସମୟ ସମାପ୍ତ କରାଯାଇଛି + ଆପଣଙ୍କୁ %2$s ଦ୍ୱାରା %1$s ପାଇଁ ସମୟ ସମାପ୍ତ କରାଯାଇଛି + ଆପଣଙ୍କୁ %2$s ଦ୍ୱାରା %1$s ପାଇଁ ସମୟ ସମାପ୍ତ କରାଯାଇଛି: %3$s + %1$s %2$s କୁ %3$s ପାଇଁ ସମୟ ସମାପ୍ତ କଲେ + %1$s %2$s କୁ %3$s ପାଇଁ ସମୟ ସମାପ୍ତ କଲେ: %4$s + %1$s କୁ %2$s ପାଇଁ ସମୟ ସମାପ୍ତ କରାଯାଇଛି + ଆପଣଙ୍କୁ ନିଷେଧ କରାଯାଇଛି + ଆପଣଙ୍କୁ %1$s ଦ୍ୱାରା ନିଷେଧ କରାଯାଇଛି + ଆପଣଙ୍କୁ %1$s ଦ୍ୱାରା ନିଷେଧ କରାଯାଇଛି: %2$s + %1$s %2$s କୁ ନିଷେଧ କଲେ + %1$s %2$s କୁ ନିଷେଧ କଲେ: %3$s + %1$s କୁ ସ୍ଥାୟୀ ଭାବେ ନିଷେଧ କରାଯାଇଛି + %1$s %2$s ର ସମୟ ସମାପ୍ତ ହଟାଇଲେ + %1$s %2$s ର ନିଷେଧ ହଟାଇଲେ + %1$s %2$s କୁ ମୋଡରେଟର୍ କଲେ + %1$s %2$s ର ମୋଡରେଟର୍ ହଟାଇଲେ + %1$s %2$s କୁ ଏହି ଚ୍ୟାନେଲର VIP ଭାବେ ଯୋଡିଛି + %1$s %2$s କୁ ଏହି ଚ୍ୟାନେଲର VIP ରୁ ହଟାଇଛି + %1$s %2$s କୁ ଚେତାବନୀ ଦେଇଛି + %1$s %2$s କୁ ଚେତାବନୀ ଦେଇଛି: %3$s + %1$s %2$s କୁ ଏକ ରେଡ୍ ଆରମ୍ଭ କଲେ + %1$s %2$s କୁ ରେଡ୍ ବାତିଲ କଲେ + %1$s %2$s ର ବାର୍ତ୍ତା ବିଲୋପ କଲେ + %1$s %2$s ର ବାର୍ତ୍ତା ବିଲୋପ କଲେ: %3$s + %1$s ର ଏକ ବାର୍ତ୍ତା ବିଲୋପ କରାଯାଇଛି + %1$s ର ଏକ ବାର୍ତ୍ତା ବିଲୋପ କରାଯାଇଛି: %2$s + %1$s ଚାଟ୍ ସଫା କଲେ + ଜଣେ ମୋଡରେଟର୍ ଦ୍ୱାରା ଚାଟ୍ ସଫା କରାଯାଇଛି + %1$s ଇମୋଟ୍-ଓନ୍ଲି ମୋଡ୍ ଚାଲୁ କଲେ + %1$s ଇମୋଟ୍-ଓନ୍ଲି ମୋଡ୍ ବନ୍ଦ କଲେ + %1$s ଅନୁଗାମୀ-ଓନ୍ଲି ମୋଡ୍ ଚାଲୁ କଲେ + %1$s ଅନୁଗାମୀ-ଓନ୍ଲି ମୋଡ୍ ଚାଲୁ କଲେ (%2$s) + %1$s ଅନୁଗାମୀ-ଓନ୍ଲି ମୋଡ୍ ବନ୍ଦ କଲେ + %1$s ୟୁନିକ୍-ଚାଟ୍ ମୋଡ୍ ଚାଲୁ କଲେ + %1$s ୟୁନିକ୍-ଚାଟ୍ ମୋଡ୍ ବନ୍ଦ କଲେ + %1$s ସ୍ଲୋ ମୋଡ୍ ଚାଲୁ କଲେ + %1$s ସ୍ଲୋ ମୋଡ୍ ଚାଲୁ କଲେ (%2$s) + %1$s ସ୍ଲୋ ମୋଡ୍ ବନ୍ଦ କଲେ + %1$s ଗ୍ରାହକ-ଓନ୍ଲି ମୋଡ୍ ଚାଲୁ କଲେ + %1$s ଗ୍ରାହକ-ଓନ୍ଲି ମୋଡ୍ ବନ୍ଦ କଲେ + + %1$s %4$s ରେ %2$s କୁ %3$s ପାଇଁ ସମୟ ସମାପ୍ତ କଲେ + %1$s %4$s ରେ %2$s କୁ %3$s ପାଇଁ ସମୟ ସମାପ୍ତ କଲେ: %5$s + %1$s %3$s ରେ %2$s ର ସମୟ ସମାପ୍ତ ହଟାଇଲେ + %1$s %3$s ରେ %2$s କୁ ନିଷେଧ କଲେ + %1$s %3$s ରେ %2$s କୁ ନିଷେଧ କଲେ: %4$s + %1$s %3$s ରେ %2$s ର ନିଷେଧ ହଟାଇଲେ + %1$s %3$s ରେ %2$s ର ବାର୍ତ୍ତା ବିଲୋପ କଲେ + %1$s %3$s ରେ %2$s ର ବାର୍ତ୍ତା ବିଲୋପ କଲେ: %4$s + %1$s%2$s + + \u0020(%1$d ଥର) + \u0020(%1$d ଥର) + < ବାର୍ତ୍ତା ବିଲୋପ ହୋଇଛି | > ରେଜେକ୍ସ ଏକ ଏଣ୍ଟ୍ରି ଯୋଡନ୍ତୁ | @@ -98,9 +228,12 @@ ଚ୍ୟାନେଲ ଅପସାରଣକୁ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଏହି ଚ୍ୟାନେଲ ଅପସାରଣ କରିବାକୁ ଚାହୁଁଛନ୍ତି କି? ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଚ୍ୟାନେଲ୍ ଅପସାରଣ କରିବାକୁ ଚାହୁଁଛନ୍ତି | \"%1$s\"? + ଏହି ଚ୍ୟାନେଲ ହଟାଇବେ? + \"%1$s\" ଚ୍ୟାନେଲ ହଟାଇବେ? ବାହାର କରନ୍ତୁ ଚ୍ୟାନେଲ ବ୍ଲକ୍ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଚ୍ୟାନେଲକୁ ଅବରୋଧ କରିବାକୁ ଚାହୁଁଛନ୍ତି | \"%1$s\"? + \"%1$s\" ଚ୍ୟାନେଲ ଅବରୋଧ କରିବେ? ଅବରୋଧ କରନ୍ତୁ | ଅନ୍-ବ୍ଲକ୍ | ବ୍ୟବହାରକାରୀଙ୍କୁ ଉଲ୍ଲେଖ କରନ୍ତୁ | @@ -124,6 +257,62 @@ ମିଡିଆ ଅପଲୋଡ୍ କରିବା ପାଇଁ ଆପଣ ଏକ କଷ୍ଟମ୍ ହୋଷ୍ଟ ସେଟ୍ କରିପାରିବେ, ଯେପରିକି imgur.com କିମ୍ବା s-ul.eu | DankChat ଚାଟେରିନୋ ସହିତ ସମାନ ବିନ୍ୟାସ ଫର୍ମାଟ୍ ବ୍ୟବହାର କରେ |\nସାହାଯ୍ୟ ପାଇଁ ଏହି ଗାଇଡ୍ ଯାଞ୍ଚ କରନ୍ତୁ: https://wiki.chatterino.com/Image%20Uploader/ ଫୁଲ୍ ସ୍କ୍ରିନ୍ ଟୋଗଲ୍ କରନ୍ତୁ | ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ | + ଷ୍ଟ୍ରିମ୍ ଦେଖାନ୍ତୁ + ଷ୍ଟ୍ରିମ୍ ଲୁଚାନ୍ତୁ + କେବଳ ଅଡିଓ + କେବଳ ଅଡିଓ ମୋଡରୁ ବାହାରନ୍ତୁ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ ବାହାରନ୍ତୁ + ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ + ଇନପୁଟ୍ ଦେଖାନ୍ତୁ + ଚ୍ୟାନେଲ ସ୍ୱାଇପ୍ ନାଭିଗେସନ୍ + ଚାଟରେ ସ୍ୱାଇପ୍ କରି ଚ୍ୟାନେଲ ବଦଳାନ୍ତୁ + ଚ୍ୟାନେଲ ମଡରେସନ + + ସର୍ବାଧିକ %1$d କ୍ରିୟା + ସର୍ବାଧିକ %1$d କ୍ରିୟା + + ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ + ଶେଷ ବାର୍ତ୍ତା + ଷ୍ଟ୍ରିମ୍ ଟୋଗଲ୍ କରନ୍ତୁ + ଚ୍ୟାନେଲ ମଡରେସନ + ଫୁଲ୍ ସ୍କ୍ରିନ୍ + ଇନପୁଟ୍ ଲୁଚାନ୍ତୁ + କ୍ରିୟାଗୁଡିକ ବିନ୍ୟାସ କରନ୍ତୁ + ଡିବଗ୍ + କେବଳ ଇମୋଟ୍ + କେବଳ ଗ୍ରାହକ + ସ୍ଲୋ ମୋଡ୍ + ମନ୍ଥର ମୋଡ୍ (%1$s) + ୟୁନିକ୍ ଚାଟ୍ (R9K) + କେବଳ ଅନୁଗାମୀ + କେବଳ ଅନୁସରଣକାରୀ (%1$s) + କଷ୍ଟମ + ଯେକୌଣସି + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + ଶିଲ୍ଡ ମୋଡ୍ ସକ୍ରିୟ କରିବେ? + ଏହା ଚ୍ୟାନେଲର ପୂର୍ବ-କନଫିଗର୍ ହୋଇଥିବା ସୁରକ୍ଷା ସେଟିଂ ପ୍ରୟୋଗ କରିବ, ଯାହା ଚାଟ୍ ସୀମାବଦ୍ଧତା, AutoMod ସେଟିଂ ଏବଂ ଯାଞ୍ଚ ଆବଶ୍ୟକତା ଅନ୍ତର୍ଭୁକ୍ତ କରିପାରେ। + ସକ୍ରିୟ କରନ୍ତୁ Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel ଆକାଉଣ୍ଟ୍ ପୁନର୍ବାର ଲଗ୍ ଇନ୍ କରନ୍ତୁ | ପ୍ରସ୍ଥାନ କର @@ -134,12 +323,15 @@ ଚାଟ୍ ମୋଡ୍ | ନିଷେଧ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ କି ଆପଣ ଏହି ଉପଭୋକ୍ତାଙ୍କୁ ବାନ୍ଧି ଦେବାକୁ ଚାହୁଁଛନ୍ତି କି? + ଏହି ଉପଭୋକ୍ତାଙ୍କୁ ନିଷେଧ କରିବେ? ନିଷେଧ | ସମୟ ସମାପ୍ତ ନିଶ୍ଚିତ କରନ୍ତୁ | ସମୟ-ସମାପ୍ତି ବାର୍ତ୍ତା ବିଲୋପକୁ ନିଶ୍ଚିତ କରନ୍ତୁ | ଆପଣ ନିଶ୍ଚିତ ଭାବରେ ଏହି ସନ୍ଦେଶ ବିଲୋପ କରିବାକୁ ଚାହୁଁଛନ୍ତି କି? + ଏହି ବାର୍ତ୍ତା ବିଲୋପ କରିବେ? ବିଲୋପ ହୋଇଛି + ଚାଟ୍ ସଫା କରିବେ? ଚାଟ୍ ମୋଡ୍ ଅପଡେଟ୍ କରନ୍ତୁ Emote କେବଳ କେବଳ ଗ୍ରାହକ @@ -151,6 +343,8 @@ ଏକ କମାଣ୍ଡ ଯୋଡନ୍ତୁ କମାଣ୍ଡ ଅପସାରଣ କରନ୍ତୁ ଟ୍ରିଗର୍ + ଏହି ଟ୍ରିଗର୍ ଏକ ଅନ୍ତର୍ନିର୍ମିତ କମାଣ୍ଡ ଦ୍ୱାରା ସଂରକ୍ଷିତ + ଏହି ଟ୍ରିଗର୍ ଅନ୍ୟ ଏକ କମାଣ୍ଡ ଦ୍ୱାରା ବ୍ୟବହୃତ ହେଉଛି କମାଣ୍ଡ[ସମ୍ପାଦନା] କଷ୍ଟମ୍ କମାଣ୍ଡସ୍ ରିପୋର୍ଟ @@ -174,6 +368,7 @@ ଟୋକେନ୍ ଖାଲି ହୋଇପାରିବ ନାହିଁ | ଟୋକନ୍ ଅବ alid ଧ ଅଟେ | ମୋଡରେଟର୍ + ମୁଖ୍ୟ ମୋଡରେଟର୍ ପୂର୍ବାନୁମାନ କରାଯାଇଛି | \"%1$s\" ସ୍ତର %1$s ଟାଇମଷ୍ଟ୍ୟାମ୍ପ ଦେଖାନ୍ତୁ | @@ -187,14 +382,57 @@ ଵିଜ୍ଞପ୍ତି ଚାଟ୍ ସାଧାରଣ + ପରାମର୍ଶ + ବାର୍ତ୍ତା + ଉପଭୋକ୍ତା + ଇମୋଟ ଏବଂ ବ୍ୟାଜ୍ + ପରାମର୍ଶ ମୋଡ୍ + ଟାଇପ୍ କରିବା ସମୟରେ ମେଳ ପ୍ରସ୍ତାବ କରନ୍ତୁ + କେବଳ ଟ୍ରିଗର ଅକ୍ଷର ପରେ ପ୍ରସ୍ତାବ କରନ୍ତୁ ଵିଷୟରେ ରୂପ DankChat %1$s @ flex3rs ଏବଂ ସହଯୋଗୀମାନଙ୍କ ଦ୍ୱାରା ପ୍ରସ୍ତୁତ | ଇନପୁଟ୍ ଦେଖାନ୍ତୁ | ବାର୍ତ୍ତା ପଠାଇବା ପାଇଁ ଇନପୁଟ୍ ଫିଲ୍ଡ ପ୍ରଦର୍ଶିତ କରେ | ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟକୁ ଅନୁସରଣ କରିବା - ପ୍ରକୃତ ଅନ୍ଧାର ଥିମ୍ | - ଚାଟ୍ ପୃଷ୍ଠଭୂମି ରଙ୍ଗକୁ କଳାକୁ ବାଧ୍ୟ କରିଥାଏ | + Amoled ଗାଢ଼ ମୋଡ୍ + OLED ସ୍କ୍ରିନ୍ ପାଇଁ ସମ୍ପୂର୍ଣ୍ଣ କଳା ପୃଷ୍ଠଭୂମି + ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ + ସିଷ୍ଟମ୍ ୱାଲପେପର୍ ଅନୁସରଣ କରନ୍ତୁ + ନୀଳ + ଟିଲ୍ + ସବୁଜ + ଲାଇମ୍ + ହଳଦିଆ + କମଳା + ଲାଲ୍ + ଗୋଲାପୀ + ବାଇଗଣୀ + ଇଣ୍ଡିଗୋ + ବାଦାମୀ + ଧୂସର + ରଙ୍ଗ ଶୈଳୀ + ସିଷ୍ଟମ ଡିଫଲ୍ଟ + ସିଷ୍ଟମର ଡିଫଲ୍ଟ ରଙ୍ଗ ପ୍ୟାଲେଟ ବ୍ୟବହାର କରନ୍ତୁ + Tonal Spot + ଶାନ୍ତ ଏବଂ ମୃଦୁ ରଙ୍ଗ + Neutral + ପ୍ରାୟ ଏକରଙ୍ଗୀ, ସୂକ୍ଷ୍ମ ରଙ୍ଗ + Vibrant + ଉଜ୍ଜ୍ୱଳ ଏବଂ ଗାଢ଼ ରଙ୍ଗ + Expressive + ସ୍ଥାନାନ୍ତରିତ ରଙ୍ଗ ସହ ଖେଳପୂର୍ଣ୍ଣ ରଙ୍ଗ + Rainbow + ରଙ୍ଗର ବ୍ୟାପକ ସ୍ପେକ୍ଟ୍ରମ୍ + Fruit Salad + ଖେଳପୂର୍ଣ୍ଣ, ବହୁରଙ୍ଗୀ ପ୍ୟାଲେଟ୍ + Monochrome + କେବଳ କଳା, ଧଳା ଏବଂ ଧୂସର + Fidelity + ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ ପ୍ରତି ବିଶ୍ୱସ୍ତ + Content + ସମାନ ତୃତୀୟ ସହ ଆକ୍ସେଣ୍ଟ ରଙ୍ଗ + ଅଧିକ ଶୈଳୀ ପ୍ରଦର୍ଶନ ଉପାଦାନଗୁଡ଼ିକ | ସମୟ ସମାପ୍ତ ବାର୍ତ୍ତା ଦେଖାନ୍ତୁ | @@ -206,8 +444,16 @@ ଛୋଟ ବଡ଼ ବହୁତ ବଡ଼ - ଇମୋଟ୍ ଏବଂ ଉପଭୋକ୍ତା ପରାମର୍ଶ | - ଟାଇପ୍ କରିବା ସମୟରେ ଇମୋଟ ଏବଂ ସକ୍ରିୟ ଉପଭୋକ୍ତାମାନଙ୍କ ପାଇଁ ପରାମର୍ଶ ଦେଖାଏ | + ପରାମର୍ଶ + ଟାଇପ୍ କରିବା ସମୟରେ କେଉଁ ପରାମର୍ଶ ଦେଖାଇବେ ବାଛନ୍ତୁ + ଇମୋଟ + : ସହିତ ଟ୍ରିଗର କରନ୍ତୁ + ଉପଭୋକ୍ତା + @ ସହିତ ଟ୍ରିଗର କରନ୍ତୁ + Twitch କମାଣ୍ଡ + / ସହିତ ଟ୍ରିଗର କରନ୍ତୁ + Supibot କମାଣ୍ଡ + $ ସହିତ ଟ୍ରିଗର କରନ୍ତୁ ଆରମ୍ଭରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ପୁନ recon ସଂଯୋଗ ପରେ ବାର୍ତ୍ତା ଇତିହାସ ଲୋଡ୍ କରନ୍ତୁ | ମିସ୍ ଡ୍ରପ୍ ସମୟରେ ଗ୍ରହଣ ହୋଇନଥିବା ସନ୍ଦେଶ ଆଣିବାକୁ ଚେଷ୍ଟା କରିବାକୁ ଚେଷ୍ଟା କରେ | @@ -217,7 +463,7 @@ ଚ୍ୟାନେଲ ତଥ୍ୟ | ଡେଵେଲପର୍ ଵିକଳ୍ପ କିବଗ୍ ମୋଡ୍ | - ଧରାପଡିଥିବା ଯେକ any ଣସି ବ୍ୟତିକ୍ରମ ପାଇଁ ସୂଚନା ପ୍ରଦାନ କରେ | + ଇନପୁଟ ବାରରେ ଡିବଗ ଆନାଲିଟିକ୍ସ କାର୍ଯ୍ୟ ଦେଖାନ୍ତୁ ଏବଂ ସ୍ଥାନୀୟ ଭାବରେ କ୍ର୍ୟାସ ରିପୋର୍ଟ ସଂଗ୍ରହ କରନ୍ତୁ ଟାଇମଷ୍ଟ୍ୟାମ୍ପ ଫର୍ମାଟ୍ | TTS ସକ୍ଷମ କରନ୍ତୁ | ସକ୍ରିୟ ଚ୍ୟାନେଲର ବାର୍ତ୍ତା ପ Read ଼େ | @@ -231,6 +477,9 @@ URL କୁ ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ | TTS ରେ ଇମୋଟ ଏବଂ ଇମୋଜିଗୁଡ଼ିକୁ ଉପେକ୍ଷା କରେ | ଇମୋଟ୍କୁ ଉପେକ୍ଷା କରନ୍ତୁ | + ଧ୍ୱନି + ଅଡିଓ ଡକିଂ + TTS ବୋଲୁଥିବା ସମୟରେ ଅନ୍ୟ ଅଡିଓ ଧ୍ୱନି କମ୍ କରନ୍ତୁ TTS ଚେକେଡ୍ ଲାଇନ୍ସ | ବିଭିନ୍ନ ପୃଷ୍ଠଭୂମି ଉଜ୍ଜ୍ୱଳତା ସହିତ ପ୍ରତ୍ୟେକ ଧାଡି ଅଲଗା କରନ୍ତୁ | @@ -243,12 +492,19 @@ ଉପଭୋକ୍ତା ଲମ୍ବା କ୍ଲିକ୍ ଆଚରଣ | ନିୟମିତ କ୍ଲିକ୍ ପପ୍ଅପ୍ ଖୋଲିବ, ଲମ୍ବା କ୍ଲିକ୍ ଉଲ୍ଲେଖ | ନିୟମିତ କ୍ଲିକ୍ ଉଲ୍ଲେଖ, ଲମ୍ବା କ୍ଲିକ୍ ପପ୍ଅପ୍ ଖୋଲିବ | + ଡାକନାମ ରଙ୍ଗ କରନ୍ତୁ + ରଙ୍ଗ ସେଟ୍ ନଥିବା ଉପଭୋକ୍ତାମାନଙ୍କୁ ଏକ ଯାଦୃଚ୍ଛିକ ରଙ୍ଗ ନିର୍ଦ୍ଧାରଣ କରନ୍ତୁ ଇଂରାଜୀକୁ ବାଧ୍ୟ କର | ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟ ପରିବର୍ତ୍ତେ TTS ଭଏସ୍ ଭାଷାକୁ ଇଂରାଜୀକୁ ବାଧ୍ୟ କରନ୍ତୁ | ଦୃଶ୍ୟମାନ ତୃତୀୟ ପକ୍ଷ ଇମୋଟ୍ | ସେବା ସର୍ତ୍ତାବଳୀ & ବ୍ୟବହାରକାରୀ ନୀତି: ଚିପ୍ କ୍ରିୟା ଦେଖାନ୍ତୁ | ଫୁଲ୍ ସ୍କ୍ରିନ୍, ଷ୍ଟ୍ରିମ୍ ଏବଂ ଚାଟ୍ ମୋଡ୍ ସଜାଡିବା ପାଇଁ ଚିପ୍ସ ପ୍ରଦର୍ଶନ କରେ | + ଅକ୍ଷର ଗଣକ ଦେଖାନ୍ତୁ + ଇନପୁଟ୍ ଫିଲ୍ଡରେ କୋଡ୍ ପଏଣ୍ଟ ଗଣନା ପ୍ରଦର୍ଶନ କରେ + ଇନପୁଟ୍ ସଫା ବଟନ୍ ଦେଖାନ୍ତୁ + ପଠାଇବା ବଟନ୍ ଦେଖାନ୍ତୁ + ଇନପୁଟ୍ ମିଡିଆ ଅପଲୋଡର୍ | ଅପଲୋଡର୍ କୁ ବିନ୍ୟାସ କରନ୍ତୁ | ସମ୍ପ୍ରତି ଅପଲୋଡ୍ | @@ -256,7 +512,7 @@ ଉପଯୋଗକର୍ତ୍ତା / ଖାତାଗୁଡ଼ିକର ତାଲିକା ଯାହାକୁ ଅଣଦେଖା କରାଯିବ | ସାଧନଗୁଡ଼ିକ | ଥିମ୍ - ଗାଢ଼ ଥିମ୍ + ଗାଢ଼ ଥିମ୍ ହାଲୁକା ଥିମ୍ ତାଲିକାଭୁକ୍ତ ଇମୋଟଗୁଡିକୁ ଅନୁମତି ଦିଅନ୍ତୁ | ଅନୁମୋଦିତ କିମ୍ବା ତାଲିକାଭୁକ୍ତ ଇମୋଟଗୁଡିକର ଫିଲ୍ଟରିଂକୁ ଅକ୍ଷମ କରିଥାଏ | @@ -277,6 +533,9 @@ କଷ୍ଟମ୍ ଲଗଇନ୍ | ଟ୍ୱିଚ୍ କମାଣ୍ଡ ହ୍ୟାଣ୍ଡଲିଂକୁ ବାଇପାସ୍ କରନ୍ତୁ | Twitch ନିର୍ଦ୍ଦେଶଗୁଡ଼ିକର ବାଧା ସୃଷ୍ଟି କରିବାକୁ ଏବଂ ସେଥିପାଇଁ ଚାଟ୍ କରିବାକୁ ସେମାନଙ୍କୁ ପଠାଏ | + ଚାଟ୍ ପଠାଇବା ପ୍ରୋଟୋକଲ୍ + ପଠାଇବା ପାଇଁ Helix API ବ୍ୟବହାର କରନ୍ତୁ + IRC ବଦଳରେ Twitch Helix API ମାଧ୍ୟମରେ ଚାଟ୍ ବାର୍ତ୍ତା ପଠାନ୍ତୁ 7TV ଲାଇଭ୍ ଇମୋଟେଟ୍ ଅପଡେଟ୍ | ଲାଇଭ୍ ଇମୋଟ୍ ଅପଡେଟ୍ ପୃଷ୍ଠଭୂମି ଆଚରଣ | ପରେ ଅଟକି ଯାଅ | %1$s.\nଏହି ସଂଖ୍ୟା ହ୍ରାସ କରିବା ବ୍ୟାଟେରୀ ଜୀବନ ବୃଦ୍ଧି କରିପାରେ | @@ -309,18 +568,21 @@ ପ୍ରତିସ୍ଥାପନ ଅବହେଳା କରନ୍ତୁ | ବାର୍ତ୍ତା ଅଗ୍ରାହ୍ୟ କରନ୍ତୁ | - ଵାର୍ତ୍ତାଗୁଡ଼ିକ + ଵାର୍ତ୍ତାଗୁଡ଼ିକ ଉପଯୋଗକର୍ତ୍ତାଗଣ | କଳା ତାଲିକାଭୁକ୍ତ ବ୍ୟବହାରକାରୀ | Twitch + ବ୍ୟାଜ୍ ପୂର୍ବବତ୍ କରନ୍ତୁ | ଆଇଟମ୍ ଅପସାରିତ ହୋଇଛି | ଅବରୋଧିତ ଉପଭୋକ୍ତା | %1$s ଉପଭୋକ୍ତାଙ୍କୁ ଅବରୋଧ କରିବାରେ ବିଫଳ | %1$s + ବ୍ୟାଜ୍ ଚାଳକକୁ ଅବରୋଧ କରିବାରେ ବିଫଳ | %1$s ଆପଣଙ୍କର ଉପଯୋଗକର୍ତ୍ତା ନାମ ସଦସ୍ୟତା ଏବଂ ଘଟଣା | ଘୋଷଣା + ଦେଖିବା ଧାରା ପ୍ରଥମ ବାର୍ତ୍ତା | ଉଚ୍ଚତର ବାର୍ତ୍ତା | ଚ୍ୟାନେଲ ପଏଣ୍ଟ ସହିତ ମୁକ୍ତ ହୋଇଥିବା ହାଇଲାଇଟ୍ | @@ -328,7 +590,8 @@ କଷ୍ଟମ ନିର୍ଦ୍ଦିଷ୍ଟ s ାଞ୍ଚା ଉପରେ ଆଧାର କରି ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ବାର୍ତ୍ତାଗୁଡ଼ିକୁ ହାଇଲାଇଟ୍ କରେ | ନିର୍ଦ୍ଦିଷ୍ଟ ବ୍ୟବହାରକାରୀଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ହାଇଲାଇଟ୍ କରେ | - ନିର୍ଦ୍ଦିଷ୍ଟ ଉପଭୋକ୍ତାମାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ଏବଂ ହାଇଲାଇଟ୍ ଅକ୍ଷମ କରନ୍ତୁ (ଯଥା ବଟ୍) | + ନିର୍ଦ୍ଦିଷ୍ଟ ଉପଭୋକ୍ତାมାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ଏବଂ ହାଇଲାଇଟ୍ ଅକ୍ଷମ କରନ୍ତୁ (ଯଥା ବଟ୍) | + ବ୍ୟାଜ୍ ଆଧାରରେ ଉପଭୋକ୍ତାମାନଙ୍କ ଠାରୁ ବିଜ୍ଞପ୍ତି ସୃଷ୍ଟି କରେ ଏବଂ ବାର୍ତ୍ତା ହାଇଲାଇଟ୍ କରେ। ନିର୍ଦ୍ଦିଷ୍ଟ s ାଞ୍ଚା ଉପରେ ଆଧାର କରି ବାର୍ତ୍ତାଗୁଡ଼ିକୁ ଉପେକ୍ଷା କରନ୍ତୁ | ନିର୍ଦ୍ଦିଷ୍ଟ ବ୍ୟବହାରକାରୀଙ୍କ ସନ୍ଦେଶକୁ ଅଣଦେଖା କରନ୍ତୁ | ଅବରୋଧିତ ଟ୍ୱିଚ୍ ବ୍ୟବହାରକାରୀଙ୍କୁ ପରିଚାଳନା କରନ୍ତୁ | @@ -346,10 +609,29 @@ ବାର୍ତ୍ତା କପି କରନ୍ତୁ | ପୂର୍ଣ୍ଣ ବାର୍ତ୍ତା କପି କରନ୍ତୁ | ସନ୍ଦେଶର ଉତ୍ତର ଦିଅ | + ମୂଳ ସନ୍ଦେଶର ଉତ୍ତର ଦିଅନ୍ତୁ ଥ୍ରେଡ୍ ଦେଖନ୍ତୁ | ବାର୍ତ୍ତା ID କପି କରନ୍ତୁ | ଅଧିକ… + ବାର୍ତ୍ତାକୁ ଯାଆନ୍ତୁ + ବାର୍ତ୍ତା ଆଉ ଚାଟ୍ ଇତିହାସରେ ନାହିଁ + ବାର୍ତ୍ତା ଇତିହାସ + ଗ୍ଲୋବାଲ ଇତିହାସ + ଇତିହାସ: %1$s + ବାର୍ତ୍ତା ଖୋଜନ୍ତୁ… + ଉପଯୋଗକର୍ତ୍ତା ନାମ ଦ୍ୱାରା ଫିଲ୍ଟର କରନ୍ତୁ + ଲିଙ୍କ୍ ଥିବା ବାର୍ତ୍ତା + ଇମୋଟ୍ ଥିବା ବାର୍ତ୍ତା + ବ୍ୟାଜ୍ ନାମ ଦ୍ୱାରା ଫିଲ୍ଟର କରନ୍ତୁ + ଉପଭୋକ୍ତା + ବ୍ୟାଜ୍ ଉତ୍ତର ଦେବା @%1$s + ଫୁସ୍ଫୁସ୍ @%1$s + ଏକ ଫୁସ୍ଫୁସ୍ ପଠାନ୍ତୁ + ନୂଆ ଫୁସ୍ଫୁସ୍ + ଫୁସ୍ଫୁସ୍ ପଠାନ୍ତୁ + ଉପଯୋଗକର୍ତ୍ତା ନାମ + ଆରମ୍ଭ କରନ୍ତୁ ଜରିମାନା ଉତ୍ତର ମିଳିଲା ନାହିଁ | ବାର୍ତ୍ତା ମିଳିଲା ନାହିଁ | ଇମୋଟ୍ ବ୍ୟବହାର କରନ୍ତୁ | @@ -375,6 +657,8 @@ ଲଗଇନ୍ ବାତିଲ୍ କରନ୍ତୁ | ଜୁମ୍ ଆଉଟ୍ କରନ୍ତୁ | ଜୁମ୍ ଇନ୍ କରନ୍ତୁ | + ପଛକୁ + ସେୟାର୍ଡ ଚାଟ୍ ସହିତ ରୁହନ୍ତୁ | %1$d ପାଇଁ ଦର୍ଶକ %2$s ସହିତ ରୁହ | %1$d ପାଇଁ ଦର୍ଶକ | %2$s @@ -383,4 +667,231 @@ %d ମାସ %d ମାସ + ଓପନ୍ ସୋର୍ସ ଲାଇସେନ୍ସ + + %2$s ରେ %1$d ଦର୍ଶକ ସହିତ %3$s ପାଇଁ ଲାଇଭ୍ + %2$s ରେ %1$d ଦର୍ଶକ ସହିତ %3$s ପାଇଁ ଲାଇଭ୍ + + ଷ୍ଟ୍ରିମ୍ ବର୍ଗ ଦେଖାନ୍ତୁ + ଷ୍ଟ୍ରିମ୍ ବର୍ଗ ମଧ୍ୟ ପ୍ରଦର୍ଶନ କରନ୍ତୁ + ଇନପୁଟ୍ ଟୋଗଲ୍ କରନ୍ତୁ + ବ୍ରଡକାଷ୍ଟର୍ + ଆଡମିନ୍ + ଷ୍ଟାଫ୍ + ମୋଡରେଟର୍ + ମୁଖ୍ୟ ମୋଡରେଟର୍ + ଯାଞ୍ଚିତ + VIP + ପ୍ରତିଷ୍ଠାତା + ଗ୍ରାହକ + କଷ୍ଟମ ହାଇଲାଇଟ୍ ରଙ୍ଗ ବାଛନ୍ତୁ + ଡିଫଲ୍ଟ + ରଙ୍ଗ ବାଛନ୍ତୁ + ଆପ୍ ବାର ଟୋଗଲ୍ କରନ୍ତୁ + ତ୍ରୁଟି: %s + + DankChat + ଆସନ୍ତୁ ଆପଣଙ୍କୁ ସେଟ ଅପ୍ କରିବା। + ଆରମ୍ଭ କରନ୍ତୁ + Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ + ବାର୍ତ୍ତା ପଠାଇବାକୁ, ଆପଣଙ୍କ ଇମୋଟ୍ ବ୍ୟବହାର କରିବାକୁ, ଫୁସ୍ଫୁସ୍ ଗ୍ରହଣ କରିବାକୁ ଏବଂ ସମସ୍ତ ବୈଶିଷ୍ଟ୍ୟ ଅନଲକ୍ କରିବାକୁ ଲଗ ଇନ କରନ୍ତୁ। + ଆପଣଙ୍କୁ ଏକାସାଥରେ ଅନେକ Twitch ଅନୁମତି ପ୍ରଦାନ କରିବାକୁ କୁହାଯିବ, ଯାହାଫଳରେ ବିଭିନ୍ନ ବୈଶିଷ୍ଟ୍ୟ ବ୍ୟବହାର କରିବାବେଳେ ଆପଣଙ୍କୁ ପୁନଃ ଅନୁମୋଦନ କରିବାକୁ ପଡ଼ିବ ନାହିଁ। DankChat କେବଳ ଆପଣ କହିଲେ ମଡରେସନ୍ ଏବଂ ଷ୍ଟ୍ରିମ୍ କାର୍ଯ୍ୟ କରିଥାଏ। + Twitch ସହ ଲଗଇନ୍ କରନ୍ତୁ + ଲଗଇନ୍ ସଫଳ + ଛାଡି ଦିଅନ୍ତୁ + ଜାରି ରଖନ୍ତୁ + ବାର୍ତ୍ତା ଇତିହାସ + DankChat ଆରମ୍ଭରେ ତୃତୀୟ-ପକ୍ଷ ସେବାରୁ ଐତିହାସିକ ବାର୍ତ୍ତା ଲୋଡ୍ କରେ। ବାର୍ତ୍ତା ପାଇବାକୁ, DankChat ଆପଣ ସେହି ସେବାରେ ଖୋଲିଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ନାମ ପଠାଏ। ସେବା ସାମୟିକ ଭାବରେ ସେବା ପ୍ରଦାନ ପାଇଁ ଆପଣ (ଏବଂ ଅନ୍ୟମାନେ) ପରିଦର୍ଶନ କରୁଥିବା ଚ୍ୟାନେଲଗୁଡ଼ିକର ବାର୍ତ୍ତା ସଂରକ୍ଷଣ କରେ।\n\nଆପଣ ଏହାକୁ ପରବର୍ତ୍ତୀ ସମୟରେ ସେଟିଂସରେ ବଦଳାଇ ପାରିବେ କିମ୍ବା https://recent-messages.robotty.de/ ରେ ଅଧିକ ଜାଣିପାରିବେ + ସକ୍ଷମ କରନ୍ତୁ + ଅକ୍ଷମ କରନ୍ତୁ + ଵିଜ୍ଞପ୍ତି + ଆପ୍ ପୃଷ୍ଠଭୂମିରେ ଥିବାବେଳେ ଚାଟ୍ ରେ କେହି ଆପଣଙ୍କୁ ଉଲ୍ଲେଖ କଲେ DankChat ଆପଣଙ୍କୁ ଜଣାଇପାରେ। + ବିଜ୍ଞପ୍ତି ଅନୁମତି ଦିଅନ୍ତୁ + ବିଜ୍ଞପ୍ତି ବିନା, ଆପ୍ ପୃଷ୍ଠଭୂମିରେ ଥିବାବେଳେ ଚାଟ୍ ରେ କେହି ଆପଣଙ୍କୁ ଉଲ୍ଲେଖ କଲେ ଆପଣ ଜାଣିପାରିବେ ନାହିଁ। + ବିଜ୍ଞପ୍ତି ସେଟିଂସ୍ ଖୋଲନ୍ତୁ + + ଖୋଜ, ଷ୍ଟ୍ରିମ୍ ଏବଂ ଅଧିକ ପାଇଁ ଦ୍ରୁତ ପ୍ରବେଶ ସହ କଷ୍ଟମାଇଜ୍ ଯୋଗ୍ୟ କ୍ରିୟା + ଅଧିକ କ୍ରିୟା ଏବଂ ଆପଣଙ୍କ ଆକ୍ସନ ବାର ବିନ୍ୟାସ କରିବାକୁ ଏଠାରେ ଟ୍ୟାପ୍ କରନ୍ତୁ + ଆପଣ ଏଠାରେ ଆପଣଙ୍କ ଆକ୍ସନ ବାରରେ କେଉଁ କ୍ରିୟା ଦେଖାଯିବ ତାହା କଷ୍ଟମାଇଜ୍ କରିପାରିବେ + ଶୀଘ୍ର ଲୁଚାଇବା ପାଇଁ ଇନପୁଟ୍ ଉପରେ ତଳକୁ ସ୍ୱାଇପ୍ କରନ୍ତୁ + ଇନପୁଟ୍ ଫେରାଇ ଆଣିବାକୁ ଏଠାରେ ଟ୍ୟାପ୍ କରନ୍ତୁ + ପରବର୍ତ୍ତୀ + ବୁଝିଗଲି + ଟୁର୍ ଛାଡି ଦିଅନ୍ତୁ + ଆପଣ ଏଠାରେ ଅଧିକ ଚ୍ୟାନେଲ ଯୋଡିପାରିବେ + + + ସାଧାରଣ + ପ୍ରମାଣୀକରଣ + Twitch EventSub ସକ୍ରିୟ କରନ୍ତୁ + ଅଚଳ PubSub ବଦଳରେ ବିଭିନ୍ନ ରିଅଲ-ଟାଇମ ଇଭେଣ୍ଟ ପାଇଁ EventSub ବ୍ୟବହାର କରେ + EventSub ଡିବଗ୍ ଆଉଟପୁଟ୍ ସକ୍ରିୟ କରନ୍ତୁ + EventSub ସମ୍ବନ୍ଧୀୟ ଡିବଗ୍ ତଥ୍ୟ ସିଷ୍ଟମ ମେସେଜ୍ ଭାବରେ ଦେଖାଏ + ଟୋକେନ୍ ବାତିଲ କରନ୍ତୁ ଏବଂ ପୁନଃଆରମ୍ଭ କରନ୍ତୁ + ବର୍ତ୍ତମାନର ଟୋକେନ୍ ଅବୈଧ କରି ଆପ୍ ପୁନଃଆରମ୍ଭ କରେ + ଲଗ୍ ଇନ୍ ହୋଇନାହାଁନ୍ତି + %1$s ପାଇଁ ଚ୍ୟାନେଲ ID ସମାଧାନ ହୋଇପାରିଲା ନାହିଁ + ମେସେଜ୍ ପଠାଯାଇନାହିଁ + ମେସେଜ୍ ବାଦ୍ ପଡ଼ିଲା: %1$s (%2$s) + user:write:chat ଅନୁମତି ନାହିଁ, ଦୟାକରି ପୁନଃ ଲଗ୍ ଇନ୍ କରନ୍ତୁ + ଏହି ଚ୍ୟାନେଲରେ ମେସେଜ୍ ପଠାଇବାକୁ ଅନୁମତି ନାହିଁ + ମେସେଜ୍ ବହୁତ ବଡ଼ + ହାର ସୀମିତ, କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ + ପଠାଇବା ବିଫଳ: %1$s + + + %1$s କମାଣ୍ଡ ବ୍ୟବହାର କରିବା ପାଇଁ ଆପଣଙ୍କୁ ଲଗ୍ ଇନ୍ ହେବାକୁ ପଡ଼ିବ + ସେହି ୟୁଜରନେମ୍ ସହ ମେଳ ଖାଉଥିବା କୌଣସି ୟୁଜର ନାହାଁନ୍ତି। + ଏକ ଅଜ୍ଞାତ ତ୍ରୁଟି ଘଟିଛି। + ଏହି କାର୍ଯ୍ୟ କରିବାକୁ ଆପଣଙ୍କର ଅନୁମତି ନାହିଁ। + ଆବଶ୍ୟକ ସ୍କୋପ୍ ନାହିଁ। ଆପଣଙ୍କ ଆକାଉଣ୍ଟରେ ପୁନଃ ଲଗ୍ ଇନ୍ କରି ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ଲଗ୍ ଇନ୍ ପ୍ରମାଣପତ୍ର ନାହିଁ। ଆପଣଙ୍କ ଆକାଉଣ୍ଟରେ ପୁନଃ ଲଗ୍ ଇନ୍ କରି ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ବ୍ୟବହାର: /block <user> + ଆପଣ ସଫଳତାର ସହ %1$s ୟୁଜରଙ୍କୁ ବ୍ଲକ୍ କଲେ + %1$s ୟୁଜରଙ୍କୁ ବ୍ଲକ୍ କରାଯାଇପାରିଲା ନାହିଁ, ସେହି ନାମର କୌଣସି ୟୁଜର ମିଳିଲା ନାହିଁ! + %1$s ୟୁଜରଙ୍କୁ ବ୍ଲକ୍ କରାଯାଇପାରିଲା ନାହିଁ, ଏକ ଅଜ୍ଞାତ ତ୍ରୁଟି ଘଟିଲା! + ବ୍ୟବହାର: /unblock <user> + ଆପଣ ସଫଳତାର ସହ %1$s ୟୁଜରଙ୍କୁ ଅନବ୍ଲକ୍ କଲେ + %1$s ୟୁଜରଙ୍କୁ ଅନବ୍ଲକ୍ କରାଯାଇପାରିଲା ନାହିଁ, ସେହି ନାମର କୌଣସି ୟୁଜର ମିଳିଲା ନାହିଁ! + %1$s ୟୁଜରଙ୍କୁ ଅନବ୍ଲକ୍ କରାଯାଇପାରିଲା ନାହିଁ, ଏକ ଅଜ୍ଞାତ ତ୍ରୁଟି ଘଟିଲା! + ଚ୍ୟାନେଲ ଲାଇଭ ନାହିଁ। + ଅପ୍‌ଟାଇମ୍: %1$s + ଏହି ରୁମ୍‌ରେ ଆପଣଙ୍କ ପାଇଁ ଉପಲବ୍ଧ କମାଣ୍ଡଗୁଡ଼ିକ: %1$s + ବ୍ୟବହାର: %1$s <username> <message>। + ହୁଇସ୍ପର ପଠାଯାଇଛି। + ହୁଇସ୍ପର ପଠାଇବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <message> - ହାଇଲାଇଟ୍ ସହ ଆପଣଙ୍କ ମେସେଜ୍ ପ୍ରତି ଧ୍ୟାନ ଆକର୍ଷଣ କରନ୍ତୁ। + ଘୋଷଣା ପଠାଇବା ବିଫଳ - %1$s + ଏହି ଚ୍ୟାନେଲରେ କୌଣସି ମୋଡେରେଟର ନାହାଁନ୍ତି। + ଏହି ଚ୍ୟାନେଲର ମୋଡେରେଟରମାନେ ହେଲେ %1$s। + ମୋଡେରେଟର ତାଲିକା ପାଇବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> - ଏକ ୟୁଜରଙ୍କୁ ମୋଡେରେଟର ମାନ୍ୟତା ଦିଅନ୍ତୁ। + ଆପଣ %1$s ଙ୍କୁ ଏହି ଚ୍ୟାନେଲର ମୋଡେରେଟର ଭାବରେ ଯୋଗ କଲେ। + ଚ୍ୟାନେଲ ମୋଡେରେଟର ଯୋଗ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> - ଏକ ୟୁଜରଙ୍କଠାରୁ ମୋଡେରେଟର ମାନ୍ୟତା ପ୍ରତ୍ୟାହାର କରନ୍ତୁ। + ଆପଣ %1$s ଙ୍କୁ ଏହି ଚ୍ୟାନେଲର ମୋଡେରେଟରରୁ ହଟାଇଲେ। + ଚ୍ୟାନେଲ ମୋଡେରେଟର ହଟାଇବା ବିଫଳ - %1$s + ଏହି ଚ୍ୟାନେଲରେ କୌଣସି VIP ନାହାଁନ୍ତି। + ଏହି ଚ୍ୟାନେଲର VIP ମାନେ ହେଲେ %1$s। + VIP ତାଲିକା ପାଇବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> - ଏକ ୟୁଜରଙ୍କୁ VIP ମାନ୍ୟତା ଦିଅନ୍ତୁ। + ଆପଣ %1$s ଙ୍କୁ ଏହି ଚ୍ୟାନେଲର VIP ଭାବରେ ଯୋଗ କଲେ। + VIP ଯୋଗ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> - ଏକ ୟୁଜରଙ୍କଠାରୁ VIP ମାନ୍ୟତା ପ୍ରତ୍ୟାହାର କରନ୍ତୁ। + ଆପଣ %1$s ଙ୍କୁ ଏହି ଚ୍ୟାନେଲର VIP ରୁ ହଟାଇଲେ। + VIP ହଟାଇବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> [କାରଣ] - ଏକ ୟୁଜରଙ୍କୁ ଚ୍ୟାଟ୍ କରିବାରୁ ସ୍ଥାୟୀ ଭାବରେ ବାରଣ କରନ୍ତୁ। କାରଣ ଐଚ୍ଛିକ ଏବଂ ଲକ୍ଷ୍ୟ ୟୁଜର ଓ ଅନ୍ୟ ମୋଡେରେଟରଙ୍କୁ ଦେଖାଯିବ। ବ୍ୟାନ ହଟାଇବା ପାଇଁ /unban ବ୍ୟବହାର କରନ୍ତୁ। + ୟୁଜରଙ୍କୁ ବ୍ୟାନ କରିବା ବିଫଳ - ଆପଣ ନିଜକୁ ବ୍ୟାନ କରିପାରିବେ ନାହିଁ। + ୟୁଜରଙ୍କୁ ବ୍ୟାନ କରିବା ବିଫଳ - ଆପଣ ବ୍ରଡ୍‌କାଷ୍ଟରଙ୍କୁ ବ୍ୟାନ କରିପାରିବେ ନାହିଁ। + ୟୁଜରଙ୍କୁ ବ୍ୟାନ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> - ଏକ ୟୁଜରଙ୍କଠାରୁ ବ୍ୟାନ ହଟାଏ। + ୟୁଜରଙ୍କୁ ଅନବ୍ୟାନ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s <username> [ସମୟସୀମା][ସମୟ ଏକକ] [କାରଣ] - ଏକ ୟୁଜରଙ୍କୁ ସାମୟିକ ଭାବରେ ଚ୍ୟାଟ୍ କରିବାରୁ ବାରଣ କରନ୍ତୁ। ସମୟସୀମା (ଐଚ୍ଛିକ, ଡିଫଲ୍ଟ: 10 ମିନିଟ୍) ଏକ ଧନାତ୍ମକ ପୂର୍ଣ୍ଣ ସଂଖ୍ୟା ହେବା ଉଚିତ; ସମୟ ଏକକ (ଐଚ୍ଛିକ, ଡିଫଲ୍ଟ: s) s, m, h, d, w ମଧ୍ୟରୁ ଗୋଟିଏ ହେବା ଉଚିତ; ସର୍ବାଧିକ ସମୟସୀମା 2 ସପ୍ତାହ। କାରଣ ଐଚ୍ଛିକ ଏବଂ ଲକ୍ଷ୍ୟ ୟୁଜର ଓ ଅନ୍ୟ ମୋଡେରେଟରଙ୍କୁ ଦେଖାଯିବ। + ୟୁଜରଙ୍କୁ ବ୍ୟାନ କରିବା ବିଫଳ - ଆପଣ ନିଜକୁ ଟାଇମଆଉଟ୍ କରିପାରିବେ ନାହିଁ। + ୟୁଜରଙ୍କୁ ବ୍ୟାନ କରିବା ବିଫଳ - ଆପଣ ବ୍ରଡ୍‌କାଷ୍ଟରଙ୍କୁ ଟାଇମଆଉଟ୍ କରିପାରିବେ ନାହିଁ। + ୟୁଜରଙ୍କୁ ଟାଇମଆଉଟ୍ କରିବା ବିଫଳ - %1$s + ଚ୍ୟାଟ୍ ମେସେଜ୍ ଡିଲିଟ୍ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: /delete <msg-id> - ନିର୍ଦ୍ଦିଷ୍ଟ ମେସେଜ୍ ଡିଲିଟ୍ କରେ। + ଅବୈଧ msg-id: \"%1$s\"। + ଚ୍ୟାଟ୍ ମେସେଜ୍ ଡିଲିଟ୍ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: /color <color> - ରଙ୍ଗ Twitch ସମର୍ଥିତ ରଙ୍ଗଗୁଡ଼ିକ ମଧ୍ୟରୁ ଗୋଟିଏ (%1$s) କିମ୍ବା ଯଦି ଆପଣଙ୍କ ପାଖରେ Turbo କିମ୍ବା Prime ଅଛି ତେବେ hex code (#000000) ହେବା ଉଚିତ। + ଆପଣଙ୍କ ରଙ୍ଗ %1$s କୁ ପରିବର୍ତ୍ତିତ ହୋଇଛି + %1$s କୁ ରଙ୍ଗ ପରିବର୍ତ୍ତନ ବିଫଳ - %2$s + %1$s%2$s ରେ ସଫଳତାର ସହ ଷ୍ଟ୍ରିମ୍ ମାର୍କର ଯୋଗ ହୋଇଛି। + ଷ୍ଟ୍ରିମ୍ ମାର୍କର ତିଆରି କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: /commercial <length> - ବର୍ତ୍ତମାନ ଚ୍ୟାନେଲ ପାଇଁ ନିର୍ଦ୍ଦିଷ୍ଟ ସମୟସୀମାର ବିଜ୍ଞାପନ ଆରମ୍ଭ କରେ। ବୈଧ ସମୟ ବିକଳ୍ପଗୁଡ଼ିକ ହେଲା 30, 60, 90, 120, 150, ଏବଂ 180 ସେକେଣ୍ଡ। + + %1$d ସେକେଣ୍ଡର ବିଜ୍ଞାପନ ବ୍ରେକ୍ ଆରମ୍ଭ ହେଉଛି। ମନେ ରଖନ୍ତୁ ଆପଣ ଏବେ ବି ଲାଇଭ ଅଛନ୍ତି ଏବଂ ସବୁ ଦର୍ଶକ ବିଜ୍ଞାପନ ପାଇବେ ନାହିଁ। ଆପଣ %2$d ସେକେଣ୍ଡ ପରେ ଆଉ ଏକ ବିଜ୍ଞାପନ ଚଲାଇ ପାରିବେ। + %1$d ସେକେଣ୍ଡର ବିଜ୍ଞାପନ ବ୍ରେକ୍ ଆରମ୍ଭ ହେଉଛି। ମନେ ରଖନ୍ତୁ ଆପଣ ଏବେ ବି ଲାଇଭ ଅଛନ୍ତି ଏବଂ ସବୁ ଦର୍ଶକ ବିଜ୍ଞାପନ ପାଇବେ ନାହିଁ। ଆପଣ %2$d ସେକେଣ୍ଡ ପରେ ଆଉ ଏକ ବିଜ୍ଞାପନ ଚଲାଇ ପାରିବେ। + + ବିଜ୍ଞାପନ ଆରମ୍ଭ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: /raid <username> - ଏକ ୟୁଜରଙ୍କୁ ରେଡ୍ କରନ୍ତୁ। କେବଳ ବ୍ରଡ୍‌କାଷ୍ଟର ରେଡ୍ ଆରମ୍ଭ କରିପାରିବେ। + ଅବୈଧ ୟୁଜରନେମ୍: %1$s + ଆପଣ %1$s ଙ୍କୁ ରେଡ୍ କରିବା ଆରମ୍ଭ କଲେ। + ରେଡ୍ ଆରମ୍ଭ କରିବା ବିଫଳ - %1$s + ଆପଣ ରେଡ୍ ବାତିଲ କଲେ। + ରେଡ୍ ବାତିଲ କରିବା ବିଫଳ - %1$s + ବ୍ୟବହାର: %1$s [ସମୟସୀମା] - କେବଳ ଅନୁସରଣକାରୀ ମୋଡ୍ ସକ୍ରିୟ କରେ (କେବଳ ଅନୁସରଣକାରୀ ଚ୍ୟାଟ୍ କରିପାରିବେ)। ସମୟସୀମା (ଐଚ୍ଛିକ, ଡିଫଲ୍ଟ: 0 ମିନିଟ୍) ଏକ ଧନାତ୍ମକ ସଂଖ୍ୟା ହେବା ଉଚିତ ଓ ପଛରେ ସମୟ ଏକକ (m, h, d, w); ସର୍ବାଧିକ ସମୟସୀମା 3 ମାସ। + ଏହି ରୁମ୍ ପୂର୍ବରୁ %1$s କେବଳ ଅନୁସରଣକାରୀ ମୋଡ୍‌ରେ ଅଛି। + ଚ୍ୟାଟ୍ ସେଟିଂସ୍ ଅପଡେଟ୍ କରିବା ବିଫଳ - %1$s + ଏହି ରୁମ୍ କେବଳ ଅନୁସରଣକାରୀ ମୋଡ୍‌ରେ ନାହିଁ। + ଏହି ରୁମ୍ ପୂର୍ବରୁ କେବଳ ଇମୋଟ ମୋଡ୍‌ରେ ଅଛି। + ଏହି ରୁମ୍ କେବଳ ଇମୋଟ ମୋଡ୍‌ରେ ନାହିଁ। + ଏହି ରୁମ୍ ପୂର୍ବରୁ କେବଳ ସବ୍‌ସ୍କ୍ରାଇବର ମୋଡ୍‌ରେ ଅଛି। + ଏହି ରୁମ୍ କେବଳ ସବ୍‌ସ୍କ୍ରାଇବର ମୋଡ୍‌ରେ ନାହିଁ। + ଏହି ରୁମ୍ ପୂର୍ବରୁ ୟୁନିକ ଚ୍ୟାଟ୍ ମୋଡ୍‌ରେ ଅଛି। + ଏହି ରୁମ୍ ୟୁନିକ ଚ୍ୟାଟ୍ ମୋଡ୍‌ରେ ନାହିଁ। + ବ୍ୟବହାର: %1$s [ସମୟସୀମା] - ଧୀର ମୋଡ୍ ସକ୍ରିୟ କରେ (ୟୁଜରମାନେ କେତେ ଥର ମେସେଜ୍ ପଠାଇ ପାରିବେ ତାହା ସୀମିତ କରେ)। ସମୟସୀମା (ଐଚ୍ଛିକ, ଡିଫଲ୍ଟ: 30) ଏକ ଧନାତ୍ମକ ସେକେଣ୍ଡ ସଂଖ୍ୟା ହେବା ଉଚିତ; ସର୍ବାଧିକ 120। + ଏହି ରୁମ୍ ପୂର୍ବରୁ %1$d-ସେକେଣ୍ଡ ଧୀର ମୋଡ୍‌ରେ ଅଛି। + ଏହି ରୁମ୍ ଧୀର ମୋଡ୍‌ରେ ନାହିଁ। + ବ୍ୟବହାର: %1$s <username> - ନିର୍ଦ୍ଦିଷ୍ଟ Twitch ୟୁଜରଙ୍କୁ ଏକ ସାଉଟଆଉଟ୍ ପଠାଏ। + %1$s ଙ୍କୁ ସାଉଟଆଉଟ୍ ପଠାଯାଇଛି + ସାଉଟଆଉଟ୍ ପଠାଇବା ବିଫଳ - %1$s + ଶିଲ୍ଡ ମୋଡ୍ ସକ୍ରିୟ ହୋଇଛି। + ଶିଲ୍ଡ ମୋଡ୍ ନିଷ୍କ୍ରିୟ ହୋଇଛି। + ଶିଲ୍ଡ ମୋଡ୍ ଅପଡେଟ୍ କରିବା ବିଫଳ - %1$s + ଆପଣ ନିଜକୁ ହୁଇସ୍ପର କରିପାରିବେ ନାହିଁ। + Twitch ନିୟନ୍ତ୍ରଣ ଅନୁସାରେ, ହୁଇସ୍ପର ପଠାଇବା ପାଇଁ ଆପଣଙ୍କର ଏକ ଯାଞ୍ଚିତ ଫୋନ ନମ୍ବର ଥିବା ଆବଶ୍ୟକ। ଆପଣ Twitch ସେଟିଂସ୍‌ରେ ଫୋନ ନମ୍ବର ଯୋଗ କରିପାରିବେ। https://www.twitch.tv/settings/security + ପ୍ରାପ୍ତକର୍ତ୍ତା ଅଜଣା ବ୍ୟକ୍ତିଙ୍କଠାରୁ କିମ୍ବା ଆପଣଙ୍କଠାରୁ ସିଧାସଳଖ ହୁଇସ୍ପରକୁ ଅନୁମତି ଦିଅନ୍ତି ନାହିଁ। + Twitch ଦ୍ବାରା ଆପଣଙ୍କ ହାର ସୀମିତ ହୋଇଛି। କିଛି ସେକେଣ୍ଡ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ଆପଣ ଦିନକୁ ସର୍ବାଧିକ 40 ଜଣ ବିଭିନ୍ନ ପ୍ରାପ୍ତକର୍ତ୍ତାଙ୍କୁ ହୁଇସ୍ପର ପଠାଇ ପାରିବେ। ଦୈନିକ ସୀମା ମଧ୍ୟରେ, ଆପଣ ସେକେଣ୍ଡକୁ ସର୍ବାଧିକ 3 ଟି ଏବଂ ମିନିଟ୍‌କୁ ସର୍ବାଧିକ 100 ଟି ହୁଇସ୍ପର ପଠାଇ ପାରିବେ। + Twitch ନିୟନ୍ତ୍ରଣ ଅନୁସାରେ, ଏହି କମାଣ୍ଡ କେବଳ ବ୍ରଡ୍‌କାଷ୍ଟର ବ୍ୟବହାର କରିପାରିବେ। ଦୟାକରି Twitch ୱେବସାଇଟ୍ ବ୍ୟବହାର କରନ୍ତୁ। + %1$s ପୂର୍ବରୁ ଏହି ଚ୍ୟାନେଲର ମୋଡେରେଟର ଅଟନ୍ତି। + %1$s ବର୍ତ୍ତମାନ ଏକ VIP, /unvip କରନ୍ତୁ ଏବଂ ଏହି କମାଣ୍ଡ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + %1$s ଏହି ଚ୍ୟାନେଲର ମୋଡେରେଟର ନୁହଁନ୍ତି। + %1$s ଏହି ଚ୍ୟାନେଲରୁ ବ୍ୟାନ ହୋଇ ନାହାଁନ୍ତି। + %1$s ଏହି ଚ୍ୟାନେଲରେ ପୂର୍ବରୁ ବ୍ୟାନ ହୋଇଛନ୍ତି। + ଆପଣ %2$s ଙ୍କୁ %1$s କରିପାରିବେ ନାହିଁ। + ଏହି ୟୁଜରଙ୍କ ଉପରେ ଏକ ବିରୋଧାତ୍ମକ ବ୍ୟାନ କାର୍ଯ୍ୟ ଥିଲା। ଦୟାକରି ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ରଙ୍ଗ Twitch ସମର୍ଥିତ ରଙ୍ଗଗୁଡ଼ିକ ମଧ୍ୟରୁ ଗୋଟିଏ (%1$s) କିମ୍ବା ଯଦି ଆପଣଙ୍କ ପାଖରେ Turbo କିମ୍ବା Prime ଅଛି ତେବେ hex code (#000000) ହେବା ଉଚିତ। + ବିଜ୍ଞାପନ ଚଲାଇବା ପାଇଁ ଆପଣ ଲାଇଭ ଷ୍ଟ୍ରିମିଂ କରୁଥିବା ଆବଶ୍ୟକ। + ଆଉ ଏକ ବିଜ୍ଞାପନ ଚଲାଇବା ପୂର୍ବରୁ ଆପଣଙ୍କ କୁଲ୍-ଡାଉନ୍ ସମୟ ଶେଷ ହେବା ପର୍ଯ୍ୟନ୍ତ ଅପେକ୍ଷା କରନ୍ତୁ। + କମାଣ୍ଡରେ ଶୂନ୍ୟରୁ ଅଧିକ ବିଜ୍ଞାପନ ବ୍ରେକ୍ ଲମ୍ବ ଅନ୍ତର୍ଭୁକ୍ତ ହେବା ଆବଶ୍ୟକ। + ଆପଣଙ୍କର କୌଣସି ସକ୍ରିୟ ରେଡ୍ ନାହିଁ। + ଏକ ଚ୍ୟାନେଲ ନିଜକୁ ରେଡ୍ କରିପାରିବ ନାହିଁ। + ବ୍ରଡ୍‌କାଷ୍ଟର ନିଜକୁ Shoutout ଦେଇପାରିବେ ନାହିଁ। + ବ୍ରଡ୍‌କାଷ୍ଟର ଲାଇଭ ଷ୍ଟ୍ରିମିଂ କରୁନାହାଁନ୍ତି କିମ୍ବା ଏକ ବା ଅଧିକ ଦର୍ଶକ ନାହାଁନ୍ତି। + ସମୟସୀମା ବୈଧ ସୀମାରୁ ବାହାରେ: %1$s। + ମେସେଜ୍ ପୂର୍ବରୁ ପ୍ରକ୍ରିୟାକୃତ ହୋଇସାରିଛି। + ଲକ୍ଷ୍ୟ ମେସେଜ୍ ମିଳିଲା ନାହିଁ। + ଆପଣଙ୍କ ମେସେଜ୍ ବହୁତ ଲମ୍ବା ଥିଲା। + ଆପଣଙ୍କ ହାର ସୀମିତ ହୋଇଛି। କିଛି ସମୟ ପରେ ପୁନଃଚେଷ୍ଟା କରନ୍ତୁ। + ଲକ୍ଷ୍ୟ ୟୁଜର + ଲଗ୍ ଦର୍ଶକ + ଆପ୍ଲିକେସନ୍ ଲଗ୍ ଦେଖନ୍ତୁ + ଲଗ୍ + ଲଗ୍ ସେୟାର କରନ୍ତୁ + ଲଗ୍ ଦେଖନ୍ତୁ + କୌଣସି ଲଗ୍ ଫାଇଲ୍ ଉପಲବ୍ଧ ନାହିଁ + ଲଗ୍ ଖୋଜନ୍ତୁ + + %1$d ଚୟନ ହୋଇଛି + %1$d ଚୟନ ହୋଇଛି + + ଚୟନିତ ଲଗ୍ କପି କରନ୍ତୁ + ଚୟନ ସଫା କରନ୍ତୁ + କ୍ର୍ୟାସ ଚିହ୍ନଟ ହୋଇଛି + ଆପଣଙ୍କ ଶେଷ ସେସନ୍ ସମୟରେ ଆପ୍ କ୍ର୍ୟାସ ହୋଇଥିଲା। + ଥ୍ରେଡ୍: %1$s + କପି + ଚ୍ୟାଟ୍ ରିପୋର୍ଟ + #flex3rs ରେ ଯୋଗ ଦେଇ ପଠାଇବା ପାଇଁ ଏକ କ୍ର୍ୟାସ ସାରାଂଶ ପ୍ରସ୍ତୁତ କରେ + ଇମେଲ୍ ରିପୋର୍ଟ + ଇମେଲ୍ ମାଧ୍ୟମରେ ଏକ ବିସ୍ତୃତ କ୍ର୍ୟାସ ରିପୋର୍ଟ ପଠାନ୍ତୁ + ଇମେଲ୍ ମାଧ୍ୟମରେ କ୍ର୍ୟାସ ରିପୋର୍ଟ ପଠାନ୍ତୁ + ନିମ୍ନଲିଖିତ ତଥ୍ୟ ରିପୋର୍ଟରେ ଅନ୍ତର୍ଭୁକ୍ତ ହେବ: + ଷ୍ଟାକ୍ ଟ୍ରେସ୍ + ବର୍ତ୍ତମାନର ଲଗ୍ ଫାଇଲ୍ ଅନ୍ତର୍ଭୁକ୍ତ କରନ୍ତୁ + କ୍ର୍ୟାସ ରିପୋର୍ଟ + ସାମ୍ପ୍ରତିକ କ୍ର୍ୟାସ ରିପୋର୍ଟ ଦେଖନ୍ତୁ + କୌଣସି କ୍ର୍ୟାସ ରିପୋର୍ଟ ମିଳିଲା ନାହିଁ + କ୍ର୍ୟାସ ରିପୋର୍ଟ + କ୍ର୍ୟାସ ରିପୋର୍ଟ ସେୟାର କରନ୍ତୁ + ଡିଲିଟ୍ + ଏହି କ୍ର୍ୟାସ ରିପୋର୍ଟ ଡିଲିଟ୍ କରିବେ? + ସବୁ ସଫା କରନ୍ତୁ + ସବୁ କ୍ର୍ୟାସ ରିପୋର୍ଟ ଡିଲିଟ୍ କରିବେ? + ତଳକୁ ସ୍କ୍ରୋଲ୍ କରନ୍ତୁ + ଇତିହାସ ଦେଖନ୍ତୁ + ବର୍ତ୍ତମାନର ଫିଲ୍ଟରଗୁଡ଼ିକ ସହ କୌଣସି ସନ୍ଦେଶ ମେଳ ଖାଉନାହିଁ diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 1c383a6bf..a410f06e4 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -48,7 +48,7 @@ Rozłączono Nie jesteś zalogowany Odpowiedz - Masz nowe wzmianki + Send announcement Masz nowe wzmianki %1$s wspomniał o Tobie w #%2$s Zostałeś wspomniany w #%1$s Logowanie jako %1$s @@ -56,12 +56,66 @@ Skopiowano: %1$s Błąd podczas przesyłania Błąd podczas przesyłania: %1$s + Prześlij + Skopiowano do schowka + Kopiuj URL Ponów Przeładowano emotki Błąd podczas ładowania danych: %1$s Ładowanie danych nie powiodło się z wieloma błędami:\n%1$s + Odznaki DankChat + Globalne odznaki + Globalne emotki FFZ + Globalne emotki BTTV + Globalne emotki 7TV + Odznaki kanału + Emotki FFZ + Emotki BTTV + Emotki 7TV + Emotki Twitch + Cheermoty + Ostatnie wiadomości + %1$s (%2$s) + + Pierwsza wiadomość + Podwyższony czat + Gigantyczny emote + Animowana wiadomość + Wymieniono %1$s + %1$d sekundę + %1$d sekundy + %1$d sekund + %1$d sekund + + + %1$d minutę + %1$d minuty + %1$d minut + %1$d minut + + + %1$d godzinę + %1$d godziny + %1$d godzin + %1$d godzin + + + %1$d dzień + %1$d dni + %1$d dni + %1$d dni + + + %1$d tydzień + %1$d tygodnie + %1$d tygodni + %1$d tygodni + + %1$s %2$s + %1$s %2$s %3$s Wklej Nazwa kanału + Kanał jest już dodany Ostatnie Subskrybcje Kanał @@ -157,6 +211,8 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Dodaj polecenie Usuń to polecenie Uruchom + Ten wyzwalacz jest zarezerwowany przez wbudowane polecenie + Ten wyzwalacz jest już używany przez inne polecenie Polecenie Niestandardowe polecenia Zgłoś @@ -194,14 +250,54 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Powiadomienia Czat Ogólne + Podpowiedzi + Wiadomości + Użytkownicy + Emotki i odznaki O aplikacji Wygląd DankChat %1$s stworzony przez @flex3rs i kontrybutorów Pokazuj pole czatu Pokazuje pole czatu do wysyłania wiadomości Użyj ustawień systemu - Użyj ciemniejszego wyglądu - Zmienia kolor tła na czarne + Tryb AMOLED + Czysto czarne tło dla ekranów OLED + Kolor akcentu + Podążaj za tapetą systemową + Niebieski + Morski + Zielony + Limonkowy + Żółty + Pomarańczowy + Czerwony + Różowy + Fioletowy + Indygo + Brązowy + Szary + Styl kolorów + Domyślny systemowy + Użyj domyślnej palety kolorów systemu + Tonal Spot + Spokojne i stonowane kolory + Neutral + Prawie monochromatyczny, subtelny odcień + Vibrant + Odważne i nasycone kolory + Expressive + Zabawne kolory z przesuniętymi odcieniami + Rainbow + Szerokie spektrum odcieni + Fruit Salad + Zabawna, wielokolorowa paleta + Monochrome + Tylko czarny, biały i szary + Fidelity + Wiernie odwzorowuje kolor akcentu + Content + Kolor akcentu z analogicznym kolorem trzecim + Więcej stylów Wyświetl Komponenty Pokazuj usunięte wiadomości @@ -213,8 +309,19 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Mały Duży Bardzo duży - Emotki i sugestie - Pokazuj sugestie i aktywnych użytkowników podczas pisania + Podpowiedzi + Wybierz, które podpowiedzi wyświetlać podczas pisania + Emotki + Użytkownicy + Komendy Twitcha + Komendy Supibota + Aktywuj za pomocą : + Aktywuj za pomocą @ + Aktywuj za pomocą / + Aktywuj za pomocą $ + Tryb podpowiedzi + Sugeruj dopasowania podczas pisania + Sugeruj tylko po znaku wyzwalającym Ładuj historię wiadomości podczas startu Załaduj historie wiadomości po ponownym połączeniu Próbuje pobrać brakujące wiadomości, które nie zostały odebrane podczas zerwania połączenia @@ -224,7 +331,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Dane kanału Opcje deweloperskie Tryb debugowania - Dostarcza informacje o wszelkich wykrytych błędach + Pokaż akcję analityki debugowania na pasku wprowadzania i zbieraj raporty o awariach lokalnie Format znacznika czasu Włącz TTS Odczytuje na głos wiadomości aktywnego kanału @@ -238,6 +345,9 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Ignoruj URLs Ignoruje emotki i emotikony podczas korzystania z TTS Ignoruj emotki + Głośność + Wyciszanie dźwięku + Zmniejsz głośność innych aplikacji podczas odtwarzania TTS TTS Zmienny kolor tła Oddziel każdą wiadomość inną jasnością tła @@ -250,12 +360,19 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Zachowanie po przytrzymaniu nazwy użytkownika Dotknięcie otwiera informacje o użytkowniku, przytrzymanie wspomina użytkownika na czacie Dotknięcie wspomina użytkownika na czacie, przytrzymanie otwiera informacje o użytkowniku + Koloruj pseudonimy + Przypisz losowy kolor użytkownikom bez ustawionego koloru Wymuś język angielski Wymuś język wiadomości TTS na angielski zamiast domyślnego języka systemu Widoczne emotki z dotatków Warunki korzystania z usługi Twitch & polisa użytkownika: Pokaż akcje chipa Wyświetla chipy dla przełączania pełnego ekranu, transmisji i dostosowywania trybów chatu + Pokaż licznik znaków + Wyświetla liczbę punktów kodowych w polu wprowadzania + Pokaż przycisk czyszczenia pola + Pokaż przycisk wysyłania + Pole tekstowe Przesyłacz media Przesyłacz konfiguracji Ostatnio udostępnione @@ -284,6 +401,9 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Niestandardowy login Pomiń obsługę poleceń Twitcha Wyłącza przechwytywanie poleceń Twitcha i zamiast tego wysyła je na czat. + Protokół wysyłania czatu + Użyj Helix API do wysyłania + Wysyłaj wiadomości czatu przez Twitch Helix API zamiast IRC Aktualizacje emotek 7TV na żywo Zachowanie w tle aktualizacji emotek na żywo Aktualizacje zostaną zatrzymane po %1$s.\nZmniejszenie tej liczby może wydłużyć czas pracy baterii. @@ -330,6 +450,7 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Twoja nazwa użytkownika Subskrypcje i Wydarzenia Ogłoszenia + Serie oglądania Pierwsza Wiadomość Podwyższone Czaty Wyróżnienie odebrane za Punkty Kanału @@ -356,9 +477,22 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Kopiuj wiadomość Kopiuj pełną wiadomość Odpowiedz na wiadomość + Odpowiedz na oryginalną wiadomość Zobacz wątek Kopiuj id wiadomości Więcej… + Przejdź do wiadomości + Wiadomość nie jest już w historii czatu + Historia wiadomości + Globalna historia + Historia: %1$s + Szukaj wiadomości… + Filtruj po nazwie użytkownika + Wiadomości zawierające linki + Wiadomości zawierające emotki + Filtruj po nazwie odznaki + Użytkownik + Odznaka Odpowiadasz @%1$s Nie znaleziono wątku Nie znaleziono wiadomości @@ -421,4 +555,385 @@ Aby zapewnić tą usługę, wspomniany serwis tymczasowo przechowuje wiadomości Wybierz niestandardowy kolor podświetlenia Domyślny Wybierz Kolor + Przełącz pasek aplikacji + Błąd: %s + Wylogować się? + Usunąć ten kanał? + Usunąć kanał \"%1$s\"? + Zablokować kanał \"%1$s\"? + Zbanować tego użytkownika? + Usunąć tę wiadomość? + Wyczyścić czat? + Konfigurowalne akcje szybkiego dostępu do wyszukiwania, streamów i więcej + Stuknij tutaj, aby uzyskać więcej akcji i skonfigurować pasek akcji + Tutaj możesz dostosować, które akcje pojawiają się na pasku akcji + Przesuń w dół na polu wpisywania, aby szybko je ukryć + Stuknij tutaj, aby przywrócić pole wpisywania + Dalej + Rozumiem + Pomiń przewodnik + Tutaj możesz dodać więcej kanałów + + + Wiadomość wstrzymana z powodu: %1$s. Zezwolenie opublikuje ją na czacie. + Zezwól + Odrzuć + Zatwierdzono + Odrzucono + Wygasło + Hej! Twoja wiadomość jest sprawdzana przez moderatorów i nie została jeszcze wysłana. + Moderatorzy zaakceptowali Twoją wiadomość. + Moderatorzy odrzucili Twoją wiadomość. + %1$s (poziom %2$d) + + pasuje do %1$d zablokowanego wyrażenia %2$s + pasuje do %1$d zablokowanych wyrażeń %2$s + pasuje do %1$d zablokowanych wyrażeń %2$s + pasuje do %1$d zablokowanych wyrażeń %2$s + + Nie udało się %1$s wiadomości AutoMod - wiadomość została już przetworzona. + Nie udało się %1$s wiadomości AutoMod - musisz się ponownie zalogować. + Nie udało się %1$s wiadomości AutoMod - nie masz uprawnień do wykonania tej czynności. + Nie udało się %1$s wiadomości AutoMod - nie znaleziono wiadomości docelowej. + Nie udało się %1$s wiadomości AutoMod - wystąpił nieznany błąd. + %1$s dodał/a %2$s jako zablokowane wyrażenie w AutoMod. + %1$s dodał/a %2$s jako dozwolone wyrażenie w AutoMod. + %1$s usunął/ęła %2$s jako zablokowane wyrażenie z AutoMod. + %1$s usunął/ęła %2$s jako dozwolone wyrażenie z AutoMod. + + + + Zostałeś/aś wyciszony/a na %1$s + Zostałeś/aś wyciszony/a na %1$s przez %2$s + Zostałeś/aś wyciszony/a na %1$s przez %2$s: %3$s + %1$s wyciszył/a %2$s na %3$s + %1$s wyciszył/a %2$s na %3$s: %4$s + %1$s został/a wyciszony/a na %2$s + Zostałeś/aś zbanowany/a + Zostałeś/aś zbanowany/a przez %1$s + Zostałeś/aś zbanowany/a przez %1$s: %2$s + %1$s zbanował/a %2$s + %1$s zbanował/a %2$s: %3$s + %1$s został/a permanentnie zbanowany/a + %1$s odciszył/a %2$s + %1$s odbanował/a %2$s + %1$s nadał/a moderatora %2$s + %1$s odebrał/a moderatora %2$s + %1$s dodał/a %2$s jako VIP tego kanału + %1$s usunął/ęła %2$s jako VIP tego kanału + %1$s ostrzegł/a %2$s + %1$s ostrzegł/a %2$s: %3$s + %1$s rozpoczął/ęła rajd na %2$s + %1$s anulował/a rajd na %2$s + %1$s usunął/ęła wiadomość od %2$s + %1$s usunął/ęła wiadomość od %2$s mówiąc: %3$s + Wiadomość od %1$s została usunięta + Wiadomość od %1$s została usunięta mówiąc: %2$s + %1$s wyczyścił/a czat + Czat został wyczyszczony przez moderatora + %1$s włączył/a tryb tylko emotki + %1$s wyłączył/a tryb tylko emotki + %1$s włączył/a tryb tylko dla obserwujących + %1$s włączył/a tryb tylko dla obserwujących (%2$s) + %1$s wyłączył/a tryb tylko dla obserwujących + %1$s włączył/a tryb unikalnego czatu + %1$s wyłączył/a tryb unikalnego czatu + %1$s włączył/a tryb powolny + %1$s włączył/a tryb powolny (%2$s) + %1$s wyłączył/a tryb powolny + %1$s włączył/a tryb tylko dla subskrybentów + %1$s wyłączył/a tryb tylko dla subskrybentów + %1$s wyciszył/a %2$s na %3$s w %4$s + %1$s wyciszył/a %2$s na %3$s w %4$s: %5$s + %1$s odciszył/a %2$s w %3$s + %1$s zbanował/a %2$s w %3$s + %1$s zbanował/a %2$s w %3$s: %4$s + %1$s odbanował/a %2$s w %3$s + %1$s usunął/ęła wiadomość od %2$s w %3$s + %1$s usunął/ęła wiadomość od %2$s w %3$s mówiąc: %4$s + %1$s%2$s + + \u0020(%1$d raz) + \u0020(%1$d razy) + \u0020(%1$d razy) + \u0020(%1$d razy) + + + + Cofnij + Wyślij szept + Szept do @%1$s + Nowy szept + Wyślij szept do + Nazwa użytkownika + Wyślij + + + Tylko emotki + Tylko subskrybenci + Tryb powolny + Tryb powolny (%1$s) + Unikalny czat (R9K) + Tylko obserwujący + Tylko obserwujący (%1$s) + Własne + Dowolny + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Aktywować tryb tarczy? + Spowoduje to zastosowanie wstępnie skonfigurowanych ustawień bezpieczeństwa kanału, które mogą obejmować ograniczenia czatu, ustawienia AutoMod i wymagania weryfikacji. + Aktywuj Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Dodaj kanał, aby zacząć czatować + Brak ostatnich emotek + + + Pokaż transmisję + Ukryj transmisję + Tylko dźwięk + Wyłącz tryb audio + Pełny ekran + Wyjdź z pełnego ekranu + Ukryj pole wpisywania + Pokaż pole wpisywania + Nawigacja kanałów przesunięciem + Przełączaj kanały przesuwając na czacie + Moderacja kanału + + + Szukaj wiadomości + Ostatnia wiadomość + Przełącz transmisję + Moderacja kanału + Pełny ekran + Ukryj pole wpisywania + Konfiguruj akcje + Debugowanie + + Maksymalnie %1$d akcja + Maksymalnie %1$d akcje + Maksymalnie %1$d akcji + Maksymalnie %1$d akcji + + + + DankChat + Skonfigurujmy wszystko. + Zaloguj się przez Twitch + Zaloguj się, aby wysyłać wiadomości, używać swoich emotek, otrzymywać szepty i odblokować wszystkie funkcje. + Zostaniesz poproszony o przyznanie kilku uprawnień Twitch jednocześnie, dzięki czemu nie będziesz musiał ponownie autoryzować dostępu przy korzystaniu z różnych funkcji. DankChat wykonuje działania moderacyjne i dotyczące transmisji tylko na Twoje polecenie. + Zaloguj się przez Twitch + Logowanie udane + Powiadomienia + DankChat może powiadamiać Cię, gdy ktoś wspomni o Tobie na czacie, gdy aplikacja działa w tle. + Zezwól na powiadomienia + Otwórz ustawienia powiadomień + Bez powiadomień nie będziesz wiedzieć, gdy ktoś wspomni o Tobie na czacie, gdy aplikacja działa w tle. + Historia wiadomości + DankChat ładuje historyczne wiadomości z zewnętrznego serwisu przy uruchomieniu. Aby pobrać wiadomości, DankChat wysyła nazwy otwartych kanałów do tego serwisu. Serwis tymczasowo przechowuje wiadomości odwiedzanych kanałów.\n\nMożesz to zmienić później w ustawieniach lub dowiedzieć się więcej na https://recent-messages.robotty.de/ + Włącz + Wyłącz + Dalej + Rozpocznij + Pomiń + + + Ogólne + Autoryzacja + Włącz Twitch EventSub + Używa EventSub do różnych zdarzeń w czasie rzeczywistym zamiast przestarzałego PubSub + Włącz dane debugowania EventSub + Wyświetla dane debugowania związane z EventSub jako wiadomości systemowe + Unieważnij token i uruchom ponownie + Unieważnia bieżący token i restartuje aplikację + Nie zalogowano + Nie udało się uzyskać ID kanału dla %1$s + Wiadomość nie została wysłana + Wiadomość odrzucona: %1$s (%2$s) + Brak uprawnienia user:write:chat, zaloguj się ponownie + Brak uprawnień do wysyłania wiadomości na tym kanale + Wiadomość jest zbyt duża + Osiągnięto limit częstotliwości, spróbuj ponownie za chwilę + Wysyłanie nie powiodło się: %1$s + + + Musisz być zalogowany, aby użyć komendy %1$s + Nie znaleziono użytkownika o tej nazwie. + Wystąpił nieznany błąd. + Nie masz uprawnień do wykonania tej czynności. + Brak wymaganego uprawnienia. Zaloguj się ponownie i spróbuj jeszcze raz. + Brak danych logowania. Zaloguj się ponownie i spróbuj jeszcze raz. + Użycie: /block <użytkownik> + Pomyślnie zablokowano użytkownika %1$s + Nie udało się zablokować użytkownika %1$s, nie znaleziono użytkownika o tej nazwie! + Nie udało się zablokować użytkownika %1$s, wystąpił nieznany błąd! + Użycie: /unblock <użytkownik> + Pomyślnie odblokowano użytkownika %1$s + Nie udało się odblokować użytkownika %1$s, nie znaleziono użytkownika o tej nazwie! + Nie udało się odblokować użytkownika %1$s, wystąpił nieznany błąd! + Kanał nie jest na żywo. + Czas nadawania: %1$s + Komendy dostępne dla Ciebie w tym pokoju: %1$s + Użycie: %1$s <nazwa użytkownika> <wiadomość>. + Szept wysłany. + Nie udało się wysłać szeptu - %1$s + Użycie: %1$s <wiadomość> - Zwróć uwagę na swoją wiadomość za pomocą wyróżnienia. + Nie udało się wysłać ogłoszenia - %1$s + Ten kanał nie ma żadnych moderatorów. + Moderatorzy tego kanału to %1$s. + Nie udało się wyświetlić moderatorów - %1$s + Użycie: %1$s <nazwa użytkownika> - Nadaj status moderatora użytkownikowi. + Dodano %1$s jako moderatora tego kanału. + Nie udało się dodać moderatora kanału - %1$s + Użycie: %1$s <nazwa użytkownika> - Odbierz status moderatora użytkownikowi. + Usunięto %1$s z moderatorów tego kanału. + Nie udało się usunąć moderatora kanału - %1$s + Ten kanał nie ma żadnych VIP-ów. + VIP-y tego kanału to %1$s. + Nie udało się wyświetlić VIP-ów - %1$s + Użycie: %1$s <nazwa użytkownika> - Nadaj status VIP użytkownikowi. + Dodano %1$s jako VIP tego kanału. + Nie udało się dodać VIP - %1$s + Użycie: %1$s <nazwa użytkownika> - Odbierz status VIP użytkownikowi. + Usunięto %1$s z VIP-ów tego kanału. + Nie udało się usunąć VIP - %1$s + Użycie: %1$s <nazwa użytkownika> [powód] - Trwale zablokuj użytkownikowi możliwość czatowania. Powód jest opcjonalny i będzie widoczny dla użytkownika oraz innych moderatorów. Użyj /unban, aby usunąć bana. + Nie udało się zbanować użytkownika - Nie możesz zbanować siebie. + Nie udało się zbanować użytkownika - Nie możesz zbanować nadawcy. + Nie udało się zbanować użytkownika - %1$s + Użycie: %1$s <nazwa użytkownika> - Usuwa bana z użytkownika. + Nie udało się odbanować użytkownika - %1$s + Użycie: %1$s <nazwa użytkownika> [czas trwania][jednostka czasu] [powód] - Tymczasowo zablokuj użytkownikowi możliwość czatowania. Czas trwania (opcjonalny, domyślnie: 10 minut) musi być dodatnią liczbą całkowitą; jednostka czasu (opcjonalna, domyślnie: s) musi być jedną z s, m, h, d, w; maksymalny czas to 2 tygodnie. Powód jest opcjonalny i będzie widoczny dla użytkownika oraz innych moderatorów. + Nie udało się zbanować użytkownika - Nie możesz dać sobie timeout. + Nie udało się zbanować użytkownika - Nie możesz dać timeout nadawcy. + Nie udało się dać timeout użytkownikowi - %1$s + Nie udało się usunąć wiadomości czatu - %1$s + Użycie: /delete <msg-id> - Usuwa określoną wiadomość. + Nieprawidłowy msg-id: \"%1$s\". + Nie udało się usunąć wiadomości czatu - %1$s + Użycie: /color <kolor> - Kolor musi być jednym z obsługiwanych kolorów Twitcha (%1$s) lub kodem hex (#000000), jeśli masz Turbo lub Prime. + Twój kolor został zmieniony na %1$s + Nie udało się zmienić koloru na %1$s - %2$s + Pomyślnie dodano znacznik streamu o %1$s%2$s. + Nie udało się utworzyć znacznika streamu - %1$s + Użycie: /commercial <długość> - Uruchamia reklamę o podanym czasie trwania dla bieżącego kanału. Prawidłowe opcje to 30, 60, 90, 120, 150 i 180 sekund. + + Rozpoczynanie %1$d-sekundowej przerwy reklamowej. Pamiętaj, że nadal jesteś na żywo i nie wszyscy widzowie zobaczą reklamę. Możesz uruchomić kolejną reklamę za %2$d sekund. + Rozpoczynanie %1$d-sekundowej przerwy reklamowej. Pamiętaj, że nadal jesteś na żywo i nie wszyscy widzowie zobaczą reklamę. Możesz uruchomić kolejną reklamę za %2$d sekund. + Rozpoczynanie %1$d-sekundowej przerwy reklamowej. Pamiętaj, że nadal jesteś na żywo i nie wszyscy widzowie zobaczą reklamę. Możesz uruchomić kolejną reklamę za %2$d sekund. + Rozpoczynanie %1$d-sekundowej przerwy reklamowej. Pamiętaj, że nadal jesteś na żywo i nie wszyscy widzowie zobaczą reklamę. Możesz uruchomić kolejną reklamę za %2$d sekund. + + Nie udało się rozpocząć reklamy - %1$s + Użycie: /raid <nazwa użytkownika> - Raiduj użytkownika. Tylko nadawca może rozpocząć raid. + Nieprawidłowa nazwa użytkownika: %1$s + Rozpoczęto raid na %1$s. + Nie udało się rozpocząć raidu - %1$s + Anulowano raid. + Nie udało się anulować raidu - %1$s + Użycie: %1$s [czas trwania] - Włącza tryb tylko dla obserwujących (tylko obserwujący mogą czatować). Czas trwania (opcjonalny, domyślnie: 0 minut) musi być dodatnią liczbą z jednostką czasu (m, h, d, w); maksymalny czas to 3 miesiące. + Ten pokój jest już w trybie tylko dla obserwujących od %1$s. + Nie udało się zaktualizować ustawień czatu - %1$s + Ten pokój nie jest w trybie tylko dla obserwujących. + Ten pokój jest już w trybie tylko emotki. + Ten pokój nie jest w trybie tylko emotki. + Ten pokój jest już w trybie tylko dla subskrybentów. + Ten pokój nie jest w trybie tylko dla subskrybentów. + Ten pokój jest już w trybie unikalnego czatu. + Ten pokój nie jest w trybie unikalnego czatu. + Użycie: %1$s [czas trwania] - Włącza tryb powolny (ogranicza częstotliwość wysyłania wiadomości). Czas trwania (opcjonalny, domyślnie: 30) musi być dodatnią liczbą sekund; maksymalnie 120. + Ten pokój jest już w %1$d-sekundowym trybie powolnym. + Ten pokój nie jest w trybie powolnym. + Użycie: %1$s <nazwa użytkownika> - Wysyła wyróżnienie dla podanego użytkownika Twitcha. + Wysłano wyróżnienie dla %1$s + Nie udało się wysłać wyróżnienia - %1$s + Tryb tarczy został aktywowany. + Tryb tarczy został dezaktywowany. + Nie udało się zaktualizować trybu tarczy - %1$s + Nie możesz szeptać do siebie. + Z powodu ograniczeń Twitcha wymagany jest zweryfikowany numer telefonu, aby wysyłać szepty. Możesz dodać numer telefonu w ustawieniach Twitcha. https://www.twitch.tv/settings/security + Odbiorca nie akceptuje szeptów od nieznajomych lub bezpośrednio od Ciebie. + Twitch ogranicza Twoją częstotliwość. Spróbuj ponownie za kilka sekund. + Możesz szeptać do maksymalnie 40 unikalnych odbiorców dziennie. W ramach dziennego limitu możesz wysłać maksymalnie 3 szepty na sekundę i 100 szeptów na minutę. + Z powodu ograniczeń Twitcha ta komenda może być używana tylko przez nadawcę. Użyj strony Twitcha zamiast tego. + %1$s jest już moderatorem tego kanału. + %1$s jest obecnie VIP-em, użyj /unvip i spróbuj ponownie. + %1$s nie jest moderatorem tego kanału. + %1$s nie jest zbanowany na tym kanale. + %1$s jest już zbanowany na tym kanale. + Nie możesz %1$s %2$s. + Wystąpił konflikt operacji bana na tym użytkowniku. Spróbuj ponownie. + Kolor musi być jednym z obsługiwanych kolorów Twitcha (%1$s) lub kodem hex (#000000), jeśli masz Turbo lub Prime. + Musisz nadawać na żywo, aby uruchamiać reklamy. + Musisz poczekać na zakończenie okresu odnowienia, zanim uruchomisz kolejną reklamę. + Komenda musi zawierać żądaną długość przerwy reklamowej większą od zera. + Nie masz aktywnego raidu. + Kanał nie może raidować samego siebie. + Nadawca nie może dać sobie wyróżnienia. + Nadawca nie jest na żywo lub nie ma jednego lub więcej widzów. + Czas trwania jest poza prawidłowym zakresem: %1$s. + Wiadomość została już przetworzona. + Nie znaleziono docelowej wiadomości. + Twoja wiadomość była zbyt długa. + Twitch ogranicza Twoją częstotliwość. Spróbuj ponownie za chwilę. + Docelowy użytkownik + Przeglądarka logów + Wyświetl logi aplikacji + Logi + Udostępnij logi + Wyświetl logi + Brak dostępnych plików logów + Szukaj w logach + + Wybrano %1$d + Wybrano %1$d + Wybrano %1$d + Wybrano %1$d + + Kopiuj wybrane logi + Wyczyść zaznaczenie + Wykryto awarię + Aplikacja uległa awarii podczas ostatniej sesji. + Wątek: %1$s + Kopiuj + Raport na czacie + Dołącza do #flex3rs i przygotowuje podsumowanie awarii do wysłania + Raport e-mail + Wyślij szczegółowy raport o awarii e-mailem + Wyślij raport o awarii e-mailem + Następujące dane zostaną zawarte w raporcie: + Ślad stosu + Dołącz bieżący plik logów + Raporty o awariach + Przeglądaj ostatnie raporty o awariach + Nie znaleziono raportów o awariach + Raport o awarii + Udostępnij raport o awarii + Usuń + Usunąć ten raport o awarii? + Wyczyść wszystko + Usunąć wszystkie raporty o awariach? + Przewiń na dół + Przesyłanie zakończone: %1$s + Zobacz historię + Brak wiadomości pasujących do bieżących filtrów diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c59ab287a..0cdeab39a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -48,20 +48,71 @@ Desconectado Sessão não iniciada Responder - Você tem novas menções + Send announcement Você tem novas menções %1$s acabou de mencionar você em #%2$s Você foi mencionado em #%1$s Iniciando sessão como %1$s Falha ao iniciar sessão Copiado: %1$s + Upload concluído: %1$s Erro durante o envio Erro durante o envio: %1$s + Enviar + Copiado para a área de transferência + Copiar URL Tentar Novamente Emotes recarregados Falha no carregamento de dados: %1$s Carregamento de dados falhou com vários erros:\n%1$s + Badges DankChat + Badges globais + Emotes FFZ globais + Emotes BTTV globais + Emotes 7TV globais + Badges do canal + Emotes FFZ + Emotes BTTV + Emotes 7TV + Emotes Twitch + Cheermotes + Mensagens recentes + %1$s (%2$s) + + Primeira mensagem + Mensagem elevada + Emote gigante + Mensagem animada + Resgatado %1$s + + %1$d segundo + %1$d segundos + %1$d segundos + + + %1$d minuto + %1$d minutos + %1$d minutos + + + %1$d hora + %1$d horas + %1$d horas + + + %1$d dia + %1$d dias + %1$d dias + + + %1$d semana + %1$d semanas + %1$d semanas + + %1$s %2$s + %1$s %2$s %3$s Colar Nome do canal + O canal já foi adicionado Recente Inscritos Canal @@ -152,6 +203,8 @@ Adicionar um comando Remover o comando Ativador + Este ativador é reservado por um comando integrado + Este ativador já está sendo usado por outro comando Comando Comandos personalizados Denunciar @@ -191,12 +244,52 @@ Geral Sobre Aparência + Sugestões + Mensagens + Usuários + Emotes & Insígnias DankChat %1$s feito por @flex3rs e colaboradores Mostrar caixa de entrada Exibe o campo de entrada para enviar mensagens Seguir o padrão do sistema - Verdadeiro tema escuro - Força a cor de fundo do chat para preto + Modo escuro AMOLED + Fundos pretos puros para telas OLED + Cor de destaque + Seguir papel de parede do sistema + Azul + Azul-petróleo + Verde + Lima + Amarelo + Laranja + Vermelho + Rosa + Roxo + Índigo + Marrom + Cinza + Estilo de cor + Padrão do sistema + Usar a paleta de cores padrão do sistema + Tonal Spot + Cores calmas e suaves + Neutral + Quase monocromático, tom sutil + Vibrant + Cores vivas e saturadas + Expressive + Cores lúdicas com tons deslocados + Rainbow + Amplo espectro de tons + Fruit Salad + Paleta lúdica e multicolorida + Monochrome + Apenas preto, branco e cinza + Fidelity + Fiel à cor de destaque + Content + Cor de destaque com terciário análogo + Mais estilos Exibição Componentes Mostrar mensagens apagadas @@ -208,8 +301,19 @@ Pequena Grande Muito Grande - Sugestões de emotes e usuários - Mostra sugestões de emotes e usuários ativos enquanto digita + Sugestões + Escolha quais sugestões mostrar ao digitar + Emotes + Usuários + Comandos do Twitch + Comandos do Supibot + Ativar com : + Ativar com @ + Ativar com / + Ativar com $ + Modo de sugestao + Sugerir correspondências enquanto digita + Sugerir apenas após um caractere de ativação Carregar histórico de mensagens no início Carregar histórico de mensagens após reconexão Tenta carregar mensagens perdidas que não foram recebidas durante quedas de conexão @@ -219,7 +323,7 @@ Dados do canal Opções de desenvolvedor Modo de depuração - Fornece informações para quaisquer exceções que foram capturadas + Mostrar ação de análise de depuração na barra de entrada e coletar relatórios de crash localmente Formato de data e hora Ativar Texto-para-voz Lê as mensagens do canal ativo @@ -233,6 +337,9 @@ Ignorar URLs Ignora emotes e emojis no Texto-para-voz Ignorar emotes + Volume + Redução de áudio + Reduzir o volume de outros áudios enquanto o TTS fala Texto-para-voz Fundo alternado Separa cada comentário com um brilho de fundo diferente @@ -245,12 +352,19 @@ Comportamento de clique longo do usuário Clique regular abre janela de usuário, clique longo menciona Clique regular menciona, clique longo abre janela + Colorir apelidos + Atribuir uma cor aleatória a usuários sem uma cor definida Forçar idioma para Inglês Força o idioma do Texto-para-voz para inglês invés do padrão do sistema Emotes de terceiros visíveis Termos de serviço do Twitch & política do usuário: Mostrar botão de ações Exibe um botão para ativar tela cheia, transmissões e ajustar o modo do chat + Mostrar contador de caracteres + Exibe a contagem de code points no campo de entrada + Mostrar botão de limpar entrada + Mostrar botão de enviar + Entrada Carregador de mídia Configurar carregador Envios recentes @@ -279,6 +393,9 @@ Login customizado Ignorar manipulação de comandos da Twitch Desativa a interceptação de comandos da Twitch e os envia para o chat + Protocolo de envio do chat + Usar Helix API para enviar + Enviar mensagens do chat via Twitch Helix API em vez de IRC Atualização de emotes 7TV ao vivo Atualização em segundo plano de alterações de emotes Atualizações param após %1$s.\nDiminuir esse número pode aumentar a duração da bateria. @@ -323,6 +440,7 @@ Seu Nome Inscrições e Eventos Avisos + Sequências de visualização Primeiras Mensagens Mensagens Elevadas Destaques resgatados com Pontos de Canal @@ -348,9 +466,22 @@ Copiar mensagem Copiar mensagem inteira Responder mensagem + Responder à mensagem original Ver tópico Copiar ID da mensagem Mais… + Ir para a mensagem + Mensagem não está mais no histórico do chat + Histórico de mensagens + Histórico global + Histórico: %1$s + Pesquisar mensagens… + Filtrar por nome de usuário + Mensagens contendo links + Mensagens contendo emotes + Filtrar por nome de emblema + Usuário + Emblema Respondendo a @%1$s Tópico não encontrado Mensagem não encontrada @@ -381,18 +512,410 @@ Chat Unido Ao vivo com %1$d espectador por %2$s + Ao vivo com %1$d espectadores por %2$s Ao vivo com %1$d espectadores por %2$s %d mês + %d meses %d meses Licenças de código aberto Ao vivo com %1$d espectador em %2$s por %3$s + Ao vivo com %1$d espectadores em %2$s por %3$s Ao vivo com %1$d espectadores em %2$s por %3$s Mostrar categoria da transmissão Também exibir categoria da transmissão Alternar entrada + Alternar barra do aplicativo + Erro: %s + Sair? + Remover este canal? + Remover o canal \"%1$s\"? + Bloquear o canal \"%1$s\"? + Banir este usuário? + Excluir esta mensagem? + Limpar chat? + Ações personalizáveis para acesso rápido a pesquisa, transmissões e mais + Toque aqui para mais ações e para configurar sua barra de ações + Você pode personalizar quais ações aparecem na sua barra de ações aqui + Deslize para baixo na entrada para ocultá-la rapidamente + Toque aqui para recuperar a entrada + Próximo + Entendi + Pular tour + Você pode adicionar mais canais aqui + + + Mensagem retida pelo motivo: %1$s. Permitir irá publicá-la no chat. + Permitir + Negar + Aprovado + Negado + Expirado + Ei! Sua mensagem está sendo verificada pelos mods e ainda não foi enviada. + Os mods aceitaram sua mensagem. + Os mods negaram sua mensagem. + %1$s (nível %2$d) + + corresponde a %1$d termo bloqueado %2$s + corresponde a %1$d termos bloqueados %2$s + corresponde a %1$d termos bloqueados %2$s + + Falha ao %1$s mensagem do AutoMod - a mensagem já foi processada. + Falha ao %1$s mensagem do AutoMod - você precisa se autenticar novamente. + Falha ao %1$s mensagem do AutoMod - você não tem permissão para realizar essa ação. + Falha ao %1$s mensagem do AutoMod - mensagem alvo não encontrada. + Falha ao %1$s mensagem do AutoMod - ocorreu um erro desconhecido. + %1$s adicionou %2$s como termo bloqueado no AutoMod. + %1$s adicionou %2$s como termo permitido no AutoMod. + %1$s removeu %2$s como termo bloqueado do AutoMod. + %1$s removeu %2$s como termo permitido do AutoMod. + + + Você foi suspenso por %1$s + Você foi suspenso por %1$s por %2$s + Você foi suspenso por %1$s por %2$s: %3$s + %1$s suspendeu %2$s por %3$s + %1$s suspendeu %2$s por %3$s: %4$s + %1$s foi suspenso por %2$s + Você foi banido + Você foi banido por %1$s + Você foi banido por %1$s: %2$s + %1$s baniu %2$s + %1$s baniu %2$s: %3$s + %1$s foi banido permanentemente + %1$s removeu a suspensão de %2$s + %1$s desbaniu %2$s + %1$s promoveu %2$s a moderador + %1$s removeu %2$s de moderador + %1$s adicionou %2$s como VIP deste canal + %1$s removeu %2$s como VIP deste canal + %1$s avisou %2$s + %1$s avisou %2$s: %3$s + %1$s iniciou uma raid para %2$s + %1$s cancelou a raid para %2$s + %1$s excluiu a mensagem de %2$s + %1$s excluiu a mensagem de %2$s dizendo: %3$s + Uma mensagem de %1$s foi excluída + Uma mensagem de %1$s foi excluída dizendo: %2$s + %1$s limpou o chat + O chat foi limpo por um moderador + %1$s ativou o modo somente emotes + %1$s desativou o modo somente emotes + %1$s ativou o modo somente seguidores + %1$s ativou o modo somente seguidores (%2$s) + %1$s desativou o modo somente seguidores + %1$s ativou o modo de chat único + %1$s desativou o modo de chat único + %1$s ativou o modo lento + %1$s ativou o modo lento (%2$s) + %1$s desativou o modo lento + %1$s ativou o modo somente inscritos + %1$s desativou o modo somente inscritos + %1$s suspendeu %2$s por %3$s em %4$s + %1$s suspendeu %2$s por %3$s em %4$s: %5$s + %1$s removeu a suspensão de %2$s em %3$s + %1$s baniu %2$s em %3$s + %1$s baniu %2$s em %3$s: %4$s + %1$s desbaniu %2$s em %3$s + %1$s excluiu a mensagem de %2$s em %3$s + %1$s excluiu a mensagem de %2$s em %3$s dizendo: %4$s + %1$s%2$s + + \u0020(%1$d vez) + \u0020(%1$d vezes) + \u0020(%1$d vezes) + + + + Apagar + Enviar um sussurro + Sussurrando para @%1$s + Novo sussurro + Enviar sussurro para + Nome de usuário + Enviar + + + Apenas emotes + Apenas assinantes + Modo lento + Modo lento (%1$s) + Chat único (R9K) + Apenas seguidores + Apenas seguidores (%1$s) + Personalizado + Qualquer + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Ativar o Modo Escudo? + Isso aplicará as configurações de segurança pré-configuradas do canal, que podem incluir restrições de chat, configurações do AutoMod e requisitos de verificação. + Ativar Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Adicione um canal para começar a conversar + Nenhum emote recente + + + Mostrar stream + Ocultar stream + Apenas áudio + Sair do modo áudio + Tela cheia + Sair da tela cheia + Ocultar entrada + Mostrar entrada + Navegação de canais por deslize + Trocar de canal deslizando no chat + Moderação do canal + + + Pesquisar mensagens + Última mensagem + Alternar stream + Moderação do canal + Tela cheia + Ocultar entrada + Configurar ações + Depuração + + Máximo de %1$d ação + Máximo de %1$d ações + Máximo de %1$d ações + + + + DankChat + Vamos configurar tudo. + Entrar com Twitch + Entre para enviar mensagens, usar seus emotes, receber sussurros e desbloquear todas as funcionalidades. + Você será solicitado a conceder várias permissões do Twitch de uma só vez para que não precise autorizar novamente ao usar diferentes funcionalidades. O DankChat só executa ações de moderação e transmissão quando você solicita. + Entrar com Twitch + Login realizado com sucesso + Notificações + O DankChat pode notificá-lo quando alguém mencionar você no chat enquanto o app está em segundo plano. + Permitir notificações + Abrir configurações de notificações + Sem notificações, você não saberá quando alguém mencionar você no chat enquanto o app está em segundo plano. + Histórico de mensagens + O DankChat carrega mensagens históricas de um serviço externo ao iniciar. Para obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço. O serviço armazena temporariamente as mensagens dos canais visitados.\n\nVocê pode alterar isso mais tarde nas configurações ou saber mais em https://recent-messages.robotty.de/ + Ativar + Desativar + Continuar + Começar + Pular + + + Geral + Autenticação + Ativar Twitch EventSub + Usa EventSub para diversos eventos em tempo real em vez do PubSub descontinuado + Ativar saída de depuração do EventSub + Exibe saída de depuração relacionada ao EventSub como mensagens do sistema + Revogar token e reiniciar + Invalida o token atual e reinicia o aplicativo + Não conectado + Não foi possível resolver o ID do canal para %1$s + A mensagem não foi enviada + Mensagem descartada: %1$s (%2$s) + Permissão user:write:chat ausente, faça login novamente + Não autorizado a enviar mensagens neste canal + A mensagem é grande demais + Limite de taxa atingido, tente novamente em instantes + Falha no envio: %1$s + + + Você precisa estar conectado para usar o comando %1$s + Nenhum usuário correspondente a esse nome. + Ocorreu um erro desconhecido. + Você não tem permissão para realizar essa ação. + Permissão necessária ausente. Faça login novamente com sua conta e tente outra vez. + Credenciais de login ausentes. Faça login novamente com sua conta e tente outra vez. + Uso: /block <usuário> + Você bloqueou o usuário %1$s com sucesso + Não foi possível bloquear o usuário %1$s, nenhum usuário encontrado com esse nome! + Não foi possível bloquear o usuário %1$s, ocorreu um erro desconhecido! + Uso: /unblock <usuário> + Você desbloqueou o usuário %1$s com sucesso + Não foi possível desbloquear o usuário %1$s, nenhum usuário encontrado com esse nome! + Não foi possível desbloquear o usuário %1$s, ocorreu um erro desconhecido! + O canal não está ao vivo. + Tempo no ar: %1$s + Comandos disponíveis para você nesta sala: %1$s + Uso: %1$s <nome de usuário> <mensagem>. + Sussurro enviado. + Falha ao enviar sussurro - %1$s + Uso: %1$s <mensagem> - Chame a atenção para sua mensagem com um destaque. + Falha ao enviar anúncio - %1$s + Este canal não tem moderadores. + Os moderadores deste canal são %1$s. + Falha ao listar moderadores - %1$s + Uso: %1$s <nome de usuário> - Concede o status de moderador a um usuário. + Você adicionou %1$s como moderador deste canal. + Falha ao adicionar moderador do canal - %1$s + Uso: %1$s <nome de usuário> - Revoga o status de moderador de um usuário. + Você removeu %1$s como moderador deste canal. + Falha ao remover moderador do canal - %1$s + Este canal não tem VIPs. + Os VIPs deste canal são %1$s. + Falha ao listar VIPs - %1$s + Uso: %1$s <nome de usuário> - Concede o status de VIP a um usuário. + Você adicionou %1$s como VIP deste canal. + Falha ao adicionar VIP - %1$s + Uso: %1$s <nome de usuário> - Revoga o status de VIP de um usuário. + Você removeu %1$s como VIP deste canal. + Falha ao remover VIP - %1$s + Uso: %1$s <nome de usuário> [motivo] - Impede permanentemente um usuário de conversar. O motivo é opcional e será exibido ao usuário alvo e a outros moderadores. Use /unban para remover um banimento. + Falha ao banir o usuário - Você não pode banir a si mesmo. + Falha ao banir o usuário - Você não pode banir o broadcaster. + Falha ao banir o usuário - %1$s + Uso: %1$s <nome de usuário> - Remove o banimento de um usuário. + Falha ao desbanir o usuário - %1$s + Uso: %1$s <nome de usuário> [duração][unidade de tempo] [motivo] - Impede temporariamente um usuário de conversar. A duração (opcional, padrão: 10 minutos) deve ser um inteiro positivo; a unidade de tempo (opcional, padrão: s) deve ser s, m, h, d ou w; a duração máxima é de 2 semanas. O motivo é opcional e será exibido ao usuário alvo e a outros moderadores. + Falha ao banir o usuário - Você não pode aplicar timeout em si mesmo. + Falha ao banir o usuário - Você não pode aplicar timeout no broadcaster. + Falha ao aplicar timeout no usuário - %1$s + Falha ao excluir mensagens do chat - %1$s + Uso: /delete <msg-id> - Exclui a mensagem especificada. + msg-id inválido: \"%1$s\". + Falha ao excluir mensagens do chat - %1$s + Uso: /color <cor> - A cor deve ser uma das cores suportadas pelo Twitch (%1$s) ou um hex code (#000000) se você tiver Turbo ou Prime. + Sua cor foi alterada para %1$s + Falha ao alterar a cor para %1$s - %2$s + Marcador de stream adicionado com sucesso em %1$s%2$s. + Falha ao criar marcador de stream - %1$s + Uso: /commercial <duração> - Inicia um comercial com a duração especificada para o canal atual. As durações válidas são 30, 60, 90, 120, 150 e 180 segundos. + + Iniciando intervalo comercial de %1$d segundos. Lembre-se de que você ainda está ao vivo e nem todos os espectadores receberão o comercial. Você pode iniciar outro comercial em %2$d segundos. + Iniciando intervalo comercial de %1$d segundos. Lembre-se de que você ainda está ao vivo e nem todos os espectadores receberão o comercial. Você pode iniciar outro comercial em %2$d segundos. + Iniciando intervalo comercial de %1$d segundos. Lembre-se de que você ainda está ao vivo e nem todos os espectadores receberão o comercial. Você pode iniciar outro comercial em %2$d segundos. + + Falha ao iniciar o comercial - %1$s + Uso: /raid <nome de usuário> - Faz um raid em um usuário. Somente o broadcaster pode iniciar um raid. + Nome de usuário inválido: %1$s + Você começou um raid em %1$s. + Falha ao iniciar um raid - %1$s + Você cancelou o raid. + Falha ao cancelar o raid - %1$s + Uso: %1$s [duração] - Ativa o modo somente seguidores (apenas seguidores podem conversar). A duração (opcional, padrão: 0 minutos) deve ser um número positivo seguido de uma unidade de tempo (m, h, d, w); a duração máxima é de 3 meses. + Esta sala já está no modo somente seguidores de %1$s. + Falha ao atualizar as configurações do chat - %1$s + Esta sala não está no modo somente seguidores. + Esta sala já está no modo somente emotes. + Esta sala não está no modo somente emotes. + Esta sala já está no modo somente assinantes. + Esta sala não está no modo somente assinantes. + Esta sala já está no modo de chat único. + Esta sala não está no modo de chat único. + Uso: %1$s [duração] - Ativa o modo lento (limita a frequência com que os usuários podem enviar mensagens). A duração (opcional, padrão: 30) deve ser um número positivo de segundos; máximo 120. + Esta sala já está no modo lento de %1$d segundos. + Esta sala não está no modo lento. + Uso: %1$s <nome de usuário> - Envia um shoutout para o usuário Twitch especificado. + Shoutout enviado para %1$s + Falha ao enviar shoutout - %1$s + O modo escudo foi ativado. + O modo escudo foi desativado. + Falha ao atualizar o modo escudo - %1$s + Você não pode sussurrar para si mesmo. + Devido a restrições do Twitch, agora é necessário ter um número de telefone verificado para enviar sussurros. Você pode adicionar um número de telefone nas configurações do Twitch. https://www.twitch.tv/settings/security + O destinatário não permite sussurros de desconhecidos ou de você diretamente. + Você está sendo limitado pelo Twitch. Tente novamente em alguns segundos. + Você pode sussurrar para no máximo 40 destinatários únicos por dia. Dentro do limite diário, você pode enviar no máximo 3 sussurros por segundo e no máximo 100 sussurros por minuto. + Devido a restrições do Twitch, este comando só pode ser usado pelo broadcaster. Use o site do Twitch. + %1$s já é moderador deste canal. + %1$s é atualmente um VIP, use /unvip e tente este comando novamente. + %1$s não é moderador deste canal. + %1$s não está banido deste canal. + %1$s já está banido neste canal. + Você não pode %1$s %2$s. + Houve uma operação de banimento conflitante neste usuário. Tente novamente. + A cor deve ser uma das cores suportadas pelo Twitch (%1$s) ou um hex code (#000000) se você tiver Turbo ou Prime. + Você precisa estar ao vivo para executar comerciais. + Você precisa esperar o período de espera expirar antes de executar outro comercial. + O comando deve incluir uma duração de intervalo comercial desejada maior que zero. + Você não tem um raid ativo. + Um canal não pode fazer raid em si mesmo. + O broadcaster não pode dar um Shoutout a si mesmo. + O broadcaster não está ao vivo ou não tem um ou mais espectadores. + A duração está fora do intervalo válido: %1$s. + A mensagem já foi processada. + A mensagem alvo não foi encontrada. + Sua mensagem era muito longa. + Você está sendo limitado. Tente novamente em instantes. + O usuário alvo + Visualizador de logs + Ver logs da aplicação + Logs + Compartilhar logs + Ver logs + Nenhum arquivo de log disponível + Pesquisar nos logs + + %1$d selecionados + %1$d selecionados + %1$d selecionados + + Copiar logs selecionados + Limpar seleção + Crash detectado + O aplicativo travou durante sua última sessão. + Thread: %1$s + Copiar + Relatório por chat + Entra em #flex3rs e prepara um resumo do crash para enviar + Relatório por e-mail + Enviar um relatório detalhado do crash por e-mail + Enviar relatório do crash por e-mail + Os seguintes dados serão incluídos no relatório: + Rastreamento de pilha + Incluir arquivo de log atual + Relatórios de crash + Ver relatórios de crash recentes + Nenhum relatório de crash encontrado + Relatório de crash + Compartilhar relatório de crash + Excluir + Excluir este relatório de crash? + Limpar tudo + Excluir todos os relatórios de crash? + Rolar para baixo + Distintivo + Admin + Transmissor + Fundador + Moderador-chefe + Moderador + Staff + Assinante + Verificado + VIP + Distintivos + Crie notificações e destaque mensagens de usuários com base em distintivos. + Escolher cor + Escolher cor de destaque personalizada + Padrão + Ver histórico + Nenhuma mensagem corresponde aos filtros atuais diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 5a641b632..d65de039c 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -3,7 +3,7 @@ DankChat DankChat (Dank) Envia uma mensagem - Inicio de sessão + Início de sessão Recarregar emotes Reconectar Limpar o chat @@ -48,20 +48,71 @@ Desconectado Sem sessão iniciada Responde - Tens novas menções + Send announcement Tens novas menções %1$s mencionou-te em #%2$s Foste mencionado em #%1$s A iniciar sessão como %1$s Falha ao iniciar sessão Copiado: %1$s + Upload concluído: %1$s Erro durante o envio Erro durante o envio: %1$s + Carregar + Copiado para a área de transferência + Copiar URL Tentaa novamente Emotes recarregados Falha ao carregar dados: %1$s Falha ao carregar dados com vários erros:\n%1$s + Badges DankChat + Badges globais + Emotes FFZ globais + Emotes BTTV globais + Emotes 7TV globais + Badges do canal + Emotes FFZ + Emotes BTTV + Emotes 7TV + Emotes Twitch + Cheermotes + Mensagens recentes + %1$s (%2$s) + + Primeira mensagem + Mensagem elevada + Emote gigante + Mensagem animada + Resgatado %1$s + + %1$d segundo + %1$d segundos + %1$d segundos + + + %1$d minuto + %1$d minutos + %1$d minutos + + + %1$d hora + %1$d horas + %1$d horas + + + %1$d dia + %1$d dias + %1$d dias + + + %1$d semana + %1$d semanas + %1$d semanas + + %1$s %2$s + %1$s %2$s %3$s Colar Nome do canal + O canal já foi adicionado Recente Subscritores Canal @@ -152,6 +203,8 @@ Adicionar comando Remover comando Desencadear + Este desencadeador está reservado por um comando integrado + Este desencadeador já está a ser utilizado por outro comando Comando Comandos personalizados Reportar @@ -191,12 +244,52 @@ Geral Sobre Aparência + Sugestões + Mensagens + Utilizadores + Emotes & Insígnias DankChat %1$s feito por @flex3rs e contribuidores Mostrar entrada Exibe o campo de entrada para enviar mensagens Seguir padrão do sistema - Verdadeiro tema escuro - Força a cor de fundo do chat para preto + Modo escuro AMOLED + Fundos pretos puros para ecrãs OLED + Cor de destaque + Seguir papel de parede do sistema + Azul + Azul-petróleo + Verde + Lima + Amarelo + Laranja + Vermelho + Rosa + Roxo + Índigo + Castanho + Cinzento + Estilo de cor + Predefinição do sistema + Usar a paleta de cores predefinida do sistema + Tonal Spot + Cores calmas e suaves + Neutral + Quase monocromático, tom subtil + Vibrant + Cores vivas e saturadas + Expressive + Cores lúdicas com tons deslocados + Rainbow + Amplo espectro de tons + Fruit Salad + Paleta lúdica e multicolor + Monochrome + Apenas preto, branco e cinzento + Fidelity + Fiel à cor de destaque + Content + Cor de destaque com terciário análogo + Mais estilos Ecrã Componentes Mostrar mensagens apagadas por suspensão temporária @@ -208,8 +301,19 @@ Pequeno Largo Muito grande - Sugestões de emotes e utilizadores - Mostra sugestões para emotes e utilizadores ativos durante a digitação + Sugestões + Escolha quais sugestões mostrar ao escrever + Emotes + Utilizadores + Comandos do Twitch + Comandos do Supibot + Ativar com : + Ativar com @ + Ativar com / + Ativar com $ + Modo de sugestao + Sugerir correspondências ao escrever + Sugerir apenas após um caractere de ativação Carregar histórico de mensagens ao conectar Carregar histórico das mensagens ao reconectar Tentativas de buscar mensagens perdidas que não foram recebidas durante quedas de conexão @@ -219,7 +323,7 @@ Dados do canal Opções de programador Modo de depuração - Fornece informação para quaisquer exceções que tenham sido capturadas + Mostrar ação de análise de depuração na barra de introdução e recolher relatórios de crash localmente Formato do carimbo da hora Habilitar TTS Lê as mensagens do canal ativo @@ -233,6 +337,9 @@ Ignorar URLs Ignora emotes e emojis no TTS Ignorar emotes + Volume + Redução de áudio + Reduzir o volume de outros áudios enquanto o TTS fala TTS Fundo alternado Separar cada linha com um brilho de fundo diferente @@ -245,12 +352,19 @@ Comportamento de clique longo do utilizador Clique regular abre janela, clique longo abre menções Clique regular abre menções, clique longo abre janela + Colorir nomes de utilizador + Atribuir uma cor aleatória a utilizadores sem cor definida Forçar a língua para Inglês Forçar idioma da voz de TTS para inglês em vez do padrão do sistema Emotes de terceiros visíveis Termos de serviço da Twitch & política do utilizador: Mostrar botão de ações Exibe um botão para alternar em tela cheia, transmissões e ajustar os modos do chat + Mostrar contador de caracteres + Exibe a contagem de pontos de código no campo de introdução + Mostrar botão de limpar introdução + Mostrar botão de enviar + Introdução Carregador de mídia Configurar carregador Carregamentos recentes @@ -276,9 +390,12 @@ Permite a prevenção experimental de recargas da stream após mudanças da orientação ou reabertura do DankChat. Mostrar lista de alterações após uma actualização O que há de novo - Inicio de sessão customizado + Início de sessão customizado Ignorar manipulação de comandos da Twitch Desativa a interceptação de comandos da Twitch e envia-os para o chat + Protocolo de envio do chat + Usar Helix API para enviar + Enviar mensagens do chat via Twitch Helix API em vez de IRC 7TV atualização de emotes ao vivo Comportamento em segundo plano de atualização ao vivo de emotes Atualizações param após %1$s.\nDiminuir este número pode aumentar a duração da bateria. @@ -323,6 +440,7 @@ O teu nome de utilizador Subscrições e Eventos Anúncios + Sequências de visualização Primeira mensagem Mensagens elevadas Destaques resgatados com pontos de canal @@ -348,9 +466,22 @@ Copiar mensagem Copiar a mensagem completa Responde à mensagem + Responder à mensagem original Vê a thread Copia o ID da mensagem Mais… + Ir para a mensagem + Mensagem já não está no histórico do chat + Histórico de mensagens + Histórico global + Histórico: %1$s + Pesquisar mensagens… + Filtrar por nome de utilizador + Mensagens com ligações + Mensagens com emotes + Filtrar por nome de distintivo + Utilizador + Distintivo Responder a @%1$s Thread de resposta não encontrado Mensagem não encontrada @@ -372,18 +503,418 @@ Emote copiado DankChat foi atualizado! O que há de novo em v%1$s - Confirmação do cancelamento do inicio de sessão - Tens a certeza de que desejas cancelar o processo do inicio de sessão - Cancelar inicio de sessão + Confirmação do cancelamento do início de sessão + Tens a certeza de que desejas cancelar o processo do início de sessão + Cancelar início de sessão Reduzir o zoom Aumentar o zoom Voltar Ao vivo com %1$d espectador à %2$s + Ao vivo com %1$d espectadores à %2$s Ao vivo com %1$d espectadores à %2$s %d mês + %d meses %d meses + Alternar barra da aplicação + Erro: %s + Terminar sessão? + Remover este canal? + Remover o canal \"%1$s\"? + Bloquear o canal \"%1$s\"? + Banir este utilizador? + Eliminar esta mensagem? + Limpar chat? + Ações personalizáveis para acesso rápido a pesquisa, transmissões e mais + Toque aqui para mais ações e para configurar a sua barra de ações + Pode personalizar quais ações aparecem na sua barra de ações aqui + Deslize para baixo na entrada para a ocultar rapidamente + Toque aqui para recuperar a entrada + Seguinte + Entendido + Saltar tour + Pode adicionar mais canais aqui + + + Mensagem retida pelo motivo: %1$s. Permitir irá publicá-la no chat. + Permitir + Negar + Aprovado + Negado + Expirado + Ei! A tua mensagem está a ser verificada pelos mods e ainda não foi enviada. + Os mods aceitaram a tua mensagem. + Os mods recusaram a tua mensagem. + %1$s (nível %2$d) + + corresponde a %1$d termo bloqueado %2$s + corresponde a %1$d termos bloqueados %2$s + corresponde a %1$d termos bloqueados %2$s + + Falha ao %1$s mensagem do AutoMod - a mensagem já foi processada. + Falha ao %1$s mensagem do AutoMod - precisa de se autenticar novamente. + Falha ao %1$s mensagem do AutoMod - não tem permissão para realizar essa ação. + Falha ao %1$s mensagem do AutoMod - mensagem alvo não encontrada. + Falha ao %1$s mensagem do AutoMod - ocorreu um erro desconhecido. + %1$s adicionou %2$s como termo bloqueado no AutoMod. + %1$s adicionou %2$s como termo permitido no AutoMod. + %1$s removeu %2$s como termo bloqueado do AutoMod. + %1$s removeu %2$s como termo permitido do AutoMod. + + + Foste suspenso por %1$s + Foste suspenso por %1$s por %2$s + Foste suspenso por %1$s por %2$s: %3$s + %1$s suspendeu %2$s por %3$s + %1$s suspendeu %2$s por %3$s: %4$s + %1$s foi suspenso por %2$s + Foste banido + Foste banido por %1$s + Foste banido por %1$s: %2$s + %1$s baniu %2$s + %1$s baniu %2$s: %3$s + %1$s foi banido permanentemente + %1$s removeu a suspensão de %2$s + %1$s desbaniu %2$s + %1$s promoveu %2$s a moderador + %1$s removeu %2$s de moderador + %1$s adicionou %2$s como VIP deste canal + %1$s removeu %2$s como VIP deste canal + %1$s avisou %2$s + %1$s avisou %2$s: %3$s + %1$s iniciou uma raid para %2$s + %1$s cancelou a raid para %2$s + %1$s eliminou a mensagem de %2$s + %1$s eliminou a mensagem de %2$s a dizer: %3$s + Uma mensagem de %1$s foi eliminada + Uma mensagem de %1$s foi eliminada a dizer: %2$s + %1$s limpou o chat + O chat foi limpo por um moderador + %1$s ativou o modo apenas emotes + %1$s desativou o modo apenas emotes + %1$s ativou o modo apenas seguidores + %1$s ativou o modo apenas seguidores (%2$s) + %1$s desativou o modo apenas seguidores + %1$s ativou o modo de chat único + %1$s desativou o modo de chat único + %1$s ativou o modo lento + %1$s ativou o modo lento (%2$s) + %1$s desativou o modo lento + %1$s ativou o modo apenas subscritores + %1$s desativou o modo apenas subscritores + %1$s suspendeu %2$s por %3$s em %4$s + %1$s suspendeu %2$s por %3$s em %4$s: %5$s + %1$s removeu a suspensão de %2$s em %3$s + %1$s baniu %2$s em %3$s + %1$s baniu %2$s em %3$s: %4$s + %1$s desbaniu %2$s em %3$s + %1$s eliminou a mensagem de %2$s em %3$s + %1$s eliminou a mensagem de %2$s em %3$s a dizer: %4$s + %1$s%2$s + + \u0020(%1$d vez) + \u0020(%1$d vezes) + \u0020(%1$d vezes) + + + + Apagar + Enviar um sussurro + Sussurrar a @%1$s + Novo sussurro + Enviar sussurro a + Nome de utilizador + Enviar + + + Apenas emotes + Apenas subscritores + Modo lento + Modo lento (%1$s) + Chat único (R9K) + Apenas seguidores + Apenas seguidores (%1$s) + Personalizado + Qualquer + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Ativar o Modo Escudo? + Isto aplicará as configurações de segurança pré-configuradas do canal, que podem incluir restrições de chat, configurações do AutoMod e requisitos de verificação. + Ativar Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Adicione um canal para começar a conversar + Nenhum emote recente + + + Mostrar transmissão + Ocultar transmissão + Apenas áudio + Sair do modo áudio + Ecrã inteiro + Sair do ecrã inteiro + Ocultar entrada + Mostrar entrada + Navegação de canais por deslize + Trocar de canal deslizando no chat + Moderação do canal + + + Pesquisar mensagens + Última mensagem + Alternar transmissão + Moderação do canal + Ecrã inteiro + Ocultar entrada + Configurar ações + Depuração + + Máximo de %1$d ação + Máximo de %1$d ações + Máximo de %1$d ações + + + + DankChat + Vamos configurar tudo. + Iniciar sessão com Twitch + Inicie sessão para enviar mensagens, usar os seus emotes, receber sussurros e desbloquear todas as funcionalidades. + Ser-lhe-á pedido que conceda várias permissões do Twitch de uma só vez para que não precise de reautorizar ao utilizar diferentes funcionalidades. O DankChat só executa ações de moderação e transmissão quando o solicita. + Iniciar sessão com Twitch + Sessão iniciada com sucesso + Notificações + O DankChat pode notificá-lo quando alguém o mencionar no chat enquanto a app está em segundo plano. + Permitir notificações + Abrir definições de notificações + Sem notificações, não saberá quando alguém o mencionar no chat enquanto a app está em segundo plano. + Histórico de mensagens + O DankChat carrega mensagens históricas de um serviço externo ao iniciar. Para obter as mensagens, o DankChat envia os nomes dos canais abertos para esse serviço. O serviço armazena temporariamente as mensagens dos canais visitados.\n\nPode alterar isto mais tarde nas definições ou saber mais em https://recent-messages.robotty.de/ + Ativar + Desativar + Continuar + Começar + Saltar + + + Geral + Autenticação + Ativar Twitch EventSub + Usa EventSub para diversos eventos em tempo real em vez do PubSub descontinuado + Ativar saída de depuração do EventSub + Apresenta saída de depuração relacionada com o EventSub como mensagens do sistema + Revogar token e reiniciar + Invalida o token atual e reinicia a aplicação + Sessão não iniciada + Não foi possível resolver o ID do canal para %1$s + A mensagem não foi enviada + Mensagem descartada: %1$s (%2$s) + Permissão user:write:chat em falta, inicie sessão novamente + Sem autorização para enviar mensagens neste canal + A mensagem é demasiado grande + Limite de taxa atingido, tente novamente dentro de momentos + Falha no envio: %1$s + + + Tens de iniciar sessão para utilizar o comando %1$s + Nenhum utilizador correspondente a esse nome. + Ocorreu um erro desconhecido. + Não tens permissão para realizar essa ação. + Permissão necessária em falta. Inicia sessão novamente com a tua conta e tenta outra vez. + Credenciais de início de sessão em falta. Inicia sessão novamente com a tua conta e tenta outra vez. + Utilização: /block <utilizador> + Bloqueaste o utilizador %1$s com sucesso + Não foi possível bloquear o utilizador %1$s, nenhum utilizador encontrado com esse nome! + Não foi possível bloquear o utilizador %1$s, ocorreu um erro desconhecido! + Utilização: /unblock <utilizador> + Desbloqueaste o utilizador %1$s com sucesso + Não foi possível desbloquear o utilizador %1$s, nenhum utilizador encontrado com esse nome! + Não foi possível desbloquear o utilizador %1$s, ocorreu um erro desconhecido! + O canal não está em direto. + Tempo no ar: %1$s + Comandos disponíveis para ti nesta sala: %1$s + Utilização: %1$s <nome de utilizador> <mensagem>. + Sussurro enviado. + Falha ao enviar sussurro - %1$s + Utilização: %1$s <mensagem> - Chama a atenção para a tua mensagem com um destaque. + Falha ao enviar anúncio - %1$s + Este canal não tem moderadores. + Os moderadores deste canal são %1$s. + Falha ao listar moderadores - %1$s + Utilização: %1$s <nome de utilizador> - Concede o estatuto de moderador a um utilizador. + Adicionaste %1$s como moderador deste canal. + Falha ao adicionar moderador do canal - %1$s + Utilização: %1$s <nome de utilizador> - Revoga o estatuto de moderador de um utilizador. + Removeste %1$s como moderador deste canal. + Falha ao remover moderador do canal - %1$s + Este canal não tem VIPs. + Os VIPs deste canal são %1$s. + Falha ao listar VIPs - %1$s + Utilização: %1$s <nome de utilizador> - Concede o estatuto de VIP a um utilizador. + Adicionaste %1$s como VIP deste canal. + Falha ao adicionar VIP - %1$s + Utilização: %1$s <nome de utilizador> - Revoga o estatuto de VIP de um utilizador. + Removeste %1$s como VIP deste canal. + Falha ao remover VIP - %1$s + Utilização: %1$s <nome de utilizador> [motivo] - Impede permanentemente um utilizador de conversar. O motivo é opcional e será mostrado ao utilizador alvo e a outros moderadores. Usa /unban para remover um banimento. + Falha ao banir o utilizador - Não podes banir-te a ti próprio. + Falha ao banir o utilizador - Não podes banir o broadcaster. + Falha ao banir o utilizador - %1$s + Utilização: %1$s <nome de utilizador> - Remove o banimento de um utilizador. + Falha ao desbanir o utilizador - %1$s + Utilização: %1$s <nome de utilizador> [duração][unidade de tempo] [motivo] - Impede temporariamente um utilizador de conversar. A duração (opcional, predefinição: 10 minutos) deve ser um inteiro positivo; a unidade de tempo (opcional, predefinição: s) deve ser s, m, h, d ou w; a duração máxima é de 2 semanas. O motivo é opcional e será mostrado ao utilizador alvo e a outros moderadores. + Falha ao banir o utilizador - Não podes aplicar timeout a ti próprio. + Falha ao banir o utilizador - Não podes aplicar timeout ao broadcaster. + Falha ao aplicar timeout ao utilizador - %1$s + Falha ao eliminar mensagens do chat - %1$s + Utilização: /delete <msg-id> - Elimina a mensagem especificada. + msg-id inválido: \"%1$s\". + Falha ao eliminar mensagens do chat - %1$s + Utilização: /color <cor> - A cor deve ser uma das cores suportadas pelo Twitch (%1$s) ou um hex code (#000000) se tiveres Turbo ou Prime. + A tua cor foi alterada para %1$s + Falha ao alterar a cor para %1$s - %2$s + Marcador de stream adicionado com sucesso em %1$s%2$s. + Falha ao criar marcador de stream - %1$s + Utilização: /commercial <duração> - Inicia um anúncio com a duração especificada para o canal atual. As durações válidas são 30, 60, 90, 120, 150 e 180 segundos. + + A iniciar intervalo publicitário de %1$d segundos. Lembra-te que ainda estás em direto e nem todos os espetadores receberão o anúncio. Podes iniciar outro anúncio dentro de %2$d segundos. + A iniciar intervalo publicitário de %1$d segundos. Lembra-te que ainda estás em direto e nem todos os espetadores receberão o anúncio. Podes iniciar outro anúncio dentro de %2$d segundos. + A iniciar intervalo publicitário de %1$d segundos. Lembra-te que ainda estás em direto e nem todos os espetadores receberão o anúncio. Podes iniciar outro anúncio dentro de %2$d segundos. + + Falha ao iniciar o anúncio - %1$s + Utilização: /raid <nome de utilizador> - Faz um raid a um utilizador. Apenas o broadcaster pode iniciar um raid. + Nome de utilizador inválido: %1$s + Começaste um raid a %1$s. + Falha ao iniciar um raid - %1$s + Cancelaste o raid. + Falha ao cancelar o raid - %1$s + Utilização: %1$s [duração] - Ativa o modo apenas seguidores (só os seguidores podem conversar). A duração (opcional, predefinição: 0 minutos) deve ser um número positivo seguido de uma unidade de tempo (m, h, d, w); a duração máxima é de 3 meses. + Esta sala já está no modo apenas seguidores de %1$s. + Falha ao atualizar as definições do chat - %1$s + Esta sala não está no modo apenas seguidores. + Esta sala já está no modo apenas emotes. + Esta sala não está no modo apenas emotes. + Esta sala já está no modo apenas subscritores. + Esta sala não está no modo apenas subscritores. + Esta sala já está no modo de chat único. + Esta sala não está no modo de chat único. + Utilização: %1$s [duração] - Ativa o modo lento (limita a frequência com que os utilizadores podem enviar mensagens). A duração (opcional, predefinição: 30) deve ser um número positivo de segundos; máximo 120. + Esta sala já está no modo lento de %1$d segundos. + Esta sala não está no modo lento. + Utilização: %1$s <nome de utilizador> - Envia um shoutout ao utilizador Twitch especificado. + Shoutout enviado para %1$s + Falha ao enviar shoutout - %1$s + O modo escudo foi ativado. + O modo escudo foi desativado. + Falha ao atualizar o modo escudo - %1$s + Não podes sussurrar para ti próprio. + Devido a restrições do Twitch, agora é necessário teres um número de telefone verificado para enviar sussurros. Podes adicionar um número de telefone nas definições do Twitch. https://www.twitch.tv/settings/security + O destinatário não permite sussurros de desconhecidos ou de ti diretamente. + Estás a ser limitado pelo Twitch. Tenta novamente dentro de alguns segundos. + Podes sussurrar para no máximo 40 destinatários únicos por dia. Dentro do limite diário, podes enviar no máximo 3 sussurros por segundo e no máximo 100 sussurros por minuto. + Devido a restrições do Twitch, este comando só pode ser utilizado pelo broadcaster. Utiliza o website do Twitch. + %1$s já é moderador deste canal. + %1$s é atualmente um VIP, usa /unvip e tenta este comando novamente. + %1$s não é moderador deste canal. + %1$s não está banido deste canal. + %1$s já está banido neste canal. + Não podes %1$s %2$s. + Houve uma operação de banimento conflituante neste utilizador. Tenta novamente. + A cor deve ser uma das cores suportadas pelo Twitch (%1$s) ou um hex code (#000000) se tiveres Turbo ou Prime. + Tens de estar em direto para executar anúncios. + Tens de esperar que o período de espera expire antes de executar outro anúncio. + O comando deve incluir uma duração de intervalo publicitário desejada superior a zero. + Não tens um raid ativo. + Um canal não pode fazer raid a si próprio. + O broadcaster não pode dar um Shoutout a si próprio. + O broadcaster não está em direto ou não tem um ou mais espetadores. + A duração está fora do intervalo válido: %1$s. + A mensagem já foi processada. + A mensagem alvo não foi encontrada. + A tua mensagem era demasiado longa. + Estás a ser limitado. Tenta novamente dentro de momentos. + O utilizador alvo + Visualizador de registos + Ver registos da aplicação + Registos + Partilhar registos + Ver registos + Nenhum ficheiro de registo disponível + Pesquisar nos registos + + %1$d selecionados + %1$d selecionados + %1$d selecionados + + Copiar registos selecionados + Limpar seleção + Crash detetado + A aplicação bloqueou durante a sua última sessão. + Thread: %1$s + Copiar + Relatório por chat + Entra em #flex3rs e prepara um resumo do crash para enviar + Relatório por e-mail + Enviar um relatório detalhado do crash por e-mail + Enviar relatório do crash por e-mail + Os seguintes dados serão incluídos no relatório: + Rastreamento de pilha + Incluir ficheiro de registo atual + Relatórios de crash + Ver relatórios de crash recentes + Nenhum relatório de crash encontrado + Relatório de crash + Partilhar relatório de crash + Eliminar + Eliminar este relatório de crash? + Limpar tudo + Eliminar todos os relatórios de crash? + Deslocar para baixo + Distintivo + Admin + Transmissor + Fundador + Moderador-chefe + Moderador + Staff + Subscritor + Verificado + VIP + Distintivos + Crie notificações e destaque mensagens de utilizadores com base em distintivos. + Escolher cor + Escolher cor de destaque personalizada + Predefinição + Licenças de código aberto + Mostrar categoria da transmissão + Mostrar também a categoria da transmissão + Chat partilhado + + Em direto com %1$d espectador em %2$s durante %3$s + Em direto com %1$d espectadores em %2$s durante %3$s + Em direto com %1$d espectadores em %2$s durante %3$s + + Ver histórico + Nenhuma mensagem corresponde aos filtros atuais diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3673afc04..62a400771 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -48,20 +48,76 @@ Отключено Вы не вошли Ответить - У вас новые упоминания + Send announcement У вас новые упоминания %1$s упомянул вас в #%2$s Вас упомянули в #%1$s Вы вошли как %1$s Ошибка входа Скопировано: %1$s + Загрузка завершена: %1$s Ошибка при загрузке Ошибка при загрузке: %1$s + Загрузить + Скопировано в буфер обмена + Копировать URL Повторить Смайлы обновлены Ошибка загрузки данных: %1$s Загрузка данных не удалась из-за нескольких ошибок:\n%1$s + Значки DankChat + Глобальные значки + Глобальные FFZ-эмоуты + Глобальные BTTV-эмоуты + Глобальные 7TV-эмоуты + Значки канала + FFZ-эмоуты + BTTV-эмоуты + 7TV-эмоуты + Twitch-эмоуты + Cheermote-ы + Последние сообщения + %1$s (%2$s) + + Первое сообщение + Выделенное сообщение + Гигантский эмоут + Анимированное сообщение + Использовано: %1$s + + %1$d секунду + %1$d секунды + %1$d секунд + %1$d секунд + + + %1$d минуту + %1$d минуты + %1$d минут + %1$d минут + + + %1$d час + %1$d часа + %1$d часов + %1$d часов + + + %1$d день + %1$d дня + %1$d дней + %1$d дней + + + %1$d неделю + %1$d недели + %1$d недель + %1$d недель + + %1$s %2$s + %1$s %2$s %3$s Вставить Имя канала + Канал уже добавлен Недавние Смайлы подписки Смайлы канала @@ -157,6 +213,8 @@ Добавить команду Удалить команду Триггер + Этот триггер зарезервирован встроенной командой + Этот триггер уже используется другой командой Команда Пользовательские команды Пожаловаться @@ -194,14 +252,54 @@ Уведомления Чат Общие + Подсказки + Сообщения + Пользователи + Эмоции и значки О программе Внешний вид DankChat %1$s создан @flex3rs и другими участниками Отображать строку ввода Отображать строку для ввода сообщений В соответствии с системой - Истинно тёмная тема - Переключать цвет заднего фона чата на чёрный + AMOLED тёмный режим + Чисто чёрный фон для OLED-экранов + Цвет акцента + Следовать обоям системы + Синий + Бирюзовый + Зелёный + Лаймовый + Жёлтый + Оранжевый + Красный + Розовый + Фиолетовый + Индиго + Коричневый + Серый + Стиль цвета + Системный по умолчанию + Использовать стандартную цветовую палитру системы + Tonal Spot + Спокойные и приглушённые цвета + Neutral + Почти монохромный, едва заметный оттенок + Vibrant + Яркие и насыщенные цвета + Expressive + Игривые цвета со сдвинутыми оттенками + Rainbow + Широкий спектр оттенков + Fruit Salad + Игривая многоцветная палитра + Monochrome + Только чёрный, белый и серый + Fidelity + Верен цвету акцента + Content + Цвет акцента с аналоговым третичным + Больше стилей Отображение Компоненты Отображать удалённые сообщения @@ -213,8 +311,19 @@ Маленький Большой Огромный - Подсказки пользователей и смайлов - Отображать подсказки для имён пользователей и смайлов при наборе сообщения + Подсказки + Выберите, какие подсказки показывать при вводе + Эмоции + Пользователи + Команды Twitch + Команды Supibot + Активировать с помощью : + Активировать с помощью @ + Активировать с помощью / + Активировать с помощью $ + Режим подсказок + Предлагать совпадения при вводе + Предлагать только после символа-триггера Загружать историю сообщений сразу Загружать историю сообщений после переподключения Попытаться получить пропущенные сообщения, которые не были получены во время разрыва соединения @@ -224,7 +333,7 @@ Данные канала Настройки разработчика Режим отладки - Отображать информацию о записанных исключениях + Показывать действие отладочной аналитики в панели ввода и собирать отчёты о сбоях локально Формат временных меток Включить синтезатор речи Зачитывает сообщения активного канала @@ -238,6 +347,9 @@ Игнорировать URL Игнорировать смайлы и эмодзи в синтезаторе речи Игнорировать смайлы + Громкость + Приглушение звука + Уменьшать громкость других приложений во время озвучки Синтезатор речи Шахматные линии Разделять каждую линию, используя цвета фона с разной яркостью @@ -250,12 +362,19 @@ Поведение при долгом нажатии на имя пользователя Обычное нажатие открывает всплывающее окно с информацией о пользователе, долгое - открывает упоминания Обычное нажатие открывает упоминания, долгое - открывает всплывающее окно с информацией о пользователе + Окрашивать никнеймы + Назначать случайный цвет пользователям без установленного цвета Переключить язык на английский Переключить язык синтезатора речи с системного на английский Видимые сторонние смайлы Условия пользования и политика пользователя Twitch: Отображать быстрые переключатели Отображать переключатели для полноэкранного режима, трансляций и настройки режимов чата + Показывать счётчик символов + Отображает количество кодовых точек в поле ввода + Показывать кнопку очистки ввода + Показывать кнопку отправки + Ввод Загрузчик медиа Настроить загрузчик Недавние загрузки @@ -284,6 +403,9 @@ Пользовательский логин Обход обработки команд Twitch Отключает перехват команд Twitch и отправляет их в чат + Протокол отправки чата + Использовать Helix API для отправки + Отправлять сообщения чата через Twitch Helix API вместо IRC Обновления 7TV эмоций в реальном времени Фоновые обновления эмоций Обновления останавливаются после %1$s.\nУменьшение этого значения может увеличить время автономной работы. @@ -328,6 +450,7 @@ Ваше имя пользователя Подписки и События Анонсы + Серии просмотров Первые сообщения Возвышенные сообщения Выделения купленные за баллы канала @@ -345,7 +468,7 @@ Пользовательское отображение пользователя Удалить пользовательское отображение пользователя Псевдоним - Пользовательский цвет + Пользовательский цвет Пользовательский псевдоним Выбрать пользовательский цвет Добавить пользовательское имя и цвет для пользователей @@ -353,9 +476,22 @@ Копировать сообщение Копировать полное сообщения Ответить на сообщение + Ответить на исходное сообщение Посмотреть ветку Копировать ID сообщения Ещё… + Перейти к сообщению + Сообщение больше не в истории чата + История сообщений + Глобальная история + История: %1$s + Поиск сообщений… + Фильтр по имени пользователя + Сообщения со ссылками + Сообщения с эмоутами + Фильтр по названию значка + Пользователь + Значок В ответ @%1$s Ветка ответов не найдена Сообщение не найдено @@ -406,4 +542,399 @@ Отображать категорию стрима Также показывать категорию стрима Переключить строку ввода + Переключить панель приложения + Ошибка: %s + Выйти? + Удалить этот канал? + Удалить канал \"%1$s\"? + Заблокировать канал \"%1$s\"? + Забанить этого пользователя? + Удалить это сообщение? + Очистить чат? + Настраиваемые действия для быстрого доступа к поиску, трансляциям и другому + Нажмите здесь для дополнительных действий и настройки панели действий + Здесь можно настроить, какие действия отображаются на панели действий + Проведите вниз по полю ввода, чтобы быстро скрыть его + Нажмите здесь, чтобы вернуть поле ввода + Далее + Понятно + Пропустить тур + Здесь можно добавить больше каналов + + + Сообщение задержано по причине: %1$s. Разрешение опубликует его в чате. + Разрешить + Отклонить + Одобрено + Отклонено + Истекло + Эй! Твоё сообщение проверяется модераторами и ещё не отправлено. + Модераторы приняли твоё сообщение. + Модераторы отклонили твоё сообщение. + %1$s (уровень %2$d) + + совпадает с %1$d заблокированным термином %2$s + совпадает с %1$d заблокированными терминами %2$s + совпадает с %1$d заблокированными терминами %2$s + совпадает с %1$d заблокированными терминами %2$s + + Не удалось %1$s сообщение AutoMod - сообщение уже обработано. + Не удалось %1$s сообщение AutoMod - необходимо повторно авторизоваться. + Не удалось %1$s сообщение AutoMod - у вас нет прав для выполнения этого действия. + Не удалось %1$s сообщение AutoMod - целевое сообщение не найдено. + Не удалось %1$s сообщение AutoMod - произошла неизвестная ошибка. + %1$s добавил %2$s как заблокированный термин в AutoMod. + %1$s добавил %2$s как разрешённый термин в AutoMod. + %1$s удалил %2$s как заблокированный термин из AutoMod. + %1$s удалил %2$s как разрешённый термин из AutoMod. + + + + Вы были заглушены на %1$s + Вы были заглушены на %1$s модератором %2$s + Вы были заглушены на %1$s модератором %2$s: %3$s + %1$s заглушил %2$s на %3$s + %1$s заглушил %2$s на %3$s: %4$s + %1$s был заглушён на %2$s + Вы были забанены + Вы были забанены модератором %1$s + Вы были забанены модератором %1$s: %2$s + %1$s забанил %2$s + %1$s забанил %2$s: %3$s + %1$s был перманентно забанен + %1$s снял заглушение с %2$s + %1$s разбанил %2$s + %1$s назначил модератором %2$s + %1$s снял модератора с %2$s + %1$s добавил %2$s как VIP этого канала + %1$s удалил %2$s как VIP этого канала + %1$s предупредил %2$s + %1$s предупредил %2$s: %3$s + %1$s начал рейд на %2$s + %1$s отменил рейд на %2$s + %1$s удалил сообщение от %2$s + %1$s удалил сообщение от %2$s с текстом: %3$s + Сообщение от %1$s было удалено + Сообщение от %1$s было удалено с текстом: %2$s + %1$s очистил чат + Чат был очищен модератором + %1$s включил режим только эмоции + %1$s выключил режим только эмоции + %1$s включил режим только для подписчиков канала + %1$s включил режим только для подписчиков канала (%2$s) + %1$s выключил режим только для подписчиков канала + %1$s включил режим уникального чата + %1$s выключил режим уникального чата + %1$s включил медленный режим + %1$s включил медленный режим (%2$s) + %1$s выключил медленный режим + %1$s включил режим только для подписчиков + %1$s выключил режим только для подписчиков + %1$s заглушил %2$s на %3$s в %4$s + %1$s заглушил %2$s на %3$s в %4$s: %5$s + %1$s снял заглушение с %2$s в %3$s + %1$s забанил %2$s в %3$s + %1$s забанил %2$s в %3$s: %4$s + %1$s разбанил %2$s в %3$s + %1$s удалил сообщение от %2$s в %3$s + %1$s удалил сообщение от %2$s в %3$s с текстом: %4$s + %1$s%2$s + + \u0020(%1$d раз) + \u0020(%1$d раза) + \u0020(%1$d раз) + \u0020(%1$d раз) + + + + Удалить + Отправить личное сообщение + Личное сообщение для @%1$s + Новое личное сообщение + Отправить личное сообщение + Имя пользователя + Отправить + + + Только эмоуты + Только подписчики + Медленный режим + Медленный режим (%1$s) + Уникальный чат (R9K) + Только фолловеры + Только фолловеры (%1$s) + Другое + Любой + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Активировать режим щита? + Это применит предварительно настроенные параметры безопасности канала, которые могут включать ограничения чата, настройки AutoMod и требования верификации. + Активировать Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Добавьте канал, чтобы начать общение + Нет недавних эмоутов + + + Показать трансляцию + Скрыть трансляцию + Только аудио + Выйти из режима аудио + На весь экран + Выйти из полноэкранного режима + Скрыть ввод + Показать ввод + Навигация каналов свайпом + Переключение каналов свайпом по чату + Модерация канала + + + Поиск сообщений + Последнее сообщение + Переключить трансляцию + Модерация канала + На весь экран + Скрыть ввод + Настроить действия + Отладка + + Максимум %1$d действие + Максимум %1$d действия + Максимум %1$d действий + Максимум %1$d действий + + + + DankChat + Давайте настроим всё. + Войти через Twitch + Войдите, чтобы отправлять сообщения, использовать свои эмоуты, получать личные сообщения и разблокировать все функции. + Вам будет предложено предоставить сразу несколько разрешений Twitch, чтобы вам не пришлось повторно авторизоваться при использовании различных функций. DankChat выполняет действия по модерации и управлению трансляцией только по вашему запросу. + Войти через Twitch + Вход выполнен + Уведомления + DankChat может уведомлять вас, когда кто-то упоминает вас в чате, пока приложение работает в фоне. + Разрешить уведомления + Открыть настройки уведомлений + Без уведомлений вы не узнаете, когда кто-то упоминает вас в чате, пока приложение работает в фоне. + История сообщений + DankChat загружает историю сообщений из стороннего сервиса при запуске. Для получения сообщений DankChat отправляет названия открытых каналов этому сервису. Сервис временно хранит сообщения посещённых каналов.\n\nВы можете изменить это позже в настройках или узнать больше на https://recent-messages.robotty.de/ + Включить + Отключить + Продолжить + Начать + Пропустить + + + Общие + Авторизация + Включить Twitch EventSub + Использует EventSub для различных событий в реальном времени вместо устаревшего PubSub + Включить отладочный вывод EventSub + Выводит отладочную информацию по EventSub в виде системных сообщений + Отозвать токен и перезапустить + Аннулирует текущий токен и перезапускает приложение + Не выполнен вход + Не удалось определить ID канала для %1$s + Сообщение не было отправлено + Сообщение отклонено: %1$s (%2$s) + Отсутствует разрешение user:write:chat, войдите заново + Нет прав для отправки сообщений в этом канале + Сообщение слишком большое + Превышен лимит запросов, попробуйте через некоторое время + Ошибка отправки: %1$s + + + Необходимо войти в аккаунт для использования команды %1$s + Пользователь с таким именем не найден. + Произошла неизвестная ошибка. + У вас нет разрешения на выполнение этого действия. + Отсутствует необходимое разрешение. Войдите заново и попробуйте снова. + Отсутствуют данные для входа. Войдите заново и попробуйте снова. + Использование: /block <пользователь> + Вы успешно заблокировали пользователя %1$s + Не удалось заблокировать пользователя %1$s, пользователь с таким именем не найден! + Не удалось заблокировать пользователя %1$s, произошла неизвестная ошибка! + Использование: /unblock <пользователь> + Вы успешно разблокировали пользователя %1$s + Не удалось разблокировать пользователя %1$s, пользователь с таким именем не найден! + Не удалось разблокировать пользователя %1$s, произошла неизвестная ошибка! + Канал не в эфире. + Время в эфире: %1$s + Доступные команды в этой комнате: %1$s + Использование: %1$s <имя пользователя> <сообщение>. + Личное сообщение отправлено. + Не удалось отправить личное сообщение - %1$s + Использование: %1$s <сообщение> - Привлеките внимание к своему сообщению с помощью выделения. + Не удалось отправить объявление - %1$s + На этом канале нет модераторов. + Модераторы этого канала: %1$s. + Не удалось получить список модераторов - %1$s + Использование: %1$s <имя пользователя> - Предоставить пользователю статус модератора. + Вы добавили %1$s в качестве модератора этого канала. + Не удалось добавить модератора канала - %1$s + Использование: %1$s <имя пользователя> - Отозвать статус модератора у пользователя. + Вы убрали %1$s из модераторов этого канала. + Не удалось убрать модератора канала - %1$s + На этом канале нет VIP. + VIP этого канала: %1$s. + Не удалось получить список VIP - %1$s + Использование: %1$s <имя пользователя> - Предоставить пользователю статус VIP. + Вы добавили %1$s в качестве VIP этого канала. + Не удалось добавить VIP - %1$s + Использование: %1$s <имя пользователя> - Отозвать статус VIP у пользователя. + Вы убрали %1$s из VIP этого канала. + Не удалось убрать VIP - %1$s + Использование: %1$s <имя пользователя> [причина] - Навсегда запретить пользователю писать в чат. Причина необязательна и будет показана целевому пользователю и другим модераторам. Используйте /unban для снятия бана. + Не удалось забанить пользователя - Вы не можете забанить самого себя. + Не удалось забанить пользователя - Вы не можете забанить стримера. + Не удалось забанить пользователя - %1$s + Использование: %1$s <имя пользователя> - Снимает бан с пользователя. + Не удалось разбанить пользователя - %1$s + Использование: %1$s <имя пользователя> [длительность][единица времени] [причина] - Временно запретить пользователю писать в чат. Длительность (необязательно, по умолчанию: 10 минут) должна быть положительным целым числом; единица времени (необязательно, по умолчанию: s) должна быть s, m, h, d или w; максимальная длительность — 2 недели. Причина необязательна и будет показана целевому пользователю и другим модераторам. + Не удалось забанить пользователя - Вы не можете дать тайм-аут самому себе. + Не удалось забанить пользователя - Вы не можете дать тайм-аут стримеру. + Не удалось дать тайм-аут пользователю - %1$s + Не удалось удалить сообщения чата - %1$s + Использование: /delete <msg-id> - Удаляет указанное сообщение. + Недопустимый msg-id: \"%1$s\". + Не удалось удалить сообщения чата - %1$s + Использование: /color <цвет> - Цвет должен быть одним из поддерживаемых Twitch цветов (%1$s) или hex code (#000000), если у вас есть Turbo или Prime. + Ваш цвет был изменён на %1$s + Не удалось изменить цвет на %1$s - %2$s + Маркер стрима успешно добавлен на %1$s%2$s. + Не удалось создать маркер стрима - %1$s + Использование: /commercial <длительность> - Запускает рекламу указанной длительности для текущего канала. Допустимые значения: 30, 60, 90, 120, 150 и 180 секунд. + + Запуск рекламной паузы длительностью %1$d секунд. Помните, что вы всё ещё в эфире и не все зрители получат рекламу. Вы сможете запустить следующую рекламу через %2$d секунд. + Запуск рекламной паузы длительностью %1$d секунд. Помните, что вы всё ещё в эфире и не все зрители получат рекламу. Вы сможете запустить следующую рекламу через %2$d секунд. + Запуск рекламной паузы длительностью %1$d секунд. Помните, что вы всё ещё в эфире и не все зрители получат рекламу. Вы сможете запустить следующую рекламу через %2$d секунд. + Запуск рекламной паузы длительностью %1$d секунд. Помните, что вы всё ещё в эфире и не все зрители получат рекламу. Вы сможете запустить следующую рекламу через %2$d секунд. + + Не удалось запустить рекламу - %1$s + Использование: /raid <имя пользователя> - Совершить рейд на пользователя. Только стример может начать рейд. + Недопустимое имя пользователя: %1$s + Вы начали рейд на %1$s. + Не удалось начать рейд - %1$s + Вы отменили рейд. + Не удалось отменить рейд - %1$s + Использование: %1$s [длительность] - Включает режим «только для подписчиков» (только подписчики могут писать в чат). Длительность (необязательно, по умолчанию: 0 минут) должна быть положительным числом с единицей времени (m, h, d, w); максимальная длительность — 3 месяца. + Эта комната уже в режиме «только для подписчиков» %1$s. + Не удалось обновить настройки чата - %1$s + Эта комната не в режиме «только для подписчиков». + Эта комната уже в режиме «только эмоуты». + Эта комната не в режиме «только эмоуты». + Эта комната уже в режиме «только для подписчиков канала». + Эта комната не в режиме «только для подписчиков канала». + Эта комната уже в режиме уникального чата. + Эта комната не в режиме уникального чата. + Использование: %1$s [длительность] - Включает медленный режим (ограничивает частоту отправки сообщений). Длительность (необязательно, по умолчанию: 30) должна быть положительным числом секунд; максимум 120. + Эта комната уже в медленном режиме (%1$d сек.). + Эта комната не в медленном режиме. + Использование: %1$s <имя пользователя> - Отправляет шаут-аут указанному пользователю Twitch. + Шаут-аут отправлен %1$s + Не удалось отправить шаут-аут - %1$s + Режим щита активирован. + Режим щита деактивирован. + Не удалось обновить режим щита - %1$s + Вы не можете отправлять личные сообщения самому себе. + Из-за ограничений Twitch теперь для отправки личных сообщений требуется подтверждённый номер телефона. Вы можете добавить номер телефона в настройках Twitch. https://www.twitch.tv/settings/security + Получатель не принимает личные сообщения от незнакомцев или от вас напрямую. + Twitch ограничил частоту ваших запросов. Попробуйте через несколько секунд. + Вы можете отправлять личные сообщения максимум 40 уникальным получателям в день. В рамках дневного лимита вы можете отправлять максимум 3 личных сообщения в секунду и максимум 100 личных сообщений в минуту. + Из-за ограничений Twitch эта команда доступна только стримеру. Пожалуйста, используйте веб-сайт Twitch. + %1$s уже является модератором этого канала. + %1$s сейчас VIP, используйте /unvip и повторите эту команду. + %1$s не является модератором этого канала. + %1$s не забанен на этом канале. + %1$s уже забанен на этом канале. + Вы не можете %1$s %2$s. + Произошла конфликтующая операция бана для этого пользователя. Пожалуйста, попробуйте снова. + Цвет должен быть одним из поддерживаемых Twitch цветов (%1$s) или hex code (#000000), если у вас есть Turbo или Prime. + Вы должны вести прямую трансляцию для запуска рекламы. + Необходимо дождаться окончания периода ожидания, прежде чем запускать следующую рекламу. + Команда должна включать желаемую длительность рекламной паузы больше нуля. + У вас нет активного рейда. + Канал не может совершить рейд на самого себя. + Стример не может дать Shoutout самому себе. + Стример не ведёт трансляцию или не имеет одного или более зрителей. + Длительность вне допустимого диапазона: %1$s. + Сообщение уже было обработано. + Целевое сообщение не найдено. + Ваше сообщение было слишком длинным. + Частота ваших запросов ограничена. Попробуйте через мгновение. + Целевой пользователь + Просмотр журналов + Просмотр журналов приложения + Журналы + Поделиться журналами + Просмотреть журналы + Файлы журналов отсутствуют + Поиск по журналам + + Выбрано: %1$d + Выбрано: %1$d + Выбрано: %1$d + Выбрано: %1$d + + Копировать выбранные журналы + Снять выделение + Обнаружен сбой + Приложение аварийно завершилось во время последнего сеанса. + Поток: %1$s + Копировать + Отчёт в чат + Присоединяется к #flex3rs и готовит сводку сбоя для отправки + Отчёт по эл. почте + Отправить подробный отчёт о сбое по электронной почте + Отправить отчёт о сбое по электронной почте + Следующие данные будут включены в отчёт: + Стек вызовов + Включить текущий файл журнала + Отчёты о сбоях + Просмотр последних отчётов о сбоях + Отчёты о сбоях не найдены + Отчёт о сбое + Поделиться отчётом о сбое + Удалить + Удалить этот отчёт о сбое? + Очистить всё + Удалить все отчёты о сбоях? + Прокрутить вниз + Значок + Администратор + Стример + Основатель + Главный модератор + Модератор + Сотрудник + Подписчик + Подтверждённый + VIP + Значки + Создавайте уведомления и выделяйте сообщения пользователей на основе значков. + Выбрать цвет + Выбрать пользовательский цвет выделения + По умолчанию + Просмотреть историю + Нет сообщений, соответствующих текущим фильтрам diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 5cc5ac61a..e50538b55 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -10,16 +10,23 @@ Dodaj kanal Preimenuj kanal U redu + Сачувај Otkaži Odbaciti Kopirati Izuzetak uhvaćen: %1$s Dodaj kanal Upravljaj kanalima + Пријави канал + Блокирај канал + Канал Otvori kanal + Уклони канал + Канал блокиран Nijedan kanal nije dodat Potvrdi odjavu Da li ste sigurni da želite da se odjavite? + Odjaviti se? Odjavi se Pošalji sliku/video Slikaj @@ -31,34 +38,188 @@ Spoljašni hosting pruža %1$s, korisite ga na sopstveni rizik Prilagođeno slanje slika Poruka kopirana + ID поруке копиран Informacije o grešci kopirane Stani FeelsDankMan DankChat radi u pozadini Otvori meni sa emotovima + Затвори мени емотикона + Нема недавних емотикона + Емотикони Uloguj se na Twitch.tv Počni ćaskanje Veza nije uspostavljena Niste prijavljeni - Neko te je spomenuo + Одговор + Send announcement Neko te je spomenuo %1$s te je spomenuo u #%2$s Spomenut si u #%1$s Prijavljen kao %1$s Neuspešno prijavljivanje Kopirano: %1$s + Otpremanje završeno: %1$s Greška prilkom slanja Greška prilikom slanja: %1$s + Отпреми + Копирано у привремену меморију + Копирај URL Pokušaj ponovo Emotovi su osveženi Učitavanje podataka nije uspelo: %1$s + Учитавање података није успело са више грешака:\n%1$s + DankChat značke + Globalne značke + Globalni FFZ emotikoni + Globalni BTTV emotikoni + Globalni 7TV emotikoni + Značke kanala + FFZ emotikoni + BTTV emotikoni + 7TV emotikoni + Twitch emotikoni + Cheermotovi + Nedavne poruke + %1$s (%2$s) + + Прва порука + Истакнута порука + Гигантски емоут + Анимирана порука + Искоришћено: %1$s + %1$d секунду + %1$d секунде + %1$d секунди + + + %1$d минут + %1$d минута + %1$d минута + + + %1$d сат + %1$d сата + %1$d сати + + + %1$d дан + %1$d дана + %1$d дана + + + %1$d недељу + %1$d недеље + %1$d недеља + + %1$s %2$s + %1$s %2$s %3$s Paste Naziv kanala + Канал је већ додат + Недавно Pretplatnici Kanal Globalno Povezan + Поново повезан + Овај канал не постоји Veza prekiuta + Сервис историје порука недоступан (Грешка %1$s) + Сервис историје порука недоступан + Сервис историје порука се опоравља, могући су прекиди у историји порука. + Историја порука недоступна јер је овај канал искључен из сервиса. Ne vidite prethodne poruke jer ste onemogućili integraciju nedavnih poruka. + Учитавање FFZ емотикона није успело (Грешка %1$s) + Учитавање BTTV емотикона није успело (Грешка %1$s) + Учитавање 7TV емотикона није успело (Грешка %1$s) + %1$s је променио активни 7TV сет емотикона на \"%2$s\". + %1$s је додао 7TV емотикон %2$s. + %1$s је преименовао 7TV емотикон %2$s у %3$s. + %1$s је уклонио 7TV емотикон %2$s. + + + Порука задржана из разлога: %1$s. Дозвола ће је објавити у ћаскању. + Дозволи + Одбиј + Одобрено + Одбијено + Истекло + Еј! Твоја порука се проверава од стране модератора и још није послата. + Модератори су прихватили твоју поруку. + Модератори су одбили твоју поруку. + %1$s (ниво %2$d) + + подудара се са %1$d блокираним термином %2$s + подудара се са %1$d блокирана термина %2$s + подудара се са %1$d блокираних термина %2$s + + Није успело %1$s AutoMod поруке - порука је већ обрађена. + Није успело %1$s AutoMod поруке - потребно је поново се аутентификовати. + Није успело %1$s AutoMod поруке - немате дозволу за ову радњу. + Није успело %1$s AutoMod поруке - циљна порука није пронађена. + Није успело %1$s AutoMod поруке - догодила се непозната грешка. + %1$s је додао %2$s као блокирани термин на AutoMod. + %1$s је додао %2$s као дозвољени термин на AutoMod. + %1$s је уклонио %2$s као блокирани термин са AutoMod. + %1$s је уклонио %2$s као дозвољени термин са AutoMod. + + + + Добили сте тајмаут на %1$s + Добили сте тајмаут на %1$s од стране %2$s + Добили сте тајмаут на %1$s од стране %2$s: %3$s + %1$s је дао тајмаут кориснику %2$s на %3$s + %1$s је дао тајмаут кориснику %2$s на %3$s: %4$s + %1$s је добио тајмаут на %2$s + Бановани сте + Бановани сте од стране %1$s + Бановани сте од стране %1$s: %2$s + %1$s је бановао %2$s + %1$s је бановао %2$s: %3$s + %1$s је трајно банован + %1$s је уклонио тајмаут кориснику %2$s + %1$s је одбановао %2$s + %1$s је поставио %2$s за модератора + %1$s је уклонио %2$s са модератора + %1$s је додао %2$s као VIP овог канала + %1$s је уклонио %2$s као VIP овог канала + %1$s је упозорио %2$s + %1$s је упозорио %2$s: %3$s + %1$s је покренуо рејд на %2$s + %1$s је отказао рејд на %2$s + %1$s је обрисао поруку од %2$s + %1$s је обрисао поруку од %2$s са садржајем: %3$s + Порука од %1$s је обрисана + Порука од %1$s је обрисана са садржајем: %2$s + %1$s је очистио чат + Чат је очишћен од стране модератора + %1$s је укључио emote-only режим + %1$s је искључио emote-only режим + %1$s је укључио followers-only режим + %1$s је укључио followers-only режим (%2$s) + %1$s је искључио followers-only режим + %1$s је укључио unique-chat режим + %1$s је искључио unique-chat режим + %1$s је укључио спори режим + %1$s је укључио спори режим (%2$s) + %1$s је искључио спори режим + %1$s је укључио subscribers-only режим + %1$s је искључио subscribers-only режим + %1$s је дао тајмаут кориснику %2$s на %3$s у %4$s + %1$s је дао тајмаут кориснику %2$s на %3$s у %4$s: %5$s + %1$s је уклонио тајмаут кориснику %2$s у %3$s + %1$s је бановао %2$s у %3$s + %1$s је бановао %2$s у %3$s: %4$s + %1$s је одбановао %2$s у %3$s + %1$s је обрисао поруку од %2$s у %3$s + %1$s је обрисао поруку од %2$s у %3$s са садржајем: %4$s + %1$s%2$s + + \u0020(%1$d пут) + \u0020(%1$d пута) + \u0020(%1$d пута) + + < Poruka obrisana > Regex Dodajte unos @@ -66,7 +227,7 @@ Odricanje odgovornosti za istoriju poruka DankChat učitava istoriju poruka sa nezavisnih servisa. Da bi dobio poruke DankChat šalje imena kanala koje ste otvori tom servisu. -Servis privremeno čuva poruke za kanal koji vi (i drugi) posetite kako bi pružio uslogu. +Servis privremeno čuva poruke za kanal koji vi (i drugi) posetite kako bi pružio uslogu. Kako biste isključili ovu uslogu, pritisnite odustati ispod ili onemogućiti učitavanje poruke iz istorije u podešavanju kasnije. - Posetite https://recent-messages.robotty.de/ kako biste saznali više informacija o servisu i onemogućili istoriju poruka za svoj kanal. @@ -74,12 +235,19 @@ Kako biste isključili ovu uslogu, pritisnite odustati ispod ili onemogućiti u Odustati Još Pominjanja / Šapat + Тема одговора Šaputanja %1$s vam je šapnuo Pominjanja Potvrdi brisanje kanala Da li ste sigurni da želite da obrišete ovaj kanal? + Да ли сте сигурни да желите да уклоните канал \"%1$s\"? + Ukloniti ovaj kanal? + Ukloniti kanal \"%1$s\"? Obriši + Потврди блокирање канала + Да ли сте сигурни да желите да блокирате канал \"%1$s\"? + Blokirati kanal \"%1$s\"? Blokiraj Odblokiraj Spomeni korisnika @@ -111,15 +279,19 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Obriši poruku Ban Unban + Пријави Režim sobe Potvrditi banovanje Da li ste sigurni da želite da banujete ovog korisnika + Banovati ovog korisnika? Ban Potvrditi timeout timeout Potvrdite brisanje poruke Da li ste sigurni da želite da obrišete poruku? + Obrisati ovu poruku? Obriši + Obrisati čet? Ažuriraj režim sobe Samo emotovi Mod pretplatnika @@ -131,8 +303,33 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Dodaj komandu Obriši komandu Okidać + Ovaj okidač je rezervisan od strane ugrađene komande + Ovaj okidač je već u upotrebi od strane druge komande Komanda Prilagođene komande + Додај корисника + Уклони корисника + Корисник + Линк за брисање:\n%1$s + Обриши отпремања + Хост + Ресетуј + OAuth токен + Верификуј и сачувај + Подржани су само Client ID-ови који раде са Twitch Helix API + Прикажи потребне дозволе + Потребне дозволе + Недостајуће дозволе + Неке дозволе потребне за DankChat недостају у токену и неке функције можда неће радити како треба. Да ли желите да наставите са овим токеном?\nНедостаје: %1$s + Настави + Недостајуће дозволе: %1$s + Грешка: %1$s + Токен не може бити празан + Токен је неважећи + Модератор + Главни модератор + Предвидео \"%1$s\" + Ниво %1$s Prikaži vremensku markicu Format pominjanja Prikaži informacije o režimu sobe @@ -144,15 +341,59 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Notifikacije Ćaskanje Generalno + Предлози + Поруке + Корисници + Емоте и значке + Режим предлога + Предлажи подударања док куцате + Предлажи само после тригер знака Dodatne informacije Izgled DankChat %1$s je napravljen od @flex3rs i saradnicima Prikaži unos Pirkazuje polje za unos za slanje poruka Sistem praćenja - Pravi tamni mod - Forsiraj pozadinu chat-a na crnu + AMOLED tamni režim + Potpuno crna pozadina za OLED ekrane + Boja isticanja + Prati sistemsku pozadinu + Plava + Tirkizna + Zelena + Limeta + Žuta + Narandžasta + Crvena + Roze + Ljubičasta + Indigo + Braon + Siva + Stil boja + Подразумевано системско + Користи подразумевану системску палету боја + Tonal Spot + Mirne i prigušene boje + Neutral + Skoro jednobojna, suptilna nijansa + Vibrant + Odvažne i zasićene boje + Expressive + Razigrane boje sa pomerenim nijansama + Rainbow + Širok spektar nijansi + Fruit Salad + Razigrana, višebojna paleta + Monochrome + Samo crna, bela i siva + Fidelity + Verna boji isticanja + Content + Boja isticanja sa analognom tercijarnom + Više stilova Ekran + Компоненте Prikaži obrisane poruke Animirani gifovi Odvojite poruke linijama @@ -162,16 +403,26 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Malo Veliko Veoma veliko - Emotovi i korisnički predlozi - Prikaži predloge za emotove i aktivne korisnike dok kucate + Предлози + Изаберите које предлоге приказати приликом куцања + Емоте + Покрените са : + Корисници + Покрените са @ + Twitch команде + Покрените са / + Supibot команде + Покрените са $ Učitaj istoriju poruka na početku + Учитај историју порука после поновног повезивања + Покушава да преузме пропуштене поруке које нису примљене током прекида везе Istorija poruka Otvorite kontrolnu tablu Saznajte više o usluzi i onemogućite istoriju poruka za svoj kanal Podaci o kanalu Programerska podešavanja Debug mod - Pruža informacije o svim izuzecima koji su uhvaćeni. + Прикажи акцију аналитике за отклањање грешака у траци за унос и локално прикупљај извештаје о падовима Format vremenskih markica Omogući čitanje (tekst u govor) Čita poruke aktivnog kanala @@ -181,7 +432,18 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Čita samo poruku Čita korisnika i poruku Režim formata poruka + Игнорише URL адресе у TTS + Игнориши URL адресе + Игнорише емотиконе и емоџије у TTS + Игнориши емотиконе + Јачина звука + Пригушивање звука + Смањи јачину других апликација док TTS говори Tekst u govor + Forsiraj engleski jezik + Forisraj čitanje teksta na engleskom umesto na podrazumevanom jeziku + Листа игнорисаних корисника + Листа корисника/налога који ће бити игнорисани Karirane linije Odvojite svaku liniju različitom osvetljenošću pozadine Predlozi komandi za Supibot @@ -193,7 +455,493 @@ Pogledajte ovaj guide za pomoć https://wiki.chatterino.com/Image%20Uploader/Ponašanje korisničkog dugog klika Običan klik otvara iskačući prozor, duži klik otvara pominjanja Obićan klik otvara pominjanja, duži klik otvara iskačući prozor - Forsiraj engleski jezik - Forisraj čitanje teksta na engleskom umesto na podrazumevanom jeziku + Обојите надимке + Додели насумичну боју корисницима без постављене боје Vidljivost emotova nezavisnih servisa + Twitch услови коришћења и правила: + Прикажи акције чипова + Приказује чипове за пребацивање целог екрана, стримова и подешавање режима чата + Prikaži brojač karaktera + Prikazuje broj kodnih tačaka u polju za unos + Prikaži dugme za brisanje unosa + Prikaži dugme za slanje + Unos + Отпремање медија + Подеси отпремање + Недавна отпремања + Алатке + Тема + Тамна тема + Светла тема + Дозволи неуврштене емотиконе + Искључује филтрирање неодобрених или неуврштених емотикона + Прилагођени хост за недавне поруке + Преузми информације о стриму + Периодично преузима информације о стриму отворених канала. Потребно за покретање уграђеног стрима. + Онемогући унос ако нисте повезани на чат + Омогући поновљено слање + Омогућава непрекидно слање порука док је дугме за слање притиснуто + Пријава је истекла! + Ваш токен за пријаву је истекао! Пријавите се поново. + Пријави се поново + Верификација токена за пријаву није успела, проверите вашу везу. + Спречи поновно учитавање стрима + Омогућава експериментално спречавање поновног учитавања стрима након ротације екрана или поновног отварања DankChat-а. + Прикажи дневник промена после ажурирања + Шта је ново + Прилагођена пријава + Заобиђи Twitch обраду команди + Искључује пресретање Twitch команди и шаље их директно у чат + Протокол за слање порука + Користи Helix API за слање + Шаље поруке у чату преко Twitch Helix API уместо IRC + 7TV ажурирања емотикона уживо + Понашање ажурирања емотикона у позадини + Ажурирања се заустављају после %1$s.\nСмањивање овог броја може продужити трајање батерије. + Ажурирања су увек активна.\nДодавање временског ограничења може продужити трајање батерије. + Ажурирања никада нису активна у позадини. + Никада активно + 1 минут + 5 минута + 30 минута + 1 сат + Увек активно + Стримови уживо + Омогући режим слика-у-слици + Омогућава наставак репродукције стримова док апликација ради у позадини + Обавештења за шапате + Ресетуј подешавања отпремања медија + Да ли сте сигурни да желите да ресетујете подешавања отпремања медија на подразумевана? + Ресетуј + Обриши недавна отпремања + Да ли сте сигурни да желите да обришете историју отпремања? Отпремљени фајлови неће бити обрисани. + Обриши + Образац + Осетљиво на величину слова + Истицања + Укључено + Обавештење + Измени истицања порука + Истицања и игнорисања + Корисничко име + Блокирај + Замена + Игнорисања + Измени игнорисања порука + Поруке + Корисници + Блокирани корисници + Twitch + Значке + Поништи + Ставка уклоњена + Корисник %1$s одблокиран + Одблокирање корисника %1$s није успело + Значка + Блокирање корисника %1$s није успело + Ваше корисничко име + Претплате и догађаји + Обавештења + Серије гледања + Прве поруке + Истакнуте поруке + Истицања откупљена поенима канала + Одговори + Прилагођено + Прави обавештења и истиче поруке на основу одређених образаца. + Прави обавештења и истиче поруке од одређених корисника. + Искључи обавештења и истицања од одређених корисника (нпр. ботова). + Прави обавештења и истиче поруке од корисника на основу значки. + Игнориши поруке на основу одређених образаца. + Игнориши поруке од одређених корисника. + Управљај блокираним Twitch корисницима. + Пријава је застарела! + Ваша пријава је застарела и нема приступ неким функцијама. Пријавите се поново. + Прилагођени приказ корисника + Уклони прилагођени приказ корисника + Надимак + Прилагођена боја + Прилагођени надимак + Изабери прилагођену боју корисника + Додај прилагођено име и боју за кориснике + Одговор на + Одговор ка @%1$s + Тема одговора није пронађена + + + Обриши + Пошаљи шапат + Шапат ка @%1$s + Нови шапат + Пошаљи шапат + Корисничко име + Пошаљи + + + Користи емотикон + Копирај + Отвори линк емотикона + Слика емотикона + Twitch емотикон + FFZ емотикон канала + Глобални FFZ емотикон + BTTV емотикон канала + Дељени BTTV емотикон + Глобални BTTV емотикон + 7TV емотикон канала + Глобални 7TV емотикон + Алијас за %1$s + Направио %1$s + (Нулте ширине) + Емотикон копиран + + + DankChat је ажуриран! + Шта је ново у v%1$s: + + + Потврди отказивање пријаве + Да ли сте сигурни да желите да откажете процес пријаве? + Откажи пријаву + Умањи + Увећај + Назад + + + Дељени чат + + + + Уживо са %1$d гледаоцем %2$s + Уживо са %1$d гледаоца %2$s + Уживо са %1$d гледалаца %2$s + + + Уживо са %1$d гледаоцем у %2$s %3$s + Уживо са %1$d гледаоца у %2$s %3$s + Уживо са %1$d гледалаца у %2$s %3$s + + + %d месец + %d месеца + %d месеци + + + Лиценце отвореног кода + Прикажи категорију стрима + Такође приказује категорију стрима + Укључи/искључи унос + Prikaži/sakrij traku aplikacije + Greška: %s + + + Стример + Админ + Особље + Модератор + Главни модератор + Верификован + VIP + Оснивач + Претплатник + Изабери прилагођену боју истицања + Подразумевано + Изабери боју + + + Само емотикони + Само претплатници + Спори режим + Спори режим (%1$s) + Јединствени чат (R9K) + Само пратиоци + Само пратиоци (%1$s) + Прилагођено + Било који + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Активирати режим штита? + Ово ће применити унапред конфигурисане безбедносне поставке канала, које могу укључивати ограничења ћаскања, подешавања AutoMod-а и захтеве за верификацију. + Активирај Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Додајте канал да бисте почели да ћаскате + + + Прикажи стрим + Сакриј стрим + Само звук + Изађи из режима звука + Цео екран + Изађи из целог екрана + Сакриј унос + Прикажи унос + Навигација канала превлачењем + Мењајте канале превлачењем по чату + Модерација канала + + + Претражи поруке + Последња порука + Укључи/искључи стрим + Модерација канала + Цео екран + Сакриј унос + Подеси акције + Отклањање грешака + + Максимално %1$d акција + Максимално %1$d акције + Максимално %1$d акција + + + + Копирај поруку + Копирај целу поруку + Одговори на поруку + Одговори на оригиналну поруку + Прикажи тему + Копирај ID поруке + Још… + Иди на поруку + Порука није пронађена + Порука више није у историји ћаскања + Историја порука + Глобална историја + Историја: %1$s + Претражи поруке… + Филтрирај по корисничком имену + Поруке са линковима + Поруке са емотима + Филтрирај по називу значке + Корисник + Значка + + + DankChat + Хајде да подесимо све. + Пријави се преко Twitch-а + Пријавите се да бисте слали поруке, користили емотиконе, примали шапате и откључали све функције. + Бићете замољени да одобрите неколико Twitch дозвола одједном како не бисте морали поново да се ауторизујете при коришћењу различитих функција. DankChat извршава радње модерације и управљања преносом само када то затражите. + Пријави се преко Twitch-а + Пријава успешна + Обавештења + DankChat може да вас обавести када вас неко помене у чату док апликација ради у позадини. + Дозволи обавештења + Отвори подешавања обавештења + Без обавештења нећете знати када вас неко помене у чату док апликација ради у позадини. + Историја порука + DankChat учитава историјске поруке из услуге треће стране при покретању. Да би добио поруке, DankChat шаље имена отворених канала овој услузи. Услуга привремено чува поруке посећених канала.\n\nОво можете касније променити у подешавањима или сазнати више на https://recent-messages.robotty.de/ + Укључи + Искључи + Настави + Почни + Прескочи + + + Прилагодљиве акције за брз приступ претрази, стримовима и другом + Додирните овде за више акција и подешавање траке акција + Овде можете прилагодити које акције се приказују на траци акција + Превуците надоле по пољу за унос да бисте га брзо сакрили + Додирните овде да вратите поље за унос + Даље + Разумем + Прескочи обилазак + Овде можете додати више канала + + + Опште + Аутентификација + Укључи Twitch EventSub + Користи EventSub за различите догађаје у реалном времену уместо застарелог PubSub + Укључи дебаг излаз за EventSub + Приказује дебаг излаз везан за EventSub као системске поруке + Опозови токен и рестартуј + Поништава тренутни токен и рестартује апликацију + Нисте пријављени + Није могуће одредити ID канала за %1$s + Порука није послата + Порука одбачена: %1$s (%2$s) + Недостаје дозвола user:write:chat, пријавите се поново + Немате дозволу за слање порука у овом каналу + Порука је превелика + Ограничење брзине, покушајте поново за тренутак + Слање неуспешно: %1$s + + + Морате бити пријављени да бисте користили команду %1$s + Није пронађен корисник са тим корисничким именом. + Дошло је до непознате грешке. + Немате дозволу за извршавање ове радње. + Недостаје потребна дозвола. Поново се пријавите и покушајте поново. + Недостају подаци за пријаву. Поново се пријавите и покушајте поново. + Употреба: /block <корисник> + Успешно сте блокирали корисника %1$s + Корисник %1$s није могао бити блокиран, корисник са тим именом није пронађен! + Корисник %1$s није могао бити блокиран, дошло је до непознате грешке! + Употреба: /unblock <корисник> + Успешно сте деблокирали корисника %1$s + Корисник %1$s није могао бити деблокиран, корисник са тим именом није пронађен! + Корисник %1$s није могао бити деблокиран, дошло је до непознате грешке! + Канал није уживо. + Време емитовања: %1$s + Команде доступне у овој соби: %1$s + Употреба: %1$s <корисничко име> <порука>. + Шапат послат. + Слање шапата није успело - %1$s + Употреба: %1$s <порука> - Скрените пажњу на своју поруку истицањем. + Слање најаве није успело - %1$s + Овај канал нема модераторе. + Модератори овог канала су %1$s. + Приказивање модератора није успело - %1$s + Употреба: %1$s <корисничко име> - Доделите статус модератора кориснику. + Додали сте %1$s као модератора овог канала. + Додавање модератора канала није успело - %1$s + Употреба: %1$s <корисничко име> - Одузмите статус модератора кориснику. + Уклонили сте %1$s као модератора овог канала. + Уклањање модератора канала није успело - %1$s + Овај канал нема VIP кориснике. + VIP корисници овог канала су %1$s. + Приказивање VIP корисника није успело - %1$s + Употреба: %1$s <корисничко име> - Доделите VIP статус кориснику. + Додали сте %1$s као VIP овог канала. + Додавање VIP корисника није успело - %1$s + Употреба: %1$s <корисничко име> - Одузмите VIP статус кориснику. + Уклонили сте %1$s као VIP овог канала. + Уклањање VIP корисника није успело - %1$s + Употреба: %1$s <корисничко име> [разлог] - Трајно забраните кориснику да ћаскa. Разлог је опционалан и биће приказан циљном кориснику и осталим модераторима. Користите /unban за уклањање забране. + Забрана корисника није успела - Не можете забранити себе. + Забрана корисника није успела - Не можете забранити емитера. + Забрана корисника није успела - %1$s + Употреба: %1$s <корисничко име> - Уклања забрану корисника. + Уклањање забране корисника није успело - %1$s + Употреба: %1$s <корисничко име> [трајање][јединица времена] [разлог] - Привремено забраните кориснику да ћаска. Трајање (опционално, подразумевано: 10 минута) мора бити позитиван цео број; јединица времена (опционално, подразумевано: s) мора бити s, m, h, d или w; максимално трајање је 2 недеље. Разлог је опционалан и биће приказан циљном кориснику и осталим модераторима. + Забрана корисника није успела - Не можете искључити себе. + Забрана корисника није успела - Не можете искључити емитера. + Искључивање корисника није успело - %1$s + Брисање порука из ћаскања није успело - %1$s + Употреба: /delete <msg-id> - Брише наведену поруку. + Неважећи msg-id: \"%1$s\". + Брисање порука из ћаскања није успело - %1$s + Употреба: /color <боја> - Боја мора бити једна од подржаних боја на Twitch-у (%1$s) или hex code (#000000) ако имате Turbo или Prime. + Ваша боја је промењена у %1$s + Промена боје у %1$s није успела - %2$s + Успешно додат маркер стрима у %1$s%2$s. + Креирање маркера стрима није успело - %1$s + Употреба: /commercial <дужина> - Покреће рекламу са наведеним трајањем за тренутни канал. Важеће дужине су 30, 60, 90, 120, 150 и 180 секунди. + + Покреће се рекламна пауза од %1$d секунди. Имајте на уму да сте и даље уживо и да неће сви гледаоци видети рекламу. Можете покренути још једну рекламу за %2$d секунди. + Покреће се рекламна пауза од %1$d секунди. Имајте на уму да сте и даље уживо и да неће сви гледаоци видети рекламу. Можете покренути још једну рекламу за %2$d секунди. + Покреће се рекламна пауза од %1$d секунди. Имајте на уму да сте и даље уживо и да неће сви гледаоци видети рекламу. Можете покренути још једну рекламу за %2$d секунди. + + Покретање рекламе није успело - %1$s + Употреба: /raid <корисничко име> - Рејдујте корисника. Само емитер може покренути рејд. + Неважеће корисничко име: %1$s + Покренули сте рејд на %1$s. + Покретање рејда није успело - %1$s + Отказали сте рејд. + Отказивање рејда није успело - %1$s + Употреба: %1$s [трајање] - Укључује режим само за пратиоце (само пратиоци могу да ћаскају). Трајање (опционално, подразумевано: 0 минута) мора бити позитиван број праћен јединицом времена (m, h, d, w); максимално трајање је 3 месеца. + Ова соба је већ у режиму само за пратиоце од %1$s. + Ажурирање подешавања ћаскања није успело - %1$s + Ова соба није у режиму само за пратиоце. + Ова соба је већ у режиму само емоте. + Ова соба није у режиму само емоте. + Ова соба је већ у режиму само за претплатнике. + Ова соба није у режиму само за претплатнике. + Ова соба је већ у режиму јединственог ћаскања. + Ова соба није у режиму јединственог ћаскања. + Употреба: %1$s [трајање] - Укључује спори режим (ограничава учесталост слања порука). Трајање (опционално, подразумевано: 30) мора бити позитиван број секунди; максимум 120. + Ова соба је већ у спором режиму од %1$d секунди. + Ова соба није у спором режиму. + Употреба: %1$s <корисничко име> - Шаље шаутаут наведеном Twitch кориснику. + Послат шаутаут за %1$s + Слање шаутаута није успело - %1$s + Режим штита је активиран. + Режим штита је деактивиран. + Ажурирање режима штита није успело - %1$s + Не можете шапутати себи. + Због ограничења Twitch-а, сада је потребан верификован број телефона за слање шапата. Можете додати број телефона у подешавањима Twitch-а. https://www.twitch.tv/settings/security + Прималац не дозвољава шапате од непознатих или директно од вас. + Twitch вам ограничава брзину. Покушајте поново за неколико секунди. + Можете шапутати највише 40 јединствених прималаца дневно. У оквиру дневног ограничења, можете послати највише 3 шапата у секунди и највише 100 шапата у минуту. + Због ограничења Twitch-а, ову команду може користити само емитер. Молимо користите Twitch веб-сајт. + %1$s је већ модератор овог канала. + %1$s је тренутно VIP, користите /unvip и покушајте поново. + %1$s није модератор овог канала. + %1$s није забрањен у овом каналу. + %1$s је већ забрањен у овом каналу. + Не можете %1$s %2$s. + Дошло је до конфликтне операције забране за овог корисника. Молимо покушајте поново. + Боја мора бити једна од подржаних боја на Twitch-у (%1$s) или hex code (#000000) ако имате Turbo или Prime. + Морате бити уживо да бисте покретали рекламе. + Морате сачекати да истекне период хлађења пре него што можете покренути другу рекламу. + Команда мора садржати жељену дужину рекламне паузе већу од нуле. + Немате активан рејд. + Канал не може рејдовати сам себе. + Емитер не може дати шаутаут сам себи. + Емитер није уживо или нема једног или више гледалаца. + Трајање је ван дозвољеног опсега: %1$s. + Порука је већ обрађена. + Циљна порука није пронађена. + Ваша порука је била предуга. + Ограничена вам је брзина. Покушајте поново за тренутак. + Циљни корисник + Прегледач логова + Прегледајте логове апликације + Логови + Подели логове + Прегледај логове + Нема доступних лог фајлова + Претражи логове + + %1$d изабрано + %1$d изабрано + %1$d изабрано + + Копирај изабране логове + Обриши избор + Откривен пад + Апликација се срушила током последње сесије. + Нит: %1$s + Копирај + Извештај у чату + Придружује се каналу #flex3rs и припрема сажетак пада за слање + Извештај путем е-поште + Пошаљи детаљан извештај о паду путем е-поште + Пошаљи извештај о паду путем е-поште + Следећи подаци ће бити укључени у извештај: + Трага стека + Укључи тренутни лог фајл + Извештаји о падовима + Прегледај недавне извештаје о падовима + Нису пронађени извештаји о падовима + Извештај о паду + Подели извештај о паду + Обриши + Обрисати овај извештај о паду? + Обриши све + Обрисати све извештаје о падовима? + Помери на дно + Погледај историју + Нема порука које одговарају тренутним филтерима diff --git a/app/src/main/res/values-tr-rTR/strings.xml b/app/src/main/res/values-tr-rTR/strings.xml index e91d07899..295e7e819 100644 --- a/app/src/main/res/values-tr-rTR/strings.xml +++ b/app/src/main/res/values-tr-rTR/strings.xml @@ -48,7 +48,7 @@ Bağlantı kesildi Giriş yapılmadı Yanıt - Yeni bahsetmeleriniz var + Send announcement Yeni bahsetmeleriniz var %1$s sizden #%2$s\'de bahsetti #%1$s\'de sizden bahsedildi %1$s olarak giriş yapıldı @@ -56,12 +56,56 @@ Kopyalandı: %1$s Yükleme sırasında hata Yükleme sırasında hata: %1$s + Yükle + Panoya kopyalandı + URL kopyala Yeniden dene İfadeler yenilendi Veri yüklenemedi: %1$s Birden çok hata ile başarısız veri yükleme:\n%1$s + DankChat Rozetleri + Global Rozetler + Global FFZ Emote\'ları + Global BTTV Emote\'ları + Global 7TV Emote\'ları + Kanal Rozetleri + FFZ Emote\'ları + BTTV Emote\'ları + 7TV Emote\'ları + Twitch Emote\'ları + Cheermote\'lar + Son mesajlar + %1$s (%2$s) + + İlk mesaj + Yükseltilmiş mesaj + Dev Emote + Animasyonlu Mesaj + Kullanıldı: %1$s + %1$d saniye + %1$d saniye + + + %1$d dakika + %1$d dakika + + + %1$d saat + %1$d saat + + + %1$d gün + %1$d gün + + + %1$d hafta + %1$d hafta + + %1$s %2$s + %1$s %2$s %3$s Yapıştır Kanal adı + Kanal zaten eklenmiş Son kullanılanlar Abonelikler Kanal @@ -158,6 +202,8 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Komut ekle Komutu sil Tetik + Bu tetik yerleşik bir komut tarafından ayrılmış + Bu tetik zaten başka bir komut tarafından kullanılıyor Komut Özel komutlar Bildir @@ -196,14 +242,54 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Bildirimler Sohbet Genel + Öneriler + Mesajlar + Kullanıcılar + İfadeler ve Rozetler Hakkında Görünüm DankChat %1$s @flex3rs ve katılımcılar tarafından yapıldı Girdiyi göster Mesaj göndermek için girdiyi gösterir Sistem varsayılanını izle - Gerçek karanlık tema - Sohbet arkaplan rengini kara olmaya zorlar + AMOLED karanlık mod + OLED ekranlar için saf siyah arka plan + Vurgu rengi + Sistem duvar kağıdını takip et + Mavi + Deniz mavisi + Yeşil + Limon yeşili + Sarı + Turuncu + Kırmızı + Pembe + Mor + Çivit + Kahverengi + Gri + Renk stili + Sistem varsayılanı + Varsayılan sistem renk paletini kullan + Tonal Spot + Sakin ve yumuşak renkler + Neutral + Neredeyse tek renkli, hafif ton + Vibrant + Cesur ve doygun renkler + Expressive + Kaydırılmış tonlarla eğlenceli renkler + Rainbow + Geniş ton yelpazesi + Fruit Salad + Eğlenceli, çok renkli palet + Monochrome + Yalnızca siyah, beyaz ve gri + Fidelity + Vurgu rengine sadık kalır + Content + Benzer üçüncül renkle vurgu rengi + Daha fazla stil Görünüm Bileşenler Zaman aşımına uğrayan mesajları göster @@ -215,8 +301,19 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Küçük Büyük Çok büyük - İfade ve kullanıcı önerileri - Yazarken ifadeler ve etkin kullanıcılar için öneriler göster + Öneriler + Yazarken hangi önerilerin gösterileceğini seçin + İfadeler + Kullanıcılar + Twitch komutları + Supibot komutları + : ile tetikle + @ ile tetikle + / ile tetikle + $ ile tetikle + Öneri modu + Yazarken eşleşmeleri öner + Yalnızca tetikleyici karakterden sonra öner Başlangıçta mesaj tarihini yükle Yeniden bağlandıktan sonra mesaj tarihini yükle Bağlantı kesintileri sırasında alınmamış yitik mesajları almayı dener @@ -226,7 +323,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kanal verisi Geliştirici seçenekleri Hata ayıklama modu - Yakalanan her hata için bilgi verir + Giriş çubuğunda hata ayıklama analitik eylemini göster ve çökme raporlarını yerel olarak topla Zaman damgası biçimi TTS\'i etkinleştir Etkin kanalın mesajlarını okur @@ -240,6 +337,9 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ URL\'leri yoksay TTS\'te ifadeler ile emojileri yok sayar İfadeleri yok say + Ses seviyesi + Ses kısma + TTS konuşurken diğer uygulamaların sesini kıs TTS Damalı Satırlar Her satırı değişik arkaplan parlaklığıyla ayır @@ -252,12 +352,19 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kullanıcı adına uzunca tıklanıncaki davranış Normal tıklama pencereyi açar, uzun tıklama bahseder Normal tıklama bahseder, uzun tıklama pencereyi açar + Takma adları renklendir + Belirlenmiş rengi olmayan kullanıcılara rastgele renk ata Dili İngilizce olmaya zorla TTS sesini sistem varsayılanı yerine İngilizce olmaya zorla Görünür üçüncü taraf ifadeler Twitch hizmet koşulları ile kullanıcı politikası: Çip eylemlerini göster Yayınlar, tamekranı açıp kapamak ve sohbet modlarını ayarlamak için çipler gösterir + Karakter sayacını göster + Giriş alanında kod noktası sayısını görüntüler + Girdiyi temizle düğmesini göster + Gönder düğmesini göster + Girdi Medya yükleyici Yükleyiciyi ayarla Son yüklemeler @@ -286,6 +393,9 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Özel giriş Twitch komut işlemeyi atla Twitch komutlarının yakalanmasını etkisizleştirir ve bunun yerine onları sohbete gönderir + Sohbet gönderme protokolü + Göndermek için Helix API kullan + Sohbet mesajlarını IRC yerine Twitch Helix API üzerinden gönderir 7TV canlı ifade güncellemeleri Canlı ifade güncellemeleri arkaplan davranışı Güncellemeler %1$s sonra durur.\nBu sayıyı düşürmek pil ömrünü uzatabilir. @@ -332,6 +442,7 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Kullanıcı adınız Abonelikler ile Etkinlikler Duyurular + İzleme Serileri İlk Mesajlar Yükseltilmiş Mesaj Kanal Puanlarıyla alınan Öne Çıkarmalar @@ -358,9 +469,22 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Mesajı kopyala Tüm mesajı kopyala Mesajı yanıtla + Orijinal mesajı yanıtla Akışı görüntüle Mesaj ID\'sini kopyala Daha fazlası… + Mesaja git + Mesaj artık sohbet geçmişinde değil + Mesaj geçmişi + Genel geçmiş + Geçmiş: %1$s + Mesaj ara… + Kullanıcı adına göre filtrele + Bağlantı içeren mesajlar + Emote içeren mesajlar + Rozet adına göre filtrele + Kullanıcı + Rozet \@%1$s yanıtlanıyor Yanıt akışı bulunamadı Mesaj bulunamadı @@ -417,4 +541,374 @@ Yardım için bu kılavuza bakın: https://wiki.chatterino.com/Image%20Uploader/ Özel vurgu rengi seç Varsayılan Renk Seç + Uygulama Çubuğunu Aç/Kapat + Hata: %s + Çıkış yapılsın mı? + Bu kanal kaldırılsın mı? + \"%1$s\" kanalı kaldırılsın mı? + \"%1$s\" kanalı engellensin mi? + Bu kullanıcı banlansın mı? + Bu mesaj silinsin mi? + Sohbet temizlensin mi? + Arama, yayınlar ve daha fazlasına hızlı erişim için özelleştirilebilir eylemler + Daha fazla eylem ve eylem çubuğunuzu yapılandırmak için buraya dokunun + Eylem çubuğunuzda hangi eylemlerin görüneceğini buradan özelleştirebilirsiniz + Hızlıca gizlemek için giriş alanında aşağı kaydırın + Giriş alanını geri getirmek için buraya dokunun + İleri + Anladım + Turu atla + Buradan daha fazla kanal ekleyebilirsiniz + + + Mesaj şu nedenle tutuldu: %1$s. İzin vermek mesajı sohbette yayınlayacaktır. + İzin Ver + Reddet + Onaylandı + Reddedildi + Süresi Doldu + Hey! Mesajın modlar tarafından kontrol ediliyor ve henüz gönderilmedi. + Modlar mesajını kabul etti. + Modlar mesajını reddetti. + %1$s (seviye %2$d) + + %1$d engellenen terimle eşleşiyor %2$s + %1$d engellenen terimle eşleşiyor %2$s + + AutoMod mesajı %1$s başarısız oldu - mesaj zaten işlenmiş. + AutoMod mesajı %1$s başarısız oldu - yeniden kimlik doğrulaması yapmanız gerekiyor. + AutoMod mesajı %1$s başarısız oldu - bu işlemi gerçekleştirme izniniz yok. + AutoMod mesajı %1$s başarısız oldu - hedef mesaj bulunamadı. + AutoMod mesajı %1$s başarısız oldu - bilinmeyen bir hata oluştu. + %1$s, AutoMod üzerinde %2$s terimini engellenen terim olarak ekledi. + %1$s, AutoMod üzerinde %2$s terimini izin verilen terim olarak ekledi. + %1$s, AutoMod üzerinden %2$s terimini engellenen terim olarak kaldırdı. + %1$s, AutoMod üzerinden %2$s terimini izin verilen terim olarak kaldırdı. + + + %1$s süreliğine zaman aşımına uğratıldınız + %2$s tarafından %1$s süreliğine zaman aşımına uğratıldınız + %2$s tarafından %1$s süreliğine zaman aşımına uğratıldınız: %3$s + %1$s, %2$s kullanıcısını %3$s süreliğine zaman aşımına uğrattı + %1$s, %2$s kullanıcısını %3$s süreliğine zaman aşımına uğrattı: %4$s + %1$s %2$s süreliğine zaman aşımına uğratıldı + Banlandınız + %1$s tarafından banlandınız + %1$s tarafından banlandınız: %2$s + %1$s, %2$s kullanıcısını banladı + %1$s, %2$s kullanıcısını banladı: %3$s + %1$s kalıcı olarak banlandı + %1$s, %2$s kullanıcısının zaman aşımını kaldırdı + %1$s, %2$s kullanıcısının banını kaldırdı + %1$s, %2$s kullanıcısını moderatör yaptı + %1$s, %2$s kullanıcısının moderatörlüğünü kaldırdı + %1$s, %2$s kullanıcısını bu kanalın VIP\'si olarak ekledi + %1$s, %2$s kullanıcısını bu kanalın VIP\'leri arasından çıkardı + %1$s, %2$s kullanıcısını uyardı + %1$s, %2$s kullanıcısını uyardı: %3$s + %1$s, %2$s kanalına raid başlattı + %1$s, %2$s kanalına raidi iptal etti + %1$s, %2$s kullanıcısının mesajını sildi + %1$s, %2$s kullanıcısının mesajını sildi şunu diyerek: %3$s + %1$s kullanıcısının bir mesajı silindi + %1$s kullanıcısının bir mesajı silindi şunu diyerek: %2$s + %1$s sohbeti temizledi + Sohbet bir moderatör tarafından temizlendi + %1$s yalnızca emote modunu açtı + %1$s yalnızca emote modunu kapattı + %1$s yalnızca takipçiler modunu açtı + %1$s yalnızca takipçiler modunu açtı (%2$s) + %1$s yalnızca takipçiler modunu kapattı + %1$s benzersiz sohbet modunu açtı + %1$s benzersiz sohbet modunu kapattı + %1$s yavaş modu açtı + %1$s yavaş modu açtı (%2$s) + %1$s yavaş modu kapattı + %1$s yalnızca aboneler modunu açtı + %1$s yalnızca aboneler modunu kapattı + %1$s, %2$s kullanıcısını %3$s süreliğine %4$s kanalında zaman aşımına uğrattı + %1$s, %2$s kullanıcısını %3$s süreliğine %4$s kanalında zaman aşımına uğrattı: %5$s + %1$s, %2$s kullanıcısının zaman aşımını %3$s kanalında kaldırdı + %1$s, %2$s kullanıcısını %3$s kanalında banladı + %1$s, %2$s kullanıcısını %3$s kanalında banladı: %4$s + %1$s, %2$s kullanıcısının banını %3$s kanalında kaldırdı + %1$s, %2$s kullanıcısının mesajını %3$s kanalında sildi + %1$s, %2$s kullanıcısının mesajını %3$s kanalında sildi şunu diyerek: %4$s + %1$s%2$s + + \u0020(%1$d kez) + \u0020(%1$d kez) + + + + Geri sil + Fısıltı gönder + @%1$s adlı kişiye fısıldıyor + Yeni fısıltı + Fısıltı gönder + Kullanıcı adı + Gönder + + + Yalnızca emote + Yalnızca aboneler + Yavaş mod + Yavaş mod (%1$s) + Benzersiz sohbet (R9K) + Yalnızca takipçiler + Yalnızca takipçiler (%1$s) + Özel + Herhangi + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Kalkan Modu etkinleştirilsin mi? + Bu, kanalın önceden yapılandırılmış güvenlik ayarlarını uygulayacaktır; sohbet kısıtlamaları, AutoMod ayarları ve doğrulama gereksinimleri dahil olabilir. + Etkinleştir Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Sohbete başlamak için bir kanal ekleyin + Son kullanılan emote yok + + + Yayını göster + Yayını gizle + Yalnızca ses + Ses modundan çık + Tam ekran + Tam ekrandan çık + Girişi gizle + Girişi göster + Kanal kaydırma ile gezinme + Sohbette kaydırarak kanal değiştirme + Kanal moderasyonu + + + Mesajları ara + Son mesaj + Yayını aç/kapat + Kanal moderasyonu + Tam ekran + Girişi gizle + Eylemleri yapılandır + Hata ayıklama + + En fazla %1$d eylem + En fazla %1$d eylem + + + + DankChat + Hadi her şeyi ayarlayalım. + Twitch ile giriş yap + Mesaj göndermek, emote\'larınızı kullanmak, fısıltı almak ve tüm özelliklerin kilidini açmak için giriş yapın. + Farklı özellikleri kullanırken tekrar yetkilendirme yapmanız gerekmemesi için birkaç Twitch izni tek seferde istenecektir. DankChat moderasyon ve yayın işlemlerini yalnızca siz istediğinizde gerçekleştirir. + Twitch ile giriş yap + Giriş başarılı + Bildirimler + DankChat, uygulama arka planda çalışırken sohbette biri sizden bahsettiğinde sizi bilgilendirebilir. + Bildirimlere izin ver + Bildirim ayarlarını aç + Bildirimler olmadan, uygulama arka planda çalışırken sohbette biri sizden bahsettiğinde haberiniz olmaz. + Mesaj Geçmişi + DankChat başlangıçta üçüncü taraf bir hizmetten geçmiş mesajları yükler. Mesajları almak için DankChat, açık kanalların adlarını bu hizmete gönderir. Hizmet, ziyaret edilen kanalların mesajlarını geçici olarak depolar.\n\nBunu daha sonra ayarlardan değiştirebilir veya https://recent-messages.robotty.de/ adresinden daha fazla bilgi edinebilirsiniz. + Etkinleştir + Devre dışı bırak + Devam et + Başla + Atla + + + Genel + Kimlik doğrulama + Twitch EventSub\'u etkinleştir + Kullanımdan kaldırılan PubSub yerine çeşitli gerçek zamanlı olaylar için EventSub kullanır + EventSub hata ayıklama çıktısını etkinleştir + EventSub ile ilgili hata ayıklama çıktısını sistem mesajları olarak gösterir + Jetonu iptal et ve yeniden başlat + Mevcut jetonu geçersiz kılar ve uygulamayı yeniden başlatır + Giriş yapılmadı + %1$s için kanal kimliği çözülemedi + Mesaj gönderilemedi + Mesaj düşürüldü: %1$s (%2$s) + user:write:chat izni eksik, lütfen tekrar giriş yapın + Bu kanalda mesaj gönderme yetkiniz yok + Mesaj çok büyük + Hız sınırına ulaşıldı, biraz sonra tekrar deneyin + Gönderim başarısız: %1$s + + + %1$s komutunu kullanmak için giriş yapmış olmalısınız + Bu kullanıcı adıyla eşleşen kullanıcı bulunamadı. + Bilinmeyen bir hata oluştu. + Bu işlemi gerçekleştirme yetkiniz yok. + Gerekli izin eksik. Hesabınızla tekrar giriş yapın ve yeniden deneyin. + Giriş bilgileri eksik. Hesabınızla tekrar giriş yapın ve yeniden deneyin. + Kullanım: /block <kullanıcı> + %1$s kullanıcısını başarıyla engellediniz + %1$s kullanıcısı engellenemedi, bu isimde bir kullanıcı bulunamadı! + %1$s kullanıcısı engellenemedi, bilinmeyen bir hata oluştu! + Kullanım: /unblock <kullanıcı> + %1$s kullanıcısının engelini başarıyla kaldırdınız + %1$s kullanıcısının engeli kaldırılamadı, bu isimde bir kullanıcı bulunamadı! + %1$s kullanıcısının engeli kaldırılamadı, bilinmeyen bir hata oluştu! + Kanal yayında değil. + Yayın süresi: %1$s + Bu odada kullanabileceğiniz komutlar: %1$s + Kullanım: %1$s <kullanıcı adı> <mesaj>. + Fısıltı gönderildi. + Fısıltı gönderilemedi - %1$s + Kullanım: %1$s <mesaj> - Mesajınıza vurgulama ile dikkat çekin. + Duyuru gönderilemedi - %1$s + Bu kanalda moderatör bulunmuyor. + Bu kanalın moderatörleri: %1$s. + Moderatörler listelenemedi - %1$s + Kullanım: %1$s <kullanıcı adı> - Bir kullanıcıya moderatörlük yetkisi ver. + %1$s bu kanalın moderatörü olarak eklendi. + Kanal moderatörü eklenemedi - %1$s + Kullanım: %1$s <kullanıcı adı> - Bir kullanıcıdan moderatörlük yetkisini kaldır. + %1$s bu kanalın moderatörlerinden çıkarıldı. + Kanal moderatörü kaldırılamadı - %1$s + Bu kanalda VIP bulunmuyor. + Bu kanalın VIP\'leri: %1$s. + VIP\'ler listelenemedi - %1$s + Kullanım: %1$s <kullanıcı adı> - Bir kullanıcıya VIP statüsü ver. + %1$s bu kanalın VIP\'i olarak eklendi. + VIP eklenemedi - %1$s + Kullanım: %1$s <kullanıcı adı> - Bir kullanıcıdan VIP statüsünü kaldır. + %1$s bu kanalın VIP\'lerinden çıkarıldı. + VIP kaldırılamadı - %1$s + Kullanım: %1$s <kullanıcı adı> [sebep] - Bir kullanıcının sohbet etmesini kalıcı olarak engelle. Sebep isteğe bağlıdır ve hedef kullanıcıya ve diğer moderatörlere gösterilir. Yasağı kaldırmak için /unban kullanın. + Kullanıcı yasaklanamadı - Kendinizi yasaklayamazsınız. + Kullanıcı yasaklanamadı - Yayıncıyı yasaklayamazsınız. + Kullanıcı yasaklanamadı - %1$s + Kullanım: %1$s <kullanıcı adı> - Bir kullanıcının yasağını kaldırır. + Kullanıcının yasağı kaldırılamadı - %1$s + Kullanım: %1$s <kullanıcı adı> [süre][zaman birimi] [sebep] - Bir kullanıcının geçici olarak sohbet etmesini engelle. Süre (isteğe bağlı, varsayılan: 10 dakika) pozitif bir tam sayı olmalıdır; zaman birimi (isteğe bağlı, varsayılan: s) s, m, h, d, w\'den biri olmalıdır; maksimum süre 2 haftadır. Sebep isteğe bağlıdır ve hedef kullanıcıya ve diğer moderatörlere gösterilir. + Kullanıcı yasaklanamadı - Kendinize zaman aşımı uygulayamazsınız. + Kullanıcı yasaklanamadı - Yayıncıya zaman aşımı uygulayamazsınız. + Kullanıcıya zaman aşımı uygulanamadı - %1$s + Sohbet mesajları silinemedi - %1$s + Kullanım: /delete <msg-id> - Belirtilen mesajı siler. + Geçersiz msg-id: \"%1$s\". + Sohbet mesajları silinemedi - %1$s + Kullanım: /color <renk> - Renk, Twitch\'in desteklediği renklerden biri (%1$s) veya Turbo ya da Prime\'ınız varsa hex kodu (#000000) olmalıdır. + Renginiz %1$s olarak değiştirildi + Renk %1$s olarak değiştirilemedi - %2$s + %1$s%2$s konumunda yayın işareti başarıyla eklendi. + Yayın işareti oluşturulamadı - %1$s + Kullanım: /commercial <uzunluk> - Mevcut kanal için belirtilen sürede reklam başlatır. Geçerli uzunluk seçenekleri 30, 60, 90, 120, 150 ve 180 saniyedir. + + %1$d saniyelik reklam arası başlatılıyor. Hâlâ yayında olduğunuzu ve tüm izleyicilerin reklam almayacağını unutmayın. %2$d saniye sonra başka bir reklam çalıştırabilirsiniz. + %1$d saniyelik reklam arası başlatılıyor. Hâlâ yayında olduğunuzu ve tüm izleyicilerin reklam almayacağını unutmayın. %2$d saniye sonra başka bir reklam çalıştırabilirsiniz. + + Reklam başlatılamadı - %1$s + Kullanım: /raid <kullanıcı adı> - Bir kullanıcıyı baskınla. Yalnızca yayıncı baskın başlatabilir. + Geçersiz kullanıcı adı: %1$s + %1$s kullanıcısına baskın başlattınız. + Baskın başlatılamadı - %1$s + Baskını iptal ettiniz. + Baskın iptal edilemedi - %1$s + Kullanım: %1$s [süre] - Yalnızca takipçi modunu etkinleştirir (yalnızca takipçiler sohbet edebilir). Süre (isteğe bağlı, varsayılan: 0 dakika) pozitif bir sayı ve ardından zaman birimi (m, h, d, w) olmalıdır; maksimum süre 3 aydır. + Bu oda zaten %1$s yalnızca takipçi modunda. + Sohbet ayarları güncellenemedi - %1$s + Bu oda yalnızca takipçi modunda değil. + Bu oda zaten yalnızca emote modunda. + Bu oda yalnızca emote modunda değil. + Bu oda zaten yalnızca abone modunda. + Bu oda yalnızca abone modunda değil. + Bu oda zaten benzersiz sohbet modunda. + Bu oda benzersiz sohbet modunda değil. + Kullanım: %1$s [süre] - Yavaş modu etkinleştirir (kullanıcıların mesaj gönderme sıklığını sınırlar). Süre (isteğe bağlı, varsayılan: 30) pozitif bir saniye sayısı olmalıdır; maksimum 120. + Bu oda zaten %1$d saniyelik yavaş modda. + Bu oda yavaş modda değil. + Kullanım: %1$s <kullanıcı adı> - Belirtilen Twitch kullanıcısına tanıtım gönderir. + %1$s kullanıcısına tanıtım gönderildi + Tanıtım gönderilemedi - %1$s + Kalkan modu etkinleştirildi. + Kalkan modu devre dışı bırakıldı. + Kalkan modu güncellenemedi - %1$s + Kendinize fısıldayamazsınız. + Twitch kısıtlamaları nedeniyle fısıltı göndermek için doğrulanmış bir telefon numarasına sahip olmanız gerekiyor. Twitch ayarlarından telefon numarası ekleyebilirsiniz. https://www.twitch.tv/settings/security + Alıcı, yabancılardan veya doğrudan sizden gelen fısıltılara izin vermiyor. + Twitch tarafından hız sınırlamasına uğruyorsunuz. Birkaç saniye sonra tekrar deneyin. + Günde en fazla 40 benzersiz alıcıya fısıldayabilirsiniz. Günlük limit dahilinde saniyede en fazla 3, dakikada en fazla 100 fısıltı gönderebilirsiniz. + Twitch kısıtlamaları nedeniyle bu komut yalnızca yayıncı tarafından kullanılabilir. Lütfen bunun yerine Twitch web sitesini kullanın. + %1$s zaten bu kanalın moderatörü. + %1$s şu anda bir VIP, /unvip yapın ve bu komutu tekrar deneyin. + %1$s bu kanalın moderatörü değil. + %1$s bu kanalda yasaklı değil. + %1$s bu kanalda zaten yasaklı. + %2$s üzerinde %1$s işlemini gerçekleştiremezsiniz. + Bu kullanıcı üzerinde çakışan bir yasaklama işlemi vardı. Lütfen tekrar deneyin. + Renk, Twitch\'in desteklediği renklerden biri (%1$s) veya Turbo ya da Prime\'ınız varsa hex kodu (#000000) olmalıdır. + Reklam çalıştırmak için canlı yayında olmalısınız. + Başka bir reklam çalıştırabilmek için bekleme sürenizin dolmasını beklemelisiniz. + Komut, sıfırdan büyük istenen reklam arası uzunluğunu içermelidir. + Aktif bir baskınınız yok. + Bir kanal kendisine baskın yapamaz. + Yayıncı kendisine tanıtım yapamaz. + Yayıncı canlı yayında değil veya bir ya da daha fazla izleyicisi yok. + Süre geçerli aralığın dışında: %1$s. + Mesaj zaten işlenmiş. + Hedef mesaj bulunamadı. + Mesajınız çok uzundu. + Hız sınırlamasına uğruyorsunuz. Biraz sonra tekrar deneyin. + Hedef kullanıcı + Günlük görüntüleyici + Uygulama günlüklerini görüntüle + Günlükler + Günlükleri paylaş + Günlükleri görüntüle + Kullanılabilir günlük dosyası yok + Günlüklerde ara + + %1$d seçili + %1$d seçili + + Seçili günlükleri kopyala + Seçimi temizle + Çökme algılandı + Uygulama son oturumunuz sırasında çöktü. + İş parçacığı: %1$s + Kopyala + Sohbet raporu + #flex3rs kanalına katılır ve göndermek için bir çökme özeti hazırlar + E-posta raporu + Ayrıntılı bir çökme raporunu e-posta ile gönder + Çökme raporunu e-posta ile gönder + Aşağıdaki veriler rapora dahil edilecektir: + Yığın izleme + Mevcut günlük dosyasını dahil et + Çökme raporları + Son çökme raporlarını görüntüle + Çökme raporu bulunamadı + Çökme Raporu + Çökme raporunu paylaş + Sil + Bu çökme raporu silinsin mi? + Tümünü temizle + Tüm çökme raporları silinsin mi? + En alta kaydır + Yükleme tamamlandı: %1$s + Geçmişi görüntüle + Mevcut filtrelerle eşleşen mesaj yok diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index bd8e91a12..b780d3020 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -48,20 +48,76 @@ Від\'єднано Ви не ввійшли Відповісти - У Вас є нові згадки + Send announcement У Вас є нові згадки %1$s згадав Вас у #%2$s Вас згадали у #%1$s Ви увійшли під ім\'ям %1$s Помилка входу Скопійовано: %1$s + Завантаження завершено: %1$s Помилка при завантаженні Помилка при завантаженні: %1$s + Завантажити + Скопійовано до буфера обміну + Копіювати URL Повторити Смайли оновлені Помилка завантаження даних: %1$s Не вдалося завантажити дані через кількох помилок:\n%1$s + Значки DankChat + Глобальні значки + Глобальні FFZ-емоути + Глобальні BTTV-емоути + Глобальні 7TV-емоути + Значки каналу + FFZ-емоути + BTTV-емоути + 7TV-емоути + Twitch-емоути + Cheermote-и + Останні повідомлення + %1$s (%2$s) + + Перше повідомлення + Піднесене повідомлення + Гігантський емоут + Анімоване повідомлення + Використано: %1$s + + %1$d секунду + %1$d секунди + %1$d секунд + %1$d секунд + + + %1$d хвилину + %1$d хвилини + %1$d хвилин + %1$d хвилин + + + %1$d годину + %1$d години + %1$d годин + %1$d годин + + + %1$d день + %1$d дні + %1$d днів + %1$d днів + + + %1$d тиждень + %1$d тижні + %1$d тижнів + %1$d тижнів + + %1$s %2$s + %1$s %2$s %3$s Вставити Ім\'я каналу + Канал вже додано Останні Підписки Смайли каналу @@ -158,6 +214,8 @@ Додати команду Видалити команду Тригер + Цей тригер зарезервований вбудованою командою + Цей тригер вже використовується іншою командою Команда Користувацькі команди Поскаржитися @@ -196,14 +254,54 @@ Сповіщення Чат Загальні + Підказки + Повідомлення + Користувачі + Емоції та значки Про додаток Зовнішній вигляд DankChat %1$s зроблений @flex3rs та іншими учасниками Показувати поле введення Показувати поле для введення тексту повідомлень За замовчуванням системи - Чорна тема - Змінює колір фону чату на чорний + AMOLED темний режим + Чисто чорний фон для OLED-екранів + Колір акценту + Слідувати шпалерам системи + Синій + Бірюзовий + Зелений + Лаймовий + Жовтий + Помаранчевий + Червоний + Рожевий + Фіолетовий + Індиго + Коричневий + Сірий + Стиль кольору + Системний за замовчуванням + Використовувати стандартну кольорову палітру системи + Tonal Spot + Спокійні та приглушені кольори + Neutral + Майже монохромний, ледь помітний відтінок + Vibrant + Яскраві та насичені кольори + Expressive + Грайливі кольори зі зміщеними відтінками + Rainbow + Широкий спектр відтінків + Fruit Salad + Грайлива багатокольорова палітра + Monochrome + Лише чорний, білий та сірий + Fidelity + Вірний кольору акценту + Content + Колір акценту з аналоговим третинним + Більше стилів Дисплей Елементи Показувати видалені повідомлення @@ -215,8 +313,19 @@ Маленький Великий Величезний - Підказки імен користувачів та смайлів - Показувати підказки для імен користувачів та назв смайлів + Підказки + Оберіть, які підказки показувати під час введення + Емоції + Користувачі + Команди Twitch + Команди Supibot + Активувати за допомогою : + Активувати за допомогою @ + Активувати за допомогою / + Активувати за допомогою $ + Режим підказок + Пропонувати збіги під час введення + Пропонувати лише після символу-тригера Завантажувати історію повідомлень при запуску Завантаження історії повідомлень після повторного підключення Спроби отримати пропущені повідомлення, які не були отримані під час з\'єднання, обриваються @@ -226,7 +335,7 @@ Дані каналу Налаштування розробника Режим відлагодження - Надає інформацію про помилки + Показувати дію аналітики налагодження в панелі введення та збирати звіти про збої локально Формат часових міток Увімкнути синтезатор мовлення Зачитує повідомлення в активному чаті @@ -240,6 +349,9 @@ Ігнорувати URL-адреси Ігнорує емоції та емодзі в TTS Ігнорувати емоції + Гучність + Приглушення звуку + Зменшувати гучність інших додатків під час озвучення Синтезатор мовлення Строкаті лінії Відокремлювати кожну строку з різною яскравістю фону @@ -252,12 +364,19 @@ Поведінка при довгому натиску на ім\'я користувача Один натиск відкриває попап, довгий натиск відкриває згадування Один натиск відкриває згадування, довгий натиск відкриває попап + Розфарбовувати псевдоніми + Призначити випадковий колір користувачам без встановленого кольору Зробити англійську мовою синтезатора Використання англійської мови для синтезу мовлення замість мови системи Видимі сторонні смайли Умови обслуговування Twitch Показувати плаваючі кнопки Показує кнопки для перемикання повноекранного режиму та плеєру і змінення режимів чату + Показувати лічильник символів + Відображає кількість кодових точок у полі введення + Показувати кнопку очищення введення + Показувати кнопку надсилання + Введення Сервіс завантаження медіа Налаштувати сервіс завантаження медіа Останні завантаження @@ -286,6 +405,9 @@ Користувацький логін Обробка команд обходу Twitch Вимикає перехоплення команд Twitch і надсилає їх до чату + Протокол надсилання чату + Використовувати Helix API для надсилання + Надсилати повідомлення чату через Twitch Helix API замість IRC Оновлення емоцій в прямому ефірі 7TV Живі емоти оновлюють фонову поведінку Оновлення припиняються після %1$s.\nЗменшення цього числа може збільшити час автономної роботи. @@ -330,6 +452,7 @@ Ваше ім’я користувача Підписки та події Оголошення + Серії переглядів Перші повідомлення Піднесені повідомлення Виділення куплені балами каналу @@ -355,9 +478,22 @@ Скопіювати повідомлення Скопіювати повне повідомлення Відповісти на повідомлення + Відповісти на початкове повідомлення Переглянути тему Скопіювати ідентифікатор повідомлення Більше… + Перейти до повідомлення + Повідомлення більше немає в історії чату + Історія повідомлень + Глобальна історія + Історія: %1$s + Пошук повідомлень… + Фільтр за іменем користувача + Повідомлення з посиланнями + Повідомлення з емоутами + Фільтр за назвою значка + Користувач + Значок Відповісти @%1$s Тема відповіді не знайдена Повідомлення не знайдено @@ -403,4 +539,409 @@ %d місяців %d місяців + Перемкнути панель додатку + Помилка: %s + Вийти? + Видалити цей канал? + Видалити канал \"%1$s\"? + Заблокувати канал \"%1$s\"? + Забанити цього користувача? + Видалити це повідомлення? + Очистити чат? + Налаштовувані дії для швидкого доступу до пошуку, трансляцій та іншого + Натисніть тут для додаткових дій та налаштування панелі дій + Тут ви можете налаштувати, які дії відображаються на панелі дій + Проведіть вниз по полю введення, щоб швидко сховати його + Натисніть тут, щоб повернути поле введення + Далі + Зрозуміло + Пропустити тур + Тут ви можете додати більше каналів + + + Повідомлення затримано з причини: %1$s. Дозвіл опублікує його в чаті. + Дозволити + Відхилити + Схвалено + Відхилено + Термін минув + Гей! Твоє повідомлення перевіряється модераторами і ще не надіслане. + Модератори прийняли твоє повідомлення. + Модератори відхилили твоє повідомлення. + %1$s (рівень %2$d) + + збігається з %1$d заблокованим терміном %2$s + збігається з %1$d заблокованими термінами %2$s + збігається з %1$d заблокованими термінами %2$s + збігається з %1$d заблокованими термінами %2$s + + Не вдалося %1$s повідомлення AutoMod - повідомлення вже оброблено. + Не вдалося %1$s повідомлення AutoMod - потрібно повторно авторизуватися. + Не вдалося %1$s повідомлення AutoMod - у вас немає дозволу на цю дію. + Не вдалося %1$s повідомлення AutoMod - цільове повідомлення не знайдено. + Не вдалося %1$s повідомлення AutoMod - сталася невідома помилка. + %1$s додав %2$s як заблокований термін у AutoMod. + %1$s додав %2$s як дозволений термін у AutoMod. + %1$s видалив %2$s як заблокований термін з AutoMod. + %1$s видалив %2$s як дозволений термін з AutoMod. + + + + Вас було заглушено на %1$s + Вас було заглушено на %1$s модератором %2$s + Вас було заглушено на %1$s модератором %2$s: %3$s + %1$s заглушив %2$s на %3$s + %1$s заглушив %2$s на %3$s: %4$s + %1$s було заглушено на %2$s + Вас було забанено + Вас було забанено модератором %1$s + Вас було забанено модератором %1$s: %2$s + %1$s забанив %2$s + %1$s забанив %2$s: %3$s + %1$s було перманентно забанено + %1$s зняв заглушення з %2$s + %1$s розбанив %2$s + %1$s призначив модератором %2$s + %1$s зняв модератора з %2$s + %1$s додав %2$s як VIP цього каналу + %1$s видалив %2$s як VIP цього каналу + %1$s попередив %2$s + %1$s попередив %2$s: %3$s + %1$s розпочав рейд на %2$s + %1$s скасував рейд на %2$s + %1$s видалив повідомлення від %2$s + %1$s видалив повідомлення від %2$s з текстом: %3$s + Повідомлення від %1$s було видалено + Повідомлення від %1$s було видалено з текстом: %2$s + %1$s очистив чат + Чат було очищено модератором + %1$s увімкнув режим лише емоції + %1$s вимкнув режим лише емоції + %1$s увімкнув режим лише для підписників каналу + %1$s увімкнув режим лише для підписників каналу (%2$s) + %1$s вимкнув режим лише для підписників каналу + %1$s увімкнув режим унікального чату + %1$s вимкнув режим унікального чату + %1$s увімкнув повільний режим + %1$s увімкнув повільний режим (%2$s) + %1$s вимкнув повільний режим + %1$s увімкнув режим лише для підписників + %1$s вимкнув режим лише для підписників + %1$s заглушив %2$s на %3$s у %4$s + %1$s заглушив %2$s на %3$s у %4$s: %5$s + %1$s зняв заглушення з %2$s у %3$s + %1$s забанив %2$s у %3$s + %1$s забанив %2$s у %3$s: %4$s + %1$s розбанив %2$s у %3$s + %1$s видалив повідомлення від %2$s у %3$s + %1$s видалив повідомлення від %2$s у %3$s з текстом: %4$s + %1$s%2$s + + \u0020(%1$d раз) + \u0020(%1$d рази) + \u0020(%1$d разів) + \u0020(%1$d разів) + + + + Видалити + Надіслати шепіт + Шепіт до @%1$s + Новий шепіт + Надіслати шепіт до + Ім\'я користувача + Надіслати + + + Лише емоути + Лише підписники + Повільний режим + Повільний режим (%1$s) + Унікальний чат (R9K) + Лише фоловери + Лише фоловери (%1$s) + Інше + Будь-який + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + + Активувати режим щита? + Це застосує попередньо налаштовані параметри безпеки каналу, які можуть включати обмеження чату, налаштування AutoMod та вимоги верифікації. + Активувати Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel + + + Додайте канал, щоб почати спілкування + Немає нещодавніх емоутів + + + Показати трансляцію + Сховати трансляцію + Лише аудіо + Вийти з режиму аудіо + На весь екран + Вийти з повноекранного режиму + Сховати введення + Показати введення + Навігація каналів свайпом + Перемикання каналів свайпом по чату + Модерація каналу + + + Пошук повідомлень + Останнє повідомлення + Перемкнути трансляцію + Модерація каналу + На весь екран + Сховати введення + Налаштувати дії + Налагодження + + Максимум %1$d дія + Максимум %1$d дії + Максимум %1$d дій + Максимум %1$d дій + + + + DankChat + Давайте все налаштуємо. + Увійти через Twitch + Увійдіть, щоб надсилати повідомлення, використовувати свої емоути, отримувати шепіт та розблокувати всі функції. + Вам буде запропоновано надати кілька дозволів Twitch одразу, щоб вам не довелося повторно авторизуватися при використанні різних функцій. DankChat виконує дії з модерації та керування трансляцією лише за вашим запитом. + Увійти через Twitch + Вхід виконано + Сповіщення + DankChat може сповіщувати вас, коли хтось згадує вас у чаті, поки застосунок працює у фоні. + Дозволити сповіщення + Відкрити налаштування сповіщень + Без сповіщень ви не дізнаєтесь, коли хтось згадує вас у чаті, поки застосунок працює у фоні. + Історія повідомлень + DankChat завантажує історичні повідомлення зі стороннього сервісу при запуску. Для отримання повідомлень DankChat надсилає назви відкритих каналів цьому сервісу. Сервіс тимчасово зберігає повідомлення відвіданих каналів.\n\nВи можете змінити це пізніше в налаштуваннях або дізнатися більше на https://recent-messages.robotty.de/ + Увімкнути + Вимкнути + Продовжити + Почати + Пропустити + + + Загальне + Авторизація + Увімкнути Twitch EventSub + Використовує EventSub для різних подій у реальному часі замість застарілого PubSub + Увімкнути налагоджувальний вивід EventSub + Виводить налагоджувальну інформацію щодо EventSub як системні повідомлення + Відкликати токен і перезапустити + Анулює поточний токен і перезапускає застосунок + Не виконано вхід + Не вдалося визначити ID каналу для %1$s + Повідомлення не було надіслано + Повідомлення відхилено: %1$s (%2$s) + Відсутній дозвіл user:write:chat, будь ласка, увійдіть знову + Немає дозволу надсилати повідомлення в цьому каналі + Повідомлення занадто велике + Перевищено ліміт запитів, спробуйте через деякий час + Помилка надсилання: %1$s + + + Потрібно увійти в обліковий запис, щоб використовувати команду %1$s + Користувача з таким іменем не знайдено. + Сталася невідома помилка. + У вас немає дозволу на виконання цієї дії. + Відсутній необхідний дозвіл. Увійдіть знову та спробуйте ще раз. + Відсутні дані для входу. Увійдіть знову та спробуйте ще раз. + Використання: /block <користувач> + Ви успішно заблокували користувача %1$s + Не вдалося заблокувати користувача %1$s, користувача з таким іменем не знайдено! + Не вдалося заблокувати користувача %1$s, сталася невідома помилка! + Використання: /unblock <користувач> + Ви успішно розблокували користувача %1$s + Не вдалося розблокувати користувача %1$s, користувача з таким іменем не знайдено! + Не вдалося розблокувати користувача %1$s, сталася невідома помилка! + Канал не в ефірі. + Час в ефірі: %1$s + Доступні команди в цій кімнаті: %1$s + Використання: %1$s <ім\'я користувача> <повідомлення>. + Особисте повідомлення надіслано. + Не вдалося надіслати особисте повідомлення - %1$s + Використання: %1$s <повідомлення> - Зверніть увагу на своє повідомлення за допомогою виділення. + Не вдалося надіслати оголошення - %1$s + На цьому каналі немає модераторів. + Модератори цього каналу: %1$s. + Не вдалося отримати список модераторів - %1$s + Використання: %1$s <ім\'я користувача> - Надати користувачу статус модератора. + Ви додали %1$s як модератора цього каналу. + Не вдалося додати модератора каналу - %1$s + Використання: %1$s <ім\'я користувача> - Відкликати статус модератора у користувача. + Ви видалили %1$s з модераторів цього каналу. + Не вдалося видалити модератора каналу - %1$s + На цьому каналі немає VIP. + VIP цього каналу: %1$s. + Не вдалося отримати список VIP - %1$s + Використання: %1$s <ім\'я користувача> - Надати користувачу статус VIP. + Ви додали %1$s як VIP цього каналу. + Не вдалося додати VIP - %1$s + Використання: %1$s <ім\'я користувача> - Відкликати статус VIP у користувача. + Ви видалили %1$s з VIP цього каналу. + Не вдалося видалити VIP - %1$s + Використання: %1$s <ім\'я користувача> [причина] - Назавжди заборонити користувачу писати в чат. Причина необов\'язкова і буде показана цільовому користувачу та іншим модераторам. Використовуйте /unban для зняття бану. + Не вдалося забанити користувача - Ви не можете забанити себе. + Не вдалося забанити користувача - Ви не можете забанити стрімера. + Не вдалося забанити користувача - %1$s + Використання: %1$s <ім\'я користувача> - Знімає бан з користувача. + Не вдалося розбанити користувача - %1$s + Використання: %1$s <ім\'я користувача> [тривалість][одиниця часу] [причина] - Тимчасово заборонити користувачу писати в чат. Тривалість (необов\'язково, за замовчуванням: 10 хвилин) має бути додатним цілим числом; одиниця часу (необов\'язково, за замовчуванням: s) має бути s, m, h, d або w; максимальна тривалість — 2 тижні. Причина необов\'язкова і буде показана цільовому користувачу та іншим модераторам. + Не вдалося забанити користувача - Ви не можете дати тайм-аут самому собі. + Не вдалося забанити користувача - Ви не можете дати тайм-аут стрімеру. + Не вдалося дати тайм-аут користувачу - %1$s + Не вдалося видалити повідомлення чату - %1$s + Використання: /delete <msg-id> - Видаляє вказане повідомлення. + Недійсний msg-id: \"%1$s\". + Не вдалося видалити повідомлення чату - %1$s + Використання: /color <колір> - Колір має бути одним з підтримуваних Twitch кольорів (%1$s) або hex code (#000000), якщо у вас є Turbo або Prime. + Ваш колір було змінено на %1$s + Не вдалося змінити колір на %1$s - %2$s + Маркер стріму успішно додано на %1$s%2$s. + Не вдалося створити маркер стріму - %1$s + Використання: /commercial <тривалість> - Запускає рекламу вказаної тривалості для поточного каналу. Допустимі значення: 30, 60, 90, 120, 150 та 180 секунд. + + Запуск рекламної паузи тривалістю %1$d секунд. Пам\'ятайте, що ви все ще в ефірі і не всі глядачі отримають рекламу. Ви зможете запустити наступну рекламу через %2$d секунд. + Запуск рекламної паузи тривалістю %1$d секунд. Пам\'ятайте, що ви все ще в ефірі і не всі глядачі отримають рекламу. Ви зможете запустити наступну рекламу через %2$d секунд. + Запуск рекламної паузи тривалістю %1$d секунд. Пам\'ятайте, що ви все ще в ефірі і не всі глядачі отримають рекламу. Ви зможете запустити наступну рекламу через %2$d секунд. + Запуск рекламної паузи тривалістю %1$d секунд. Пам\'ятайте, що ви все ще в ефірі і не всі глядачі отримають рекламу. Ви зможете запустити наступну рекламу через %2$d секунд. + + Не вдалося запустити рекламу - %1$s + Використання: /raid <ім\'я користувача> - Зробити рейд на користувача. Тільки стрімер може розпочати рейд. + Недійсне ім\'я користувача: %1$s + Ви розпочали рейд на %1$s. + Не вдалося розпочати рейд - %1$s + Ви скасували рейд. + Не вдалося скасувати рейд - %1$s + Використання: %1$s [тривалість] - Вмикає режим «тільки для підписників» (лише підписники можуть писати в чат). Тривалість (необов\'язково, за замовчуванням: 0 хвилин) має бути додатним числом з одиницею часу (m, h, d, w); максимальна тривалість — 3 місяці. + Ця кімната вже в режимі «тільки для підписників» %1$s. + Не вдалося оновити налаштування чату - %1$s + Ця кімната не в режимі «тільки для підписників». + Ця кімната вже в режимі «тільки емоути». + Ця кімната не в режимі «тільки емоути». + Ця кімната вже в режимі «тільки для підписників каналу». + Ця кімната не в режимі «тільки для підписників каналу». + Ця кімната вже в режимі унікального чату. + Ця кімната не в режимі унікального чату. + Використання: %1$s [тривалість] - Вмикає повільний режим (обмежує частоту надсилання повідомлень). Тривалість (необов\'язково, за замовчуванням: 30) має бути додатним числом секунд; максимум 120. + Ця кімната вже в повільному режимі (%1$d сек.). + Ця кімната не в повільному режимі. + Використання: %1$s <ім\'я користувача> - Надсилає шаут-аут вказаному користувачу Twitch. + Шаут-аут надіслано %1$s + Не вдалося надіслати шаут-аут - %1$s + Режим щита активовано. + Режим щита деактивовано. + Не вдалося оновити режим щита - %1$s + Ви не можете надсилати особисті повідомлення самому собі. + Через обмеження Twitch тепер для надсилання особистих повідомлень потрібен підтверджений номер телефону. Ви можете додати номер телефону в налаштуваннях Twitch. https://www.twitch.tv/settings/security + Одержувач не приймає особисті повідомлення від незнайомців або від вас безпосередньо. + Twitch обмежив частоту ваших запитів. Спробуйте через кілька секунд. + Ви можете надсилати особисті повідомлення максимум 40 унікальним одержувачам на день. В межах денного ліміту ви можете надсилати максимум 3 особистих повідомлення на секунду та максимум 100 особистих повідомлень на хвилину. + Через обмеження Twitch ця команда доступна лише стрімеру. Будь ласка, скористайтеся веб-сайтом Twitch. + %1$s вже є модератором цього каналу. + %1$s наразі є VIP, використайте /unvip і повторіть цю команду. + %1$s не є модератором цього каналу. + %1$s не забанений на цьому каналі. + %1$s вже забанений на цьому каналі. + Ви не можете %1$s %2$s. + Відбулася конфліктуюча операція бану для цього користувача. Будь ласка, спробуйте знову. + Колір має бути одним з підтримуваних Twitch кольорів (%1$s) або hex code (#000000), якщо у вас є Turbo або Prime. + Ви повинні вести пряму трансляцію для запуску реклами. + Потрібно дочекатися закінчення періоду очікування, перш ніж запускати наступну рекламу. + Команда повинна включати бажану тривалість рекламної паузи більше нуля. + У вас немає активного рейду. + Канал не може зробити рейд на самого себе. + Стрімер не може дати Shoutout самому собі. + Стрімер не веде трансляцію або не має одного чи більше глядачів. + Тривалість поза допустимим діапазоном: %1$s. + Повідомлення вже було оброблено. + Цільове повідомлення не знайдено. + Ваше повідомлення було занадто довгим. + Частоту ваших запитів обмежено. Спробуйте через мить. + Цільовий користувач + Перегляд журналів + Переглянути журнали застосунку + Журнали + Поділитися журналами + Переглянути журнали + Файли журналів відсутні + Пошук у журналах + + Вибрано: %1$d + Вибрано: %1$d + Вибрано: %1$d + Вибрано: %1$d + + Копіювати вибрані журнали + Скасувати вибір + Виявлено збій + Додаток аварійно завершився під час останнього сеансу. + Потік: %1$s + Копіювати + Звіт у чат + Приєднується до #flex3rs і готує зведення збою для відправки + Звіт електронною поштою + Надіслати детальний звіт про збій електронною поштою + Надіслати звіт про збій електронною поштою + Наступні дані будуть включені у звіт: + Стек викликів + Включити поточний файл журналу + Звіти про збої + Переглянути останні звіти про збої + Звітів про збої не знайдено + Звіт про збій + Поділитися звітом про збій + Видалити + Видалити цей звіт про збій? + Очистити все + Видалити всі звіти про збої? + Прокрутити донизу + Значок + Адміністратор + Стрімер + Засновник + Головний модератор + Модератор + Співробітник + Підписник + Підтверджений + VIP + Значки + Створюйте сповіщення та виділяйте повідомлення користувачів на основі значків. + Обрати колір + Обрати власний колір виділення + За замовчуванням + Ліцензії відкритого коду + Показувати категорію трансляції + Також відображати категорію трансляції + Спільний чат + + Наживо з %1$d глядачем у %2$s протягом %3$s + Наживо з %1$d глядачами у %2$s протягом %3$s + Наживо з %1$d глядачами у %2$s протягом %3$s + Наживо з %1$d глядачами у %2$s протягом %3$s + + Переглянути історію + Немає повідомлень, що відповідають поточним фільтрам diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index ba671c0c8..000000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - #34ae0a - - #00FFFFFF - #cca0a0a0 - - #D1C4E9 - - - #EF9A9A - - - #93f1ff - - - #c2f18d - - - #ffe087 - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 5f97f418f..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - -0.03 - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index 775e7c154..f2589359f 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,5 @@ #696969 - \ No newline at end of file + #131314 + diff --git a/app/src/main/res/values/roomstate_entries.xml b/app/src/main/res/values/roomstate_entries.xml deleted file mode 100644 index 307455975..000000000 --- a/app/src/main/res/values/roomstate_entries.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - @string/roomstate_emote - @string/roomstate_subs - @string/roomstate_slow - @string/roomstate_r9k - @string/roomstate_follow - - \ 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 405ac596e..eeb716dd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,8 +24,10 @@ Remove channel Channel blocked No channels added + Add a channel to start chatting Confirm logout Are you sure you want to logout? + Log out? Logout Upload media Take picture @@ -43,26 +45,79 @@ FeelsDankMan DankChat running in the background Open the emote menu + Close the emote menu + No recent emotes + Emotes Login to Twitch.tv Start chatting Disconnected Not logged in Reply + Send announcement You have new mentions %1$s just mentioned you in #%2$s You were mentioned in #%1$s Logging in as %1$s Failed to login Copied: %1$s + Upload complete: %1$s Error during upload Error during upload: %1$s + Upload + Copied to clipboard + Copy URL Retry Emotes reloaded Data loading failed: %1$s Data loading failed with multiple errors:\n%1$s + DankChat Badges + Global Badges + Global FFZ Emotes + Global BTTV Emotes + Global 7TV Emotes + Channel Badges + FFZ Emotes + BTTV Emotes + 7TV Emotes + Twitch Emotes + Cheermotes + Recent Messages + %1$s (%2$s) + + First Time Chat + Elevated Chat + Gigantified Emote + Animated Message + Redeemed %1$s + + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + + %1$d week + %1$d weeks + + %1$s %2$s + %1$s %2$s %3$s + Paste Channel name + Channel is already added Recent + Backspace Subs Channel Global @@ -82,6 +137,110 @@ %1$s added 7TV Emote %2$s. %1$s renamed 7TV Emote %2$s to %3$s. %1$s removed 7TV Emote %2$s. + + Not logged in + Could not resolve channel ID for %1$s + Message was not sent + Message dropped: %1$s (%2$s) + Missing user:write:chat scope, please re-login + Not authorized to send messages in this channel + Message is too large + Rate limited, try again in a moment + Send failed: %1$s + + + Held a message for reason: %1$s. Allow will post it in chat. + Allow + Deny + Approved + Denied + Expired + Hey! Your message is being checked by mods and has not been sent. + Mods have accepted your message. + Mods have denied your message. + %1$s (level %2$d) + + matches %1$d blocked term %2$s + matches %1$d blocked terms %2$s + + Failed to %1$s AutoMod message - message has already been processed. + Failed to %1$s AutoMod message - you need to re-authenticate. + Failed to %1$s AutoMod message - you don\'t have permission to perform that action. + Failed to %1$s AutoMod message - target message not found. + Failed to %1$s AutoMod message - an unknown error occurred. + %1$s added %2$s as a blocked term on AutoMod. + %1$s added %2$s as a permitted term on AutoMod. + %1$s removed %2$s as a blocked term on AutoMod. + %1$s removed %2$s as a permitted term on AutoMod. + + + + You were timed out for %1$s + + You were timed out for %1$s by %2$s + You were timed out for %1$s by %2$s: %3$s + + %1$s timed out %2$s for %3$s + %1$s timed out %2$s for %3$s: %4$s + + %1$s has been timed out for %2$s + + You were banned + You were banned by %1$s + You were banned by %1$s: %2$s + + %1$s banned %2$s + %1$s banned %2$s: %3$s + %1$s has been permanently banned + + %1$s untimedout %2$s + %1$s unbanned %2$s + %1$s modded %2$s + %1$s unmodded %2$s + %1$s has added %2$s as a VIP of this channel + %1$s has removed %2$s as a VIP of this channel + %1$s has warned %2$s + %1$s has warned %2$s: %3$s + %1$s initiated a raid to %2$s + %1$s canceled the raid to %2$s + + %1$s deleted message from %2$s + %1$s deleted message from %2$s saying: %3$s + A message from %1$s was deleted + A message from %1$s was deleted saying: %2$s + + %1$s cleared the chat + Chat has been cleared by a moderator + + %1$s turned on emote-only mode + %1$s turned off emote-only mode + %1$s turned on followers-only mode + %1$s turned on followers-only mode (%2$s) + %1$s turned off followers-only mode + %1$s turned on unique-chat mode + %1$s turned off unique-chat mode + %1$s turned on slow mode + %1$s turned on slow mode (%2$s) + %1$s turned off slow mode + %1$s turned on subscribers-only mode + %1$s turned off subscribers-only mode + + %1$s timed out %2$s for %3$s in %4$s + %1$s timed out %2$s for %3$s in %4$s: %5$s + %1$s untimedout %2$s in %3$s + %1$s banned %2$s in %3$s + %1$s banned %2$s in %3$s: %4$s + %1$s unbanned %2$s in %3$s + %1$s deleted message from %2$s in %3$s + %1$s deleted message from %2$s in %3$s saying: %4$s + + %1$s%2$s + + + \u0020(%1$d time) + \u0020(%1$d times) + + < Message deleted > Regex Add an entry @@ -99,9 +258,12 @@ Confirm channel removal Are you sure you want to remove this channel? Are you sure you want to remove channel \"%1$s\"? + Remove this channel? + Remove channel \"%1$s\"? Remove Confirm channel block Are you sure you want to block channel \"%1$s\"? + Block channel \"%1$s\"? Block Unblock Mention user @@ -125,6 +287,62 @@ You can set a custom host for uploading media, like imgur.com or s-ul.eu. DankChat uses the same configuration format as Chatterino.\nCheck this guide for help: https://wiki.chatterino.com/Image%20Uploader/ Toggle fullscreen Toggle stream + Show stream + Hide stream + Audio only + Exit audio only + Fullscreen + Exit fullscreen + Hide input + Show input + Channel swipe navigation + Switch channels by swiping on the chat + Channel moderation + + Maximum of %1$d action + Maximum of %1$d actions + + Search messages + Last message + Toggle stream + Channel moderation + Fullscreen + Hide input + Debug + Configure actions + Emote only + Subscriber only + Slow mode + Slow mode (%1$s) + Unique chat (R9K) + Follower only + Follower only (%1$s) + Custom + Any + %1$ds + %1$dm + %1$dh + %1$dd + %1$dw + %1$dmo + Room state + Mod actions + Broadcaster + Shield mode + Clear chat + Clear all messages in this channel? + Activate Shield Mode? + This will apply the channel\'s pre-configured safety settings, which may include chat restrictions, AutoMod overrides, and chat verification requirements. + Activate + Announce + Announcement + Shoutout + Commercial + Raid + Cancel raid + Stream marker + Username + Channel Account Login again Logout @@ -135,12 +353,15 @@ Chat modes Confirm ban Are you sure you want to ban this user? + Ban this user? Ban Confirm timeout Timeout Confirm message deletion Are you sure you want to delete this message? + Delete this message? Delete + Clear chat? Update chat modes Emote only Subscriber only @@ -152,6 +373,8 @@ Add a command Remove the command Trigger + This trigger is reserved by a built-in command + This trigger is already used by another command Command Custom commands Report @@ -197,6 +420,13 @@ Notifications Chat General + Suggestions + Messages + Users + Emotes & Badges + Suggestion mode + Suggest matches as you type + Only suggest after a trigger character About Appearance DankChat %1$s made by @flex3rs and contributors @@ -208,8 +438,44 @@ follow_system_mode Follow system default true_dark_mode - True dark theme - Forces chat background color to black + Amoled dark mode + Pure black backgrounds for OLED screens + Accent color + Follow system wallpaper + Blue + Teal + Green + Lime + Yellow + Orange + Red + Pink + Purple + Indigo + Brown + Gray + Color style + System default + Use the default system color palette + Tonal Spot + Calm and subdued colors + Neutral + Nearly monochrome, subtle tint + Vibrant + Bold and saturated colors + Expressive + Playful colors with shifted hues + Rainbow + Broad spectrum of hues + Fruit Salad + Playful, multi-colored palette + Monochrome + Black, white, and gray only + Fidelity + Stays true to the accent color + Content + Accent color with analogous tertiary + More styles Display Components show_timed_out_messages_key @@ -227,8 +493,16 @@ Large Very large suggestions_key - Emote and user suggestions - Shows suggestions for emotes and active users while typing + Suggestions + Choose which suggestions to show while typing + Emotes + Trigger with : + Users + Trigger with @ + Twitch commands + Trigger with / + Supibot commands + Trigger with $ Load message history on start load_message_history_key Load message history after a reconnect @@ -240,8 +514,10 @@ Channel data Developer options debug_mode_key + Log viewer + View application logs Debug mode - Provides information for any exceptions that have been caught + Show debug analytics action in input bar and collect crash reports locally timestamp_format_key Timestamp format tts_key @@ -261,6 +537,9 @@ Ignores emotes and emojis in TTS tts_message_ignore_emote Ignore emotes + Volume + Audio ducking + Lower other audio volume while TTS is speaking TTS checkered_messages Checkered Lines @@ -280,6 +559,8 @@ User long click behavior Regular click opens popup, long click mentions Regular click mentions, long click opens popup + Colorize nicknames + Assign a random color to users without a set color tts_force_english_key Force language to English Force TTS voice language to English instead of system default @@ -290,6 +571,11 @@ Show chip actions show_chip_actions_key Displays chips for toggling fullscreen, streams and adjusting chat modes + Show character counter + Displays code point count in the input field + Show clear input button + Show send button + Input Media uploader Configure uploader Recent uploads @@ -306,6 +592,20 @@ Disables filtering of unapproved or unlisted emotes rm_host_key Custom recent messages host + General + Twitch + Auth + Enable Twitch EventSub + Uses EventSub for various real-time events instead of deprecated PubSub + Enable EventSub debug output + Prints debug output related to EventSub as system messages + Revoke token and restart + Invalidates the current token and restarts the app + Onboarding + Reset onboarding + Clears onboarding completion, shows onboarding flow on next restart + Reset feature tour + Clears feature tour and toolbar hint progress fetch_streams_key Fetch stream information Periodically fetches stream information of open channels. Required to start embedded stream. @@ -327,6 +627,9 @@ bypass_command_handling_key Bypass Twitch command handling Disables intercepting of Twitch commands and sends them to chat instead + Chat send protocol + Use Helix API for sending + Send chat messages via Twitch Helix API instead of IRC 7tv_live_updates_key 7TV live emote updates 7TV @@ -384,6 +687,7 @@ Your username Subscriptions and Events Announcements + Watch Streaks First Messages Elevated Messages Highlights redeemed with Channel Points @@ -411,10 +715,65 @@ Copy message Copy full message Reply to message + Reply to original message View thread Copy message id More… + Jump to message + Message no longer in chat history + Message history + Global History + History: %1$s + View history + No messages match the current filters + Search messages… + + Logs + Share logs + View logs + No log files available + Search logs + + %1$d selected + %1$d selected + + Copy selected logs + Clear selection + Crash detected + The app crashed during your last session. + Thread: %1$s + Copy + Chat report + Joins #flex3rs and prepares a crash summary to send + Email report + Send a detailed crash report via email + Send crash report via email + The following data will be included in the report: + Stack trace + Include current log file + Crash reports + View recent crash reports + No crash reports found + Crash Report + Share crash report + Delete crash report + Clear all + Delete this crash report? + Delete all crash reports? + Scroll to bottom + Filter by username + Messages containing links + Messages containing emotes + Filter by badge name + User + Badge Replying to @%1$s + Whispering @%1$s + Send a whisper + New whisper + Send whisper to + Username + Start Reply thread not found Message not found Use emote @@ -470,4 +829,157 @@ Pick custom highlight color Default Choose Color + Toggle App Bar + Error: %s + + + DankChat + Let\'s get you set up. + Get Started + Login with Twitch + Log in to send messages, use your emotes, receive whispers, and unlock all features. + You will be asked to grant several Twitch permissions at once so you won\'t need to re-authorize when using different features. DankChat only performs moderation and stream actions when you ask it to. + Login with Twitch + Login successful + Skip + Continue + Message History + DankChat loads historical messages from a third-party service on startup. To get the messages, DankChat sends the names of the channels you have open to that service. The service temporarily stores messages for channels you (and others) visit to provide the service.\n\nYou can change this later in the settings or learn more at https://recent-messages.robotty.de/ + Enable + Disable + Notifications + DankChat can notify you when someone mentions you in chat while the app is in the background. + Allow Notifications + Without notifications, you won\'t know when someone mentions you in chat while the app is in the background. + Open Notification Settings + + + Customizable actions for quick access to search, streams, and more + Tap here for more actions and to configure your action bar + You can customize which actions appear in your action bar here + Swipe down on the input to quickly hide it + Tap here to bring the input back + Next + Got it + Skip tour + You can add more channels here + + + You must be logged in to use the %1$s command + No user matching that username. + An unknown error has occurred. + You don\'t have permission to perform that action. + Missing required scope. Re-login with your account and try again. + Missing login credentials. Re-login with your account and try again. + Usage: /block <user> + You successfully blocked user %1$s + User %1$s couldn\'t be blocked, no user with that name found! + User %1$s couldn\'t be blocked, an unknown error occurred! + Usage: /unblock <user> + You successfully unblocked user %1$s + User %1$s couldn\'t be unblocked, no user with that name found! + User %1$s couldn\'t be unblocked, an unknown error occurred! + Channel is not live. + Uptime: %1$s + Commands available to you in this room: %1$s + Usage: %1$s <username> <message>. + Whisper sent. + Failed to send whisper - %1$s + Usage: %1$s <message> - Call attention to your message with a highlight. + Failed to send announcement - %1$s + This channel does not have any moderators. + The moderators of this channel are %1$s. + Failed to list moderators - %1$s + Usage: %1$s <username> - Grant moderation status to a user. + You have added %1$s as a moderator of this channel. + Failed to add channel moderator - %1$s + Usage: %1$s <username> - Revoke moderation status from a user. + You have removed %1$s as a moderator of this channel. + Failed to remove channel moderator - %1$s + This channel does not have any VIPs. + The VIPs of this channel are %1$s. + Failed to list VIPs - %1$s + Usage: %1$s <username> - Grant VIP status to a user. + You have added %1$s as a VIP of this channel. + Failed to add VIP - %1$s + Usage: %1$s <username> - Revoke VIP status from a user. + You have removed %1$s as a VIP of this channel. + Failed to remove VIP - %1$s + Usage: %1$s <username> [reason] - Permanently prevent a user from chatting. Reason is optional and will be shown to the target user and other moderators. Use /unban to remove a ban. + Failed to ban user - You cannot ban yourself. + Failed to ban user - You cannot ban the broadcaster. + Failed to ban user - %1$s + Usage: %1$s <username> - Removes a ban on a user. + Failed to unban user - %1$s + Usage: %1$s <username> [duration][time unit] [reason] - Temporarily prevent a user from chatting. Duration (optional, default: 10 minutes) must be a positive integer; time unit (optional, default: s) must be one of s, m, h, d, w; maximum duration is 2 weeks. Reason is optional and will be shown to the target user and other moderators. + Failed to ban user - You cannot timeout yourself. + Failed to ban user - You cannot timeout the broadcaster. + Failed to timeout user - %1$s + Failed to delete chat messages - %1$s + Usage: /delete <msg-id> - Deletes the specified message. + Invalid msg-id: \"%1$s\". + Failed to delete chat messages - %1$s + Usage: /color <color> - Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + Your color has been changed to %1$s + Failed to change color to %1$s - %2$s + Successfully added a stream marker at %1$s%2$s. + Failed to create stream marker - %1$s + Usage: /commercial <length> - Starts a commercial with the specified duration for the current channel. Valid length options are 30, 60, 90, 120, 150, and 180 seconds. + + Starting %1$d second long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + Starting %1$d seconds long commercial break. Keep in mind you are still live and not all viewers will receive a commercial. You may run another commercial in %2$d seconds. + + Failed to start commercial - %1$s + Usage: /raid <username> - Raid a user. Only the broadcaster can start a raid. + Invalid username: %1$s + You started to raid %1$s. + Failed to start a raid - %1$s + You cancelled the raid. + Failed to cancel the raid - %1$s + Usage: %1$s [duration] - Enables followers-only mode (only followers may chat). Duration (optional, default: 0 minutes) must be a positive number followed by a time unit (m, h, d, w); maximum duration is 3 months. + This room is already in %1$s followers-only mode. + Failed to update chat settings - %1$s + This room is not in followers-only mode. + This room is already in emote-only mode. + This room is not in emote-only mode. + This room is already in subscribers-only mode. + This room is not in subscribers-only mode. + This room is already in unique-chat mode. + This room is not in unique-chat mode. + Usage: %1$s [duration] - Enables slow mode (limit how often users may send messages). Duration (optional, default: 30) must be a positive number of seconds; maximum is 120. + This room is already in %1$d-second slow mode. + This room is not in slow mode. + Usage: %1$s <username> - Sends a shoutout to the specified Twitch user. + Sent shoutout to %1$s + Failed to send shoutout - %1$s + Shield mode was activated. + Shield mode was deactivated. + Failed to update shield mode - %1$s + You cannot whisper yourself. + Due to Twitch restrictions, you are now required to have a verified phone number to send whispers. You can add a phone number in Twitch settings. https://www.twitch.tv/settings/security + The recipient doesn\'t allow whispers from strangers or you directly. + You are being rate-limited by Twitch. Try again in a few seconds. + You may only whisper a maximum of 40 unique recipients per day. Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute. + Due to Twitch restrictions, this command can only be used by the broadcaster. Please use the Twitch website instead. + %1$s is already a moderator of this channel. + %1$s is currently a VIP, /unvip them and retry this command. + %1$s is not a moderator of this channel. + %1$s is not banned from this channel. + %1$s is already banned in this channel. + You cannot %1$s %2$s. + There was a conflicting ban operation on this user. Please try again. + Color must be one of Twitch\'s supported colors (%1$s) or a hex code (#000000) if you have Turbo or Prime. + You must be streaming live to run commercials. + You must wait until your cool-down period expires before you can run another commercial. + Command must include a desired commercial break length that is greater than zero. + You don\'t have an active raid. + A channel cannot raid itself. + The broadcaster may not give themselves a Shoutout. + The broadcaster is not streaming live or does not have one or more viewers. + The duration is out of the valid range: %1$s. + The message has already been processed. + The target message was not found. + Your message was too long. + You are being rate-limited. Try again in a moment. + The target user diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml deleted file mode 100644 index b29b9af92..000000000 --- a/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c9ad23932..ee31cb4a5 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,21 +1,21 @@ - + - - diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 8b3f9c613..1d6ae2728 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,7 @@ + \ No newline at end of file diff --git a/app/src/test/kotlin/com/flxrs/dankchat/data/api/upload/UploadLinkPatternTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/data/api/upload/UploadLinkPatternTest.kt new file mode 100644 index 000000000..516312d6a --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/api/upload/UploadLinkPatternTest.kt @@ -0,0 +1,109 @@ +package com.flxrs.dankchat.data.api.upload + +import com.flxrs.dankchat.data.api.upload.UploadClient.Companion.extractJsonLink +import com.flxrs.dankchat.data.api.upload.UploadClient.Companion.getJsonValue +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class UploadLinkPatternTest { + private fun parse(json: String) = Json.parseToJsonElement(json) + + @Test + fun `getJsonValue extracts simple object key`() { + val json = parse("""{"link": "https://example.com/image.png"}""") + assertEquals("https://example.com/image.png", json.getJsonValue("link")) + } + + @Test + fun `getJsonValue extracts nested object key`() { + val json = parse("""{"data": {"url": "https://example.com/image.png"}}""") + assertEquals("https://example.com/image.png", json.getJsonValue("data.url")) + } + + @Test + fun `getJsonValue extracts from array by index`() { + val json = parse("""[{"url": "https://example.com/image.png"}]""") + assertEquals("https://example.com/image.png", json.getJsonValue("0.url")) + } + + @Test + fun `getJsonValue extracts from nested object with array`() { + val json = parse("""{"files": [{"url": "https://example.com/image.png"}]}""") + assertEquals("https://example.com/image.png", json.getJsonValue("files.0.url")) + } + + @Test + fun `getJsonValue extracts from deeply nested path`() { + val json = parse("""{"a": {"b": [{"c": "value"}]}}""") + assertEquals("value", json.getJsonValue("a.b.0.c")) + } + + @Test + fun `getJsonValue extracts non-first array element`() { + val json = parse("""["first", "second", "third"]""") + assertEquals("second", json.getJsonValue("1")) + } + + @Test + fun `getJsonValue returns null for missing key`() { + val json = parse("""{"link": "https://example.com"}""") + assertNull(json.getJsonValue("missing")) + } + + @Test + fun `getJsonValue returns null for out of bounds index`() { + val json = parse("""["only"]""") + assertNull(json.getJsonValue("5")) + } + + @Test + fun `getJsonValue returns null for invalid index on array`() { + val json = parse("""["item"]""") + assertNull(json.getJsonValue("notanumber")) + } + + @Test + fun `extractJsonLink replaces single placeholder`() { + val json = parse("""{"link": "https://example.com/image.png"}""") + assertEquals("https://example.com/image.png", json.extractJsonLink("{link}")) + } + + @Test + fun `extractJsonLink replaces array path placeholder`() { + val json = parse("""[{"url": "https://example.com/image.png"}]""") + assertEquals("https://example.com/image.png", json.extractJsonLink("{0.url}")) + } + + @Test + fun `extractJsonLink interpolates multiple placeholders`() { + val json = parse("""{"host": "https://example.com", "path": "image.png"}""") + assertEquals("https://example.com/image.png", json.extractJsonLink("{host}/{path}")) + } + + @Test + fun `extractJsonLink preserves unmatched placeholders`() { + val json = parse("""{"link": "https://example.com"}""") + assertEquals("{missing}", json.extractJsonLink("{missing}")) + } + + @Test + fun `extractJsonLink with real uploader response`() { + val response = """[{"id":"cmnh6sm1z","name":"zKjLeT.png","type":"image/png","url":"https://sus.link/u/zKjLeT.png"}]""" + val json = parse(response) + assertEquals("https://sus.link/u/zKjLeT.png", json.extractJsonLink("{0.url}")) + } + + @Test + fun `getJsonValue handles numeric value`() { + val json = parse("""{"count": 42}""") + assertEquals("42", json.getJsonValue("count")) + } + + @Test + fun `getJsonValue handles boolean value`() { + val json = parse("""{"success": true}""") + assertEquals("true", json.getJsonValue("success")) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/data/irc/IrcMessageTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/data/irc/IrcMessageTest.kt index 224952097..610f5de2b 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/data/irc/IrcMessageTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/irc/IrcMessageTest.kt @@ -3,9 +3,8 @@ package com.flxrs.dankchat.data.irc import org.junit.jupiter.api.Test import kotlin.test.assertEquals - +@Suppress("MaxLineLength") internal class IrcMessageTest { - // examples from https://github.com/robotty/twitch-irc-rs @Test @@ -16,12 +15,11 @@ internal class IrcMessageTest { assertEquals(expected = "CLEARCHAT", actual = ircMessage.command) assertEquals(expected = listOf("#pajlada", "fabzeef"), actual = ircMessage.params) assertEquals(expected = "tmi.twitch.tv", actual = ircMessage.prefix) - assertEquals(expected = "1", actual = ircMessage.tags["ban-duration"] ) - assertEquals(expected = "11148817", actual = ircMessage.tags["room-id"] ) - assertEquals(expected = "148973258", actual = ircMessage.tags["target-user-id"] ) - assertEquals(expected = "1594553828245", actual = ircMessage.tags["tmi-sent-ts"] ) + assertEquals(expected = "1", actual = ircMessage.tags["ban-duration"]) + assertEquals(expected = "11148817", actual = ircMessage.tags["room-id"]) + assertEquals(expected = "148973258", actual = ircMessage.tags["target-user-id"]) + assertEquals(expected = "1594553828245", actual = ircMessage.tags["tmi-sent-ts"]) assertEquals(expected = 4, actual = ircMessage.tags.size) - } @Test @@ -32,9 +30,9 @@ internal class IrcMessageTest { assertEquals(expected = "CLEARCHAT", actual = ircMessage.command) assertEquals(expected = listOf("#pajlada", "weeb123"), actual = ircMessage.params) assertEquals(expected = "tmi.twitch.tv", actual = ircMessage.prefix) - assertEquals(expected = "11148817", actual = ircMessage.tags["room-id"] ) - assertEquals(expected = "70948394", actual = ircMessage.tags["target-user-id"] ) - assertEquals(expected = "1594561360331", actual = ircMessage.tags["tmi-sent-ts"] ) + assertEquals(expected = "11148817", actual = ircMessage.tags["room-id"]) + assertEquals(expected = "70948394", actual = ircMessage.tags["target-user-id"]) + assertEquals(expected = "1594561360331", actual = ircMessage.tags["tmi-sent-ts"]) assertEquals(expected = 3, actual = ircMessage.tags.size) } @@ -122,7 +120,8 @@ internal class IrcMessageTest { @Test fun `parse privmsg`() { - val msg = "@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada :dank cam" + val msg = + "@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada :dank cam" val ircMessage = IrcMessage.parse(msg) assertEquals(expected = "PRIVMSG", actual = ircMessage.command) @@ -139,7 +138,8 @@ internal class IrcMessageTest { @Test fun `parse privmsg without colon`() { - val msg = "@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" + val msg = + "@badge-info=;badges=;color=#0000FF;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" val ircMessage = IrcMessage.parse(msg) assertEquals(expected = "PRIVMSG", actual = ircMessage.command) @@ -156,7 +156,8 @@ internal class IrcMessageTest { @Test fun `parse privmsg with tag without value`() { - val msg = "@badge-info=;badges=;color=#0000FF;foo=;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" + val msg = + "@badge-info=;badges=;color=#0000FF;foo=;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" val ircMessage = IrcMessage.parse(msg) assertEquals(expected = "PRIVMSG", actual = ircMessage.command) @@ -176,7 +177,8 @@ internal class IrcMessageTest { @Test fun `parse privmsg with character replacement inside tag values`() { - val msg = "@badge-info=;badges=;color=#0000FF;foo=\\:;foo2=\\:\\s\\r\\n;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" + val msg = + "@badge-info=;badges=;color=#0000FF;foo=\\:;foo2=\\:\\s\\r\\n;display-name=JuN1oRRRR;emotes=;flags=;id=e9d998c3-36f1-430f-89ec-6b887c28af36;mod=0;room-id=11148817;subscriber=0;tmi-sent-ts=1594545155039;turbo=0;user-id=29803735;user-type= :jun1orrrr!jun1orrrr@jun1orrrr.tmi.twitch.tv PRIVMSG #pajlada dank" val ircMessage = IrcMessage.parse(msg) assertEquals(expected = "PRIVMSG", actual = ircMessage.command) @@ -194,4 +196,4 @@ internal class IrcMessageTest { assertEquals(expected = "", actual = ircMessage.tags["user-type"]) assertEquals(expected = 16, actual = ircMessage.tags.size) } -} \ No newline at end of file +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepositoryTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepositoryTest.kt index 7e58d5b76..25ad8506f 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepositoryTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/repo/emote/EmoteRepositoryTest.kt @@ -1,9 +1,11 @@ package com.flxrs.dankchat.data.repo.emote -import com.flxrs.dankchat.data.api.dankchat.DankChatApiClient +import com.flxrs.dankchat.data.api.helix.HelixApiClient import com.flxrs.dankchat.data.repo.channel.ChannelRepository import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmote import com.flxrs.dankchat.data.twitch.emote.ChatMessageEmoteType +import com.flxrs.dankchat.data.twitch.message.EmoteWithPositions +import com.flxrs.dankchat.di.DispatchersProvider import com.flxrs.dankchat.preferences.chat.ChatSettingsDataStore import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK @@ -14,9 +16,8 @@ import kotlin.test.assertEquals @ExtendWith(MockKExtension::class) internal class EmoteRepositoryTest { - @MockK - lateinit var dankchatApiClient: DankChatApiClient + lateinit var helixApiClient: HelixApiClient @MockK lateinit var chatSettings: ChatSettingsDataStore @@ -24,17 +25,195 @@ internal class EmoteRepositoryTest { @MockK lateinit var channelRepository: ChannelRepository + @MockK + lateinit var dispatchersProvider: DispatchersProvider + @InjectMockKs lateinit var emoteRepository: EmoteRepository + // --- parseTwitchEmotes tests --- + + @Test + fun `emote positions are correct for regular message without emoji`() { + // "hello Kappa world" — Kappa at position 6..10 + val message = "hello Kappa world" + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(6..10))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = emptyList(), + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = 0, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 6..11, actual = result.single().position) + } + + @Test + fun `emote positions are correct for reply message without emoji`() { + // Original: "@someuser hello Kappa world" — Kappa at Twitch position 16..20 + // Stripped: "hello Kappa world" — replyMentionOffset = 10 ("@someuser " = 10 chars) + val message = "hello Kappa world" + val replyOffset = 10 + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(16..20))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = emptyList(), + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = replyOffset, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 6..11, actual = result.single().position) + } + + @Test + fun `emote positions are correct with flag emoji before emote`() { + // "nice play 🇩🇪 Kappa" — 🇩🇪 = U+1F1E9 U+1F1EA (two supplementary codepoints) + // Twitch codepoint positions: n=0..y=8, ' '=9, 🇩=10, 🇪=11, ' '=12, K=13..a=17 + // supplementaryCodePointPositions: [10, 11] + // unicodeExtra = 2 → fixedStart = 13 + 2 = 15 + // Kotlin string: "nice play " = 0..9, 🇩 = 10-11, 🇪 = 12-13, ' ' = 14, K = 15 + val message = "nice play 🇩🇪 Kappa" + val supplementary = listOf(10, 11) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(13..17))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = 0, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 15..20, actual = result.single().position) + } + + @Test + fun `emote positions are correct with skin tone emoji before emote`() { + // "GG 👍🏽 Kappa" — 👍🏽 = U+1F44D U+1F3FD (two supplementary codepoints) + // Twitch codepoints: G=0, G=1, ' '=2, 👍=3, 🏽=4, ' '=5, K=6..a=10 + // supplementaryCodePointPositions: [3, 4] + // unicodeExtra = 2 → fixedStart = 6 + 2 = 8 + // Kotlin string: G=0, G=1, ' '=2, 👍=3-4, 🏽=5-6, ' '=7, K=8 + val message = "GG 👍🏽 Kappa" + val supplementary = listOf(3, 4) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(6..10))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = 0, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 8..13, actual = result.single().position) + } + + @Test + fun `reply with flag emoji before emote adjusts positions correctly`() { + // Original: "@treejadey nice play 🇩🇪 Kappa" + // "@treejadey " = 12 codepoints, stripped: "nice play 🇩🇪 Kappa" + // Twitch Kappa position: 25..29 (13 + 12), replyMentionOffset = 12 + // adjustedFirst = 25 - 12 = 13, unicodeExtra = countLessThan([10, 11], 13) = 2 + // fixedStart = 13 + 2 = 15 → Kotlin index 15 = 'K' ✓ + val message = "nice play 🇩🇪 Kappa" + val replyOffset = 12 + val supplementary = listOf(10, 11) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(25..29))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = replyOffset, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 15..20, actual = result.single().position) + } + + @Test + fun `reply with skin tone emoji before emote adjusts positions correctly`() { + // Original: "@flex3rs GG 👍🏽 Kappa" + // "@flex3rs " = 9 codepoints, stripped: "GG 👍🏽 Kappa" + // Twitch Kappa position: 15..19 (6 + 9), replyMentionOffset = 9 + // adjustedFirst = 15 - 9 = 6, unicodeExtra = countLessThan([3, 4], 6) = 2 + // fixedStart = 6 + 2 = 8 → Kotlin index 8 = 'K' ✓ + val message = "GG 👍🏽 Kappa" + val replyOffset = 9 + val supplementary = listOf(3, 4) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(15..19))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = replyOffset, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 8..13, actual = result.single().position) + } + + @Test + fun `emote positions are correct with emoji after emote`() { + // "Kappa 🇩🇪 nice" — emoji is after emote, should not affect emote position + // Twitch codepoints: K=0..a=4, ' '=5, 🇩=6, 🇪=7, ' '=8, n=9..e=12 + // Kappa at 0..4, supplementary at [6, 7] — both after emote + // unicodeExtra = countLessThan([6, 7], 0) = 0 + val message = "Kappa 🇩🇪 nice" + val supplementary = listOf(6, 7) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(0..4))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = 0, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 0..5, actual = result.single().position) + } + + @Test + fun `reply with emote between emojis`() { + // Original: "@user 👍🏽 Kappa ⚡" — "@user " = 6, stripped: "👍🏽 Kappa ⚡" + // Twitch codepoints (full): 👍=6, 🏽=7, ' '=8, K=9..a=13, ' '=14, ⚡=15 + // Kappa at 9..13, replyMentionOffset = 6 + // Stripped supplementary: 👍 at 0, 🏽 at 1, ⚡ at 8 + // adjustedFirst = 9 - 6 = 3, unicodeExtra = countLessThan([0, 1, 8], 3) = 2 + // fixedStart = 3 + 2 = 5 + // Kotlin string "👍🏽 Kappa ⚡": 👍=0-1, 🏽=2-3, ' '=4, K=5 + val message = "👍🏽 Kappa ⚡" + val replyOffset = 6 + val supplementary = listOf(0, 1, 8) + val emotes = listOf(EmoteWithPositions(id = "25", positions = listOf(9..13))) + val result = emoteRepository.parseTwitchEmotes( + emotesWithPositions = emotes, + message = message, + supplementaryCodePointPositions = supplementary, + appendedSpaces = emptyList(), + removedSpaces = emptyList(), + replyMentionOffset = replyOffset, + ) + assertEquals(expected = "Kappa", actual = result.single().code) + assertEquals(expected = 5..10, actual = result.single().position) + } + @Test fun `overlay emotes are not moved if regular text is in-between`() { val message = "FeelsDankMan asd cvHazmat RainTime" - val emotes = listOf( - ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), - ChatMessageEmote(position = 17..25, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ChatMessageEmote(position = 26..34, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ) + val emotes = + listOf( + ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), + ChatMessageEmote(position = 17..25, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ChatMessageEmote(position = 26..34, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ) val (resultMessage, resultEmotes) = emoteRepository.adjustOverlayEmotes(message, emotes) assertEquals(expected = message, actual = resultMessage) @@ -44,17 +223,19 @@ internal class EmoteRepositoryTest { @Test fun `overlay emotes are moved if no regular text is in-between`() { val message = "FeelsDankMan cvHazmat RainTime" - val emotes = listOf( - ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), - ChatMessageEmote(position = 13..21, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ChatMessageEmote(position = 22..30, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ) + val emotes = + listOf( + ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), + ChatMessageEmote(position = 13..21, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ChatMessageEmote(position = 22..30, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ) val expectedMessage = "FeelsDankMan " // KKona - val expectedEmotes = listOf( - ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), - ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), - ) + val expectedEmotes = + listOf( + ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "FeelsDankMan", scale = 1, type = ChatMessageEmoteType.TwitchEmote), + ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ChatMessageEmote(position = 0..12, url = "asd", id = "1", code = "cvHazmat", scale = 1, type = ChatMessageEmoteType.TwitchEmote, isOverlayEmote = true), + ) val (resultMessage, resultEmotes) = emoteRepository.adjustOverlayEmotes(message, emotes) diff --git a/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/MockIrcServer.kt b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/MockIrcServer.kt new file mode 100644 index 000000000..324403dc2 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/MockIrcServer.kt @@ -0,0 +1,102 @@ +package com.flxrs.dankchat.data.twitch.chat + +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class MockIrcServer : AutoCloseable { + private val server = MockWebServer() + private var serverSocket: WebSocket? = null + val sentFrames = CopyOnWriteArrayList() + private val connectedLatch = CountDownLatch(1) + + private val listener = + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + serverSocket = webSocket + connectedLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + text.trimEnd('\r', '\n').split("\r\n").forEach { line -> + sentFrames.add(line) + handleIrcCommand(webSocket, line) + } + } + } + + val url: String get() = server.url("/").toString().replace("http://", "ws://") + + fun start() { + server.enqueue(MockResponse.Builder().webSocketUpgrade(listener).build()) + server.start() + } + + fun awaitConnection( + timeout: Long = 5, + unit: TimeUnit = TimeUnit.SECONDS, + ): Boolean = connectedLatch.await(timeout, unit) + + fun sendToClient(ircLine: String) { + serverSocket?.send("$ircLine\r\n") + } + + override fun close() { + serverSocket?.close(1000, null) + server.close() + } + + private fun handleIrcCommand( + webSocket: WebSocket, + line: String, + ) { + when { + line.startsWith("NICK ") -> { + val nick = line.removePrefix("NICK ") + sendMotd(webSocket, nick) + } + + line.startsWith("JOIN ") -> { + val channels = line.removePrefix("JOIN ").split(",") + channels.forEach { channel -> + val ch = channel.trim() + webSocket.send(":$NICK!$NICK@$NICK.tmi.twitch.tv JOIN $ch\r\n") + webSocket.send(":tmi.twitch.tv 353 $NICK = $ch :$NICK\r\n") + webSocket.send(":tmi.twitch.tv 366 $NICK $ch :End of /NAMES list\r\n") + } + } + + line.startsWith("PING") -> { + webSocket.send(":tmi.twitch.tv PONG tmi.twitch.tv\r\n") + } + } + } + + private fun sendMotd( + webSocket: WebSocket, + nick: String, + ) { + webSocket.send(":tmi.twitch.tv 001 $nick :Welcome, GLHF!\r\n") + webSocket.send(":tmi.twitch.tv 002 $nick :Your host is tmi.twitch.tv\r\n") + webSocket.send(":tmi.twitch.tv 003 $nick :This server is rather new\r\n") + webSocket.send(":tmi.twitch.tv 004 $nick :-\r\n") + webSocket.send(":tmi.twitch.tv 375 $nick :-\r\n") + webSocket.send(":tmi.twitch.tv 372 $nick :You are in a maze of twisty passages.\r\n") + webSocket.send(":tmi.twitch.tv 376 $nick :>\r\n") + } + + private companion object { + const val NICK = "justinfan12781923" + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/TwitchIrcIntegrationTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/TwitchIrcIntegrationTest.kt new file mode 100644 index 000000000..6fd40a526 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/data/twitch/chat/TwitchIrcIntegrationTest.kt @@ -0,0 +1,229 @@ +package com.flxrs.dankchat.data.twitch.chat + +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.toUserName +import com.flxrs.dankchat.di.DispatchersProvider +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +internal class ChatConnectionTest { + private val httpClient = HttpClient(OkHttp) + private val mockServer = MockIrcServer() + private val dispatchers = + object : DispatchersProvider { + override val default: CoroutineDispatcher = Dispatchers.Default + override val io: CoroutineDispatcher = Dispatchers.IO + override val main: CoroutineDispatcher = Dispatchers.Default + override val immediate: CoroutineDispatcher = Dispatchers.Default + } + + private lateinit var connection: ChatConnection + + @BeforeEach + fun setup() { + mockServer.start() + } + + @AfterEach + fun cleanup() { + if (::connection.isInitialized) { + connection.close() + } + mockServer.close() + httpClient.close() + } + + private fun createConnection( + userName: String? = null, + oAuth: String? = null, + ): ChatConnection { + val authDataStore: AuthDataStore = + mockk { + every { this@mockk.userName } returns userName?.toUserName() + every { oAuthKey } returns oAuth + } + return ChatConnection(ChatConnectionType.Read, httpClient, authDataStore, dispatchers, url = mockServer.url).also { + connection = it + } + } + + @Test + fun `anonymous connect sends correct CAP, PASS, NICK sequence`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.connect() + awaitFrame { it == "NICK justinfan12781923" } + + assertEquals("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership", mockServer.sentFrames[0]) + assertEquals("PASS NaM", mockServer.sentFrames[1]) + assertEquals("NICK justinfan12781923", mockServer.sentFrames[2]) + } + } + } + + @Test + fun `authenticated connect sends correct credentials`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection(userName = "testuser", oAuth = "oauth:abc123") + conn.connect() + awaitFrame { it == "NICK testuser" } + + assertEquals("PASS oauth:abc123", mockServer.sentFrames[1]) + assertEquals("NICK testuser", mockServer.sentFrames[2]) + } + } + } + + @Test + fun `connected state updates on successful handshake`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + assertFalse(conn.connected.value) + + conn.connect() + conn.connected.first { it } + assertTrue(conn.connected.value) + } + } + } + + @Test + fun `joinChannels sends JOIN command after connect`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.joinChannels(listOf("testchannel".toUserName())) + conn.connect() + + awaitFrame { it.startsWith("JOIN") } + assertContains(mockServer.sentFrames, "JOIN #testchannel") + } + } + } + + @Test + fun `partChannel sends PART command`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + val channel = "testchannel".toUserName() + conn.joinChannels(listOf(channel)) + conn.connect() + awaitFrame { it.startsWith("JOIN") } + + conn.partChannel(channel) + awaitFrame { it.startsWith("PART") } + assertContains(mockServer.sentFrames, "PART #testchannel") + } + } + } + + @Test + fun `sendMessage sends raw IRC through websocket`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.connect() + conn.connected.first { it } + + conn.sendMessage("PRIVMSG #test :hello world") + awaitFrame { it.startsWith("PRIVMSG") } + assertContains(mockServer.sentFrames, "PRIVMSG #test :hello world") + } + } + } + + @Test + fun `close resets connected state`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.connect() + conn.connected.first { it } + + conn.close() + assertFalse(conn.connected.value) + } + } + } + + @Test + fun `PING from server is answered with PONG`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.connect() + conn.connected.first { it } + + mockServer.sendToClient("PING :tmi.twitch.tv") + awaitFrame { it.startsWith("PONG") } + assertContains(mockServer.sentFrames, "PONG :tmi.twitch.tv") + } + } + } + + @Test + fun `reconnectIfNecessary does nothing when already connected`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.connect() + conn.connected.first { it } + + val frameCountBefore = mockServer.sentFrames.size + conn.reconnectIfNecessary() + + // No new connection = no new frames + assertEquals(frameCountBefore, mockServer.sentFrames.size) + assertTrue(conn.connected.value) + } + } + } + + @Test + fun `multiple channels are joined via single JOIN command`() = runTest { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + val conn = createConnection() + conn.joinChannels(listOf("ch1".toUserName(), "ch2".toUserName())) + conn.connect() + + awaitFrame { it.contains("#ch1") && it.contains("#ch2") } + val joinFrame = mockServer.sentFrames.first { it.startsWith("JOIN") } + assertContains(joinFrame, "#ch1") + assertContains(joinFrame, "#ch2") + } + } + } + + private suspend fun awaitFrame( + timeoutMs: Long = 3000, + predicate: (String) -> Boolean, + ) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (mockServer.sentFrames.any(predicate)) return + kotlinx.coroutines.delay(25) + } + throw AssertionError("No frame matching predicate within ${timeoutMs}ms. Frames: ${mockServer.sentFrames}") + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt new file mode 100644 index 000000000..71b7c4adc --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataCoordinatorTest.kt @@ -0,0 +1,272 @@ +package com.flxrs.dankchat.domain + +import app.cash.turbine.test +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.auth.AuthDataStore +import com.flxrs.dankchat.data.auth.StartupValidation +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.data.repo.chat.ChatLoadingFailure +import com.flxrs.dankchat.data.repo.chat.ChatLoadingStep +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.data.DataLoadingFailure +import com.flxrs.dankchat.data.repo.data.DataLoadingStep +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.repo.data.DataUpdateEventMessage +import com.flxrs.dankchat.data.repo.stream.StreamDataRepository +import com.flxrs.dankchat.data.state.ChannelLoadingFailure +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.di.DispatchersProvider +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockKExtension::class) +internal class ChannelDataCoordinatorTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val dispatchersProvider = + object : DispatchersProvider { + override val default: CoroutineDispatcher = testDispatcher + override val io: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + override val immediate: CoroutineDispatcher = testDispatcher + } + + private val channelDataLoader: ChannelDataLoader = mockk() + private val globalDataLoader: GlobalDataLoader = mockk() + private val chatMessageRepository: ChatMessageRepository = mockk(relaxed = true) + private val dataRepository: DataRepository = mockk(relaxed = true) + private val authDataStore: AuthDataStore = mockk() + private val preferenceStore: DankChatPreferenceStore = mockk() + private val startupValidationHolder = StartupValidationHolder() + private val streamDataRepository: StreamDataRepository = mockk(relaxed = true) + + private val dataUpdateEvents = MutableSharedFlow() + private val dataLoadingFailures = MutableStateFlow>(emptySet()) + private val chatLoadingFailures = MutableStateFlow>(emptySet()) + + private lateinit var coordinator: ChannelDataCoordinator + + @BeforeEach + fun setup() { + every { dataRepository.dataUpdateEvents } returns dataUpdateEvents + every { dataRepository.dataLoadingFailures } returns dataLoadingFailures + every { chatMessageRepository.chatLoadingFailures } returns chatLoadingFailures + + startupValidationHolder.update(StartupValidation.Validated) + + coordinator = + ChannelDataCoordinator( + channelDataLoader = channelDataLoader, + globalDataLoader = globalDataLoader, + chatMessageRepository = chatMessageRepository, + dataRepository = dataRepository, + authDataStore = authDataStore, + preferenceStore = preferenceStore, + startupValidationHolder = startupValidationHolder, + streamDataRepository = streamDataRepository, + dispatchersProvider = dispatchersProvider, + ) + } + + @Test + fun `loadGlobalData transitions to Loaded when no failures`() = runTest(testDispatcher) { + every { authDataStore.isLoggedIn } returns false + coEvery { globalDataLoader.loadGlobalData() } returns emptyList() + every { dataRepository.clearDataLoadingFailures() } just runs + + coordinator.loadGlobalData() + + assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) + } + + @Test + fun `loadGlobalData transitions to Failed when data failures exist`() = runTest(testDispatcher) { + every { authDataStore.isLoggedIn } returns false + coEvery { globalDataLoader.loadGlobalData() } returns emptyList() + every { dataRepository.clearDataLoadingFailures() } just runs + + val failure = DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout")) + dataLoadingFailures.value = setOf(failure) + + coordinator.loadGlobalData() + + val state = coordinator.globalLoadingState.value + assertIs(state) + assertEquals(1, state.failures.size) + assertEquals(DataLoadingStep.GlobalBTTVEmotes, state.failures.first().step) + } + + @Test + fun `loadGlobalData with auth loads stream data and auth global data`() = runTest(testDispatcher) { + every { authDataStore.isLoggedIn } returns true + every { authDataStore.userIdString } returns null + every { preferenceStore.channels } returns listOf(UserName("testchannel")) + coEvery { globalDataLoader.loadGlobalData() } returns emptyList() + coEvery { globalDataLoader.loadAuthGlobalData() } returns emptyList() + every { dataRepository.clearDataLoadingFailures() } just runs + coEvery { streamDataRepository.fetchOnce(any()) } just runs + + coordinator.loadGlobalData() + + coVerify { streamDataRepository.fetchOnce(listOf(UserName("testchannel"))) } + coVerify { globalDataLoader.loadAuthGlobalData() } + } + + @Test + fun `loadChannelData transitions to Loaded`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + + coordinator.loadChannelData(channel) + + assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) + } + + @Test + fun `loadChannelData transitions to Failed on loader failure`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + val failures = listOf(ChannelLoadingFailure.BTTVEmotes(channel, RuntimeException("network"))) + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Failed(failures) + + coordinator.loadChannelData(channel) + + val state = coordinator.getChannelLoadingState(channel).value + assertIs(state) + assertEquals(1, state.failures.size) + } + + @Test + fun `chat loading failures update global state from Loaded to Failed`() = runTest(testDispatcher) { + every { authDataStore.isLoggedIn } returns false + coEvery { globalDataLoader.loadGlobalData() } returns emptyList() + every { dataRepository.clearDataLoadingFailures() } just runs + + coordinator.loadGlobalData() + assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) + + coordinator.globalLoadingState.test { + assertEquals(GlobalLoadingState.Loaded, awaitItem()) + + val chatFailure = ChatLoadingFailure(ChatLoadingStep.RecentMessages(UserName("test")), RuntimeException("fail")) + chatLoadingFailures.value = setOf(chatFailure) + + val failed = awaitItem() + assertIs(failed) + assertEquals(1, failed.chatFailures.size) + } + } + + @Test + fun `retryDataLoading retries failed global steps`() = runTest(testDispatcher) { + every { dataRepository.clearDataLoadingFailures() } just runs + every { chatMessageRepository.clearChatLoadingFailures() } just runs + coEvery { globalDataLoader.loadGlobalBTTVEmotes() } returns Result.success(Unit) + + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) + + coordinator.retryDataLoading(failedState) + + coVerify { globalDataLoader.loadGlobalBTTVEmotes() } + } + + @Test + fun `retryDataLoading retries failed channel steps via channelDataLoader`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + every { dataRepository.clearDataLoadingFailures() } just runs + every { chatMessageRepository.clearChatLoadingFailures() } just runs + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.ChannelBTTVEmotes(channel, DisplayName("testchannel"), UserId("123")), RuntimeException("fail"))), + ) + + coordinator.retryDataLoading(failedState) + + coVerify { channelDataLoader.loadChannelData(channel) } + } + + @Test + fun `retryDataLoading retries failed chat steps`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + every { dataRepository.clearDataLoadingFailures() } just runs + every { chatMessageRepository.clearChatLoadingFailures() } just runs + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + + val failedState = + GlobalLoadingState.Failed( + chatFailures = setOf(ChatLoadingFailure(ChatLoadingStep.RecentMessages(channel), RuntimeException("fail"))), + ) + + coordinator.retryDataLoading(failedState) + + coVerify { channelDataLoader.loadChannelData(channel) } + } + + @Test + fun `retryDataLoading transitions to Loaded when retry succeeds`() = runTest(testDispatcher) { + every { dataRepository.clearDataLoadingFailures() } just runs + every { chatMessageRepository.clearChatLoadingFailures() } just runs + coEvery { globalDataLoader.loadGlobalBTTVEmotes() } returns Result.success(Unit) + + val failedState = + GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("timeout"))), + ) + + coordinator.retryDataLoading(failedState) + + assertEquals(GlobalLoadingState.Loaded, coordinator.globalLoadingState.value) + } + + @Test + fun `retryDataLoading stays Failed when failures persist`() = runTest(testDispatcher) { + val failure = DataLoadingFailure(DataLoadingStep.GlobalBTTVEmotes, RuntimeException("still broken")) + every { dataRepository.clearDataLoadingFailures() } just runs + every { chatMessageRepository.clearChatLoadingFailures() } just runs + coEvery { globalDataLoader.loadGlobalBTTVEmotes() } returns Result.success(Unit) + dataLoadingFailures.value = setOf(failure) + + val failedState = GlobalLoadingState.Failed(failures = setOf(failure)) + + coordinator.retryDataLoading(failedState) + + assertIs(coordinator.globalLoadingState.value) + } + + @Test + fun `cleanupChannel removes channel state`() = runTest(testDispatcher) { + val channel = UserName("testchannel") + coEvery { channelDataLoader.loadChannelData(channel) } returns ChannelLoadingState.Loaded + + coordinator.loadChannelData(channel) + assertEquals(ChannelLoadingState.Loaded, coordinator.getChannelLoadingState(channel).value) + + coordinator.cleanupChannel(channel) + + assertEquals(ChannelLoadingState.Idle, coordinator.getChannelLoadingState(channel).value) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt new file mode 100644 index 000000000..6747d1508 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/domain/ChannelDataLoaderTest.kt @@ -0,0 +1,208 @@ +package com.flxrs.dankchat.domain + +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.UserId +import com.flxrs.dankchat.data.UserName +import com.flxrs.dankchat.data.repo.channel.Channel +import com.flxrs.dankchat.data.repo.channel.ChannelRepository +import com.flxrs.dankchat.data.repo.chat.ChatMessageRepository +import com.flxrs.dankchat.data.repo.chat.ChatRepository +import com.flxrs.dankchat.data.repo.data.DataRepository +import com.flxrs.dankchat.data.state.ChannelLoadingFailure +import com.flxrs.dankchat.data.state.ChannelLoadingState +import com.flxrs.dankchat.data.twitch.message.SystemMessageType +import com.flxrs.dankchat.di.DispatchersProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockKExtension::class) +internal class ChannelDataLoaderTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val dispatchersProvider = + object : DispatchersProvider { + override val default: CoroutineDispatcher = testDispatcher + override val io: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + override val immediate: CoroutineDispatcher = testDispatcher + } + + private val dataRepository: DataRepository = mockk(relaxed = true) + private val chatRepository: ChatRepository = mockk(relaxed = true) + private val chatMessageRepository: ChatMessageRepository = mockk(relaxed = true) + private val channelRepository: ChannelRepository = mockk() + private val getChannelsUseCase: GetChannelsUseCase = mockk() + + private lateinit var loader: ChannelDataLoader + + private val testChannel = UserName("testchannel") + private val testChannelId = UserId("123") + private val testChannelInfo = + Channel( + id = testChannelId, + name = testChannel, + displayName = DisplayName("TestChannel"), + avatarUrl = null, + ) + + @BeforeEach + fun setup() { + loader = + ChannelDataLoader( + dataRepository = dataRepository, + chatRepository = chatRepository, + chatMessageRepository = chatMessageRepository, + channelRepository = channelRepository, + getChannelsUseCase = getChannelsUseCase, + dispatchersProvider = dispatchersProvider, + ) + } + + private fun stubAllEmotesAndBadgesSuccess() { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelBTTVEmotes(testChannel, any(), testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelFFZEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelSevenTVEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelCheermotes(testChannel, testChannelId) } returns Result.success(Unit) + } + + @Test + fun `loadChannelData returns Loaded when all steps succeed`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + stubAllEmotesAndBadgesSuccess() + + val result = loader.loadChannelData(testChannel) + + assertEquals(ChannelLoadingState.Loaded, result) + } + + @Test + fun `loadChannelData returns Failed with empty list when channel info is null`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns null + coEvery { getChannelsUseCase(listOf(testChannel)) } returns emptyList() + + val result = loader.loadChannelData(testChannel) + + assertIs(result) + assertTrue(result.failures.isEmpty()) + } + + @Test + fun `loadChannelData falls back to GetChannelsUseCase when channelRepository returns null`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns null + coEvery { getChannelsUseCase(listOf(testChannel)) } returns listOf(testChannelInfo) + stubAllEmotesAndBadgesSuccess() + + val result = loader.loadChannelData(testChannel) + + assertEquals(ChannelLoadingState.Loaded, result) + coVerify { getChannelsUseCase(listOf(testChannel)) } + } + + @Test + fun `loadChannelData returns Failed with BTTV failure`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelBTTVEmotes(testChannel, any(), testChannelId) } returns Result.failure(RuntimeException("bttv down")) + coEvery { dataRepository.loadChannelFFZEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelSevenTVEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelCheermotes(testChannel, testChannelId) } returns Result.success(Unit) + + val result = loader.loadChannelData(testChannel) + + assertIs(result) + assertEquals(1, result.failures.size) + assertIs(result.failures.first()) + } + + @Test + fun `loadChannelData collects multiple failures`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("badges down")) + coEvery { dataRepository.loadChannelBTTVEmotes(testChannel, any(), testChannelId) } returns Result.failure(RuntimeException("bttv down")) + coEvery { dataRepository.loadChannelFFZEmotes(testChannel, testChannelId) } returns Result.failure(RuntimeException("ffz down")) + coEvery { dataRepository.loadChannelSevenTVEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelCheermotes(testChannel, testChannelId) } returns Result.success(Unit) + + val result = loader.loadChannelData(testChannel) + + assertIs(result) + assertEquals(3, result.failures.size) + assertTrue(result.failures.any { it is ChannelLoadingFailure.Badges }) + assertTrue(result.failures.any { it is ChannelLoadingFailure.BTTVEmotes }) + assertTrue(result.failures.any { it is ChannelLoadingFailure.FFZEmotes }) + } + + @Test + fun `loadChannelData posts system messages for emote failures`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelBTTVEmotes(testChannel, any(), testChannelId) } returns Result.failure(RuntimeException("bttv")) + coEvery { dataRepository.loadChannelFFZEmotes(testChannel, testChannelId) } returns Result.success(Unit) + coEvery { dataRepository.loadChannelSevenTVEmotes(testChannel, testChannelId) } returns Result.failure(RuntimeException("7tv")) + coEvery { dataRepository.loadChannelCheermotes(testChannel, testChannelId) } returns Result.success(Unit) + + loader.loadChannelData(testChannel) + + coVerify { chatMessageRepository.addSystemMessage(testChannel, match { it is SystemMessageType.ChannelBTTVEmotesFailed }) } + coVerify { chatMessageRepository.addSystemMessage(testChannel, match { it is SystemMessageType.ChannelSevenTVEmotesFailed }) } + } + + @Test + fun `loadChannelData returns Failed on unexpected exception`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } throws RuntimeException("unexpected") + + val result = loader.loadChannelData(testChannel) + + assertIs(result) + assertTrue(result.failures.isEmpty()) + } + + @Test + fun `loadChannelData creates flows and loads history before channel info`() = runTest(testDispatcher) { + coEvery { channelRepository.getChannel(testChannel) } returns testChannelInfo + stubAllEmotesAndBadgesSuccess() + + loader.loadChannelData(testChannel) + + coVerify(ordering = io.mockk.Ordering.ORDERED) { + dataRepository.createFlowsIfNecessary(listOf(testChannel)) + chatRepository.createFlowsIfNecessary(testChannel) + chatRepository.loadRecentMessagesIfEnabled(testChannel) + channelRepository.getChannel(testChannel) + } + } + + @Test + fun `loadChannelBadges returns null on success`() = runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.success(Unit) + + val result = loader.loadChannelBadges(testChannel, testChannelId) + + assertEquals(null, result) + } + + @Test + fun `loadChannelBadges returns failure on error`() = runTest(testDispatcher) { + coEvery { dataRepository.loadChannelBadges(testChannel, testChannelId) } returns Result.failure(RuntimeException("fail")) + + val result = loader.loadChannelBadges(testChannel, testChannelId) + + assertIs(result) + assertEquals(testChannel, result.channel) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt new file mode 100644 index 000000000..899af185f --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionFilteringTest.kt @@ -0,0 +1,230 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import com.flxrs.dankchat.data.DisplayName +import com.flxrs.dankchat.data.repo.emote.EmojiData +import com.flxrs.dankchat.data.twitch.emote.EmoteType +import com.flxrs.dankchat.data.twitch.emote.GenericEmote +import io.mockk.mockk +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class SuggestionFilteringTest { + private val provider = + SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + emoteUsageRepository = mockk(), + emojiRepository = mockk(), + ) + + private fun emote( + code: String, + id: String = code, + ) = GenericEmote(code = code, url = "", lowResUrl = "", id = id, scale = 1, emoteType = EmoteType.GlobalTwitchEmote) + + @Test + fun `emotes sorted by score - shorter before longer`() { + val emotes = listOf(emote("PogChamp"), emote("PogU"), emote("Pog")) + val result = provider.filterEmotesScored(emotes, "Pog", emptySet()) + + assertEquals( + expected = listOf("Pog", "PogU", "PogChamp"), + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, + ) + } + + @Test + fun `emotes sorted by score - exact case beats case mismatch at same length`() { + val emotes = listOf(emote("POGX"), emote("PogX")) + val result = provider.filterEmotesScored(emotes, "Pog", emptySet()) + + // PogX: 1 case diff + 1*100 = 101, POGX: 2 case diffs + 1*100 = 102 + assertEquals( + expected = listOf("PogX", "POGX"), + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, + ) + } + + @Test + fun `shorter match beats case mismatch longer match`() { + val emotes = listOf(emote("wikked"), emote("Wink")) + val result = provider.filterEmotesScored(emotes, "wi", emptySet()) + + // Wink: 1 case diff + 2*100 = 201, wikked: -10 + 4*100 = 390 + assertEquals( + expected = listOf("Wink", "wikked"), + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, + ) + } + + @Test + fun `recently used emote gets boost`() { + val emotes = listOf(emote("PogChamp", id = "1"), emote("PogU", id = "2")) + val result = provider.filterEmotesScored(emotes, "Pog", setOf("1")) + + // PogChamp: -10 + 5*100 - 50 = 440, PogU: -10 + 1*100 = 90 + // PogU still wins due to length dominance + assertEquals( + expected = listOf("PogU", "PogChamp"), + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, + ) + } + + @Test + fun `non-matching emotes are excluded`() { + val emotes = listOf(emote("Kappa"), emote("PogChamp"), emote("LUL")) + val result = provider.filterEmotesScored(emotes, "Pog", emptySet()) + + assertEquals( + expected = listOf("PogChamp"), + actual = result.map { (it.suggestion as Suggestion.EmoteSuggestion).emote.code }, + ) + } + + // endregion + + // region filterUsers + + @Test + fun `users sorted alphabetically`() { + val users = setOf(DisplayName("Zed"), DisplayName("Alice"), DisplayName("Mike")) + val result = provider.filterUsers(users, "") + + assertEquals( + expected = listOf("Alice", "Mike", "Zed"), + actual = result.map { it.name.value }, + ) + } + + @Test + fun `users filtered by prefix and sorted`() { + val users = setOf(DisplayName("Bob"), DisplayName("Anna"), DisplayName("Alex")) + val result = provider.filterUsers(users, "A") + + assertEquals( + expected = listOf("Alex", "Anna"), + actual = result.map { it.name.value }, + ) + } + + @Test + fun `users with at-prefix get leading at`() { + val users = setOf(DisplayName("Bob"), DisplayName("Bea")) + val result = provider.filterUsers(users, "@B") + + assertEquals( + expected = listOf("@Bea", "@Bob"), + actual = result.map { it.toString() }, + ) + } + + // endregion + + // region filterUsersScored + + @Test + fun `scored users use emote scoring with penalty`() { + val users = setOf(DisplayName("Pog"), DisplayName("PogChamp")) + val result = provider.filterUsersScored(users, "Pog") + + assertEquals( + expected = listOf("Pog", "PogChamp"), + actual = result.map { (it.suggestion as Suggestion.UserSuggestion).name.value }, + ) + // Pog: -10 + 25 = 15, PogChamp: -10 + 5*100 + 25 = 515 + assertEquals(15, result[0].score) + assertEquals(515, result[1].score) + } + + @Test + fun `scored users exclude non-matching names`() { + val users = setOf(DisplayName("Alice"), DisplayName("Bob")) + val result = provider.filterUsersScored(users, "Pog") + + assertEquals(emptyList(), result) + } + + @Test + fun `scored users have higher score than equivalent emotes`() { + val provider = this.provider + val emoteScore = provider.scoreEmote("Pog", "Pog", isRecentlyUsed = false) + val users = setOf(DisplayName("Pog")) + val userResult = provider.filterUsersScored(users, "Pog") + + assertTrue(userResult[0].score > emoteScore) + } + + // endregion + + // region filterCommands + + @Test + fun `commands sorted alphabetically`() { + val commands = listOf("/timeout", "/ban", "/mod") + val result = provider.filterCommands(commands, "/") + + assertEquals( + expected = listOf("/ban", "/mod", "/timeout"), + actual = result.map { it.command }, + ) + } + + @Test + fun `commands filtered by prefix`() { + val commands = listOf("/timeout", "/ban", "/title") + val result = provider.filterCommands(commands, "/ti") + + assertEquals( + expected = listOf("/timeout", "/title"), + actual = result.map { it.command }, + ) + } + + // endregion + + // region filterEmojis + + @Test + fun `emojis filtered by shortcode`() { + val emojis = + listOf( + EmojiData("smile", "\uD83D\uDE04"), + EmojiData("wave", "\uD83D\uDC4B"), + EmojiData("smirk", "\uD83D\uDE0F"), + ) + val result = provider.filterEmojis(emojis, "smi") + + assertEquals( + expected = listOf("smile", "smirk"), + actual = result.map { it.suggestion as Suggestion.EmojiSuggestion }.map { it.emoji.code }, + ) + } + + @Test + fun `emojis use same scoring as emotes`() { + val emojis = + listOf( + EmojiData("smirk", "\uD83D\uDE0F"), + EmojiData("smile", "\uD83D\uDE04"), + ) + val result = provider.filterEmojis(emojis, "smi") + + assertEquals(2, result.size) + } + + @Test + fun `non-matching emojis excluded`() { + val emojis = + listOf( + EmojiData("wave", "\uD83D\uDC4B"), + EmojiData("heart", "\u2764\uFE0F"), + ) + val result = provider.filterEmojis(emojis, "smi") + + assertEquals(emptyList(), result) + } + + // endregion +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt new file mode 100644 index 000000000..c8c1765ce --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionProviderExtractWordTest.kt @@ -0,0 +1,102 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import io.mockk.mockk +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +internal class SuggestionProviderExtractWordTest { + private val suggestionProvider = + SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + emoteUsageRepository = mockk(), + emojiRepository = mockk(), + ) + + @Test + fun `cursor at end of single word returns full word`() { + val result = suggestionProvider.extractCurrentWord("asd", cursorPosition = 3) + assertEquals(expected = "asd", actual = result) + } + + @Test + fun `cursor at start of text returns empty string`() { + val result = suggestionProvider.extractCurrentWord("asd asdasd", cursorPosition = 0) + assertEquals(expected = "", actual = result) + } + + @Test + fun `cursor at end of first word returns first word`() { + val result = suggestionProvider.extractCurrentWord("asd asdasd", cursorPosition = 3) + assertEquals(expected = "asd", actual = result) + } + + @Test + fun `cursor at end of second word returns second word`() { + val result = suggestionProvider.extractCurrentWord("asd asdasd", cursorPosition = 10) + assertEquals(expected = "asdasd", actual = result) + } + + @Test + fun `cursor in middle of word returns partial word up to cursor`() { + val result = suggestionProvider.extractCurrentWord("hello world", cursorPosition = 8) + assertEquals(expected = "wo", actual = result) + } + + @Test + fun `cursor right after space returns empty string`() { + val result = suggestionProvider.extractCurrentWord("hello world", cursorPosition = 6) + assertEquals(expected = "", actual = result) + } + + @Test + fun `cursor at space between words returns first word`() { + val result = suggestionProvider.extractCurrentWord("hello world", cursorPosition = 5) + assertEquals(expected = "hello", actual = result) + } + + @Test + fun `handles at-mention prefix`() { + val result = suggestionProvider.extractCurrentWord("hello @us", cursorPosition = 9) + assertEquals(expected = "@us", actual = result) + } + + @Test + fun `handles slash command prefix`() { + val result = suggestionProvider.extractCurrentWord("/ti", cursorPosition = 3) + assertEquals(expected = "/ti", actual = result) + } + + @Test + fun `cursor position clamped to text length`() { + val result = suggestionProvider.extractCurrentWord("abc", cursorPosition = 100) + assertEquals(expected = "abc", actual = result) + } + + @Test + fun `cursor position clamped to zero`() { + val result = suggestionProvider.extractCurrentWord("abc", cursorPosition = -5) + assertEquals(expected = "", actual = result) + } + + @Test + fun `empty text returns empty string`() { + val result = suggestionProvider.extractCurrentWord("", cursorPosition = 0) + assertEquals(expected = "", actual = result) + } + + @Test + fun `cursor mid-text with multiple words returns word being typed`() { + // "one two three" — cursor at position 5, word starts at 4 + val result = suggestionProvider.extractCurrentWord("one two three", cursorPosition = 5) + assertEquals(expected = "t", actual = result) + } + + @Test + fun `typing at beginning of existing text`() { + // User typed "asd" before existing "asd asdasd": "asdasd asdasd" + val result = suggestionProvider.extractCurrentWord("asdasd asdasd", cursorPosition = 3) + assertEquals(expected = "asd", actual = result) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionScoringTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionScoringTest.kt new file mode 100644 index 000000000..706d74073 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/chat/suggestion/SuggestionScoringTest.kt @@ -0,0 +1,68 @@ +package com.flxrs.dankchat.ui.chat.suggestion + +import io.mockk.mockk +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +internal class SuggestionScoringTest { + private val provider = + SuggestionProvider( + emoteRepository = mockk(), + usersRepository = mockk(), + commandRepository = mockk(), + emoteUsageRepository = mockk(), + emojiRepository = mockk(), + ) + + @Test + fun `exact full match scores lowest`() { + val score = provider.scoreEmote("Pog", "Pog", isRecentlyUsed = false) + assertEquals(expected = -10, actual = score) + } + + @Test + fun `shorter match beats longer match regardless of case`() { + val shorter = provider.scoreEmote("Wink", "wi", isRecentlyUsed = false) + val longer = provider.scoreEmote("wikked", "wi", isRecentlyUsed = false) + assertTrue(shorter < longer) + } + + @Test + fun `exact case beats case mismatch at same length`() { + val exactCase = provider.scoreEmote("wink", "wi", isRecentlyUsed = false) + val caseMismatch = provider.scoreEmote("Wink", "wi", isRecentlyUsed = false) + assertTrue(exactCase < caseMismatch) + } + + @Test + fun `recently used emote gets boost`() { + val notRecent = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = false) + val recent = provider.scoreEmote("PogChamp", "Pog", isRecentlyUsed = true) + assertTrue(recent < notRecent) + } + + @Test + fun `no match returns NO_MATCH`() { + val score = provider.scoreEmote("Kappa", "Pog", isRecentlyUsed = false) + assertEquals(SuggestionProvider.NO_MATCH, score) + } + + @Test + fun `substring match has same extra chars cost as prefix`() { + val prefix = provider.scoreEmote("wink", "wi", isRecentlyUsed = false) + val substring = provider.scoreEmote("owie", "wi", isRecentlyUsed = false) + // Both have 2 extra chars, same case match → same score + assertEquals(prefix, substring) + } + + @Test + fun `multiple case diffs add up`() { + val noDiff = provider.scoreEmote("pog", "pog", isRecentlyUsed = false) + val twoDiffs = provider.scoreEmote("POG", "pog", isRecentlyUsed = false) + assertTrue(noDiff < twoDiffs) + // noDiff = -10, twoDiffs = 3 (three case diffs) + assertEquals(-10, noDiff) + assertEquals(3, twoDiffs) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt new file mode 100644 index 000000000..14856e999 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/LoadingFailureStateTest.kt @@ -0,0 +1,213 @@ +package com.flxrs.dankchat.ui.main + +import app.cash.turbine.test +import com.flxrs.dankchat.data.repo.chat.UserStateRepository +import com.flxrs.dankchat.data.repo.data.DataLoadingFailure +import com.flxrs.dankchat.data.repo.data.DataLoadingStep +import com.flxrs.dankchat.data.state.GlobalLoadingState +import com.flxrs.dankchat.domain.ChannelDataCoordinator +import com.flxrs.dankchat.preferences.DankChatPreferenceStore +import com.flxrs.dankchat.preferences.appearance.AppearanceSettings +import com.flxrs.dankchat.preferences.appearance.AppearanceSettingsDataStore +import com.flxrs.dankchat.preferences.developer.DeveloperSettings +import com.flxrs.dankchat.preferences.developer.DeveloperSettingsDataStore +import io.mockk.coEvery +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.justRun +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockKExtension::class) +internal class LoadingFailureStateTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val globalLoadingStateFlow = MutableStateFlow(GlobalLoadingState.Idle) + + private val channelDataCoordinator: ChannelDataCoordinator = mockk() + private val appearanceSettingsDataStore: AppearanceSettingsDataStore = mockk() + private val preferenceStore: DankChatPreferenceStore = mockk() + private val developerSettingsDataStore: DeveloperSettingsDataStore = mockk() + private val userStateRepository: UserStateRepository = mockk() + + private lateinit var viewModel: MainScreenViewModel + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { channelDataCoordinator.globalLoadingState } returns globalLoadingStateFlow + justRun { channelDataCoordinator.loadGlobalData() } + + every { appearanceSettingsDataStore.settings } returns MutableStateFlow(AppearanceSettings()) + coEvery { appearanceSettingsDataStore.update(any()) } returns Unit + every { developerSettingsDataStore.settings } returns MutableStateFlow(DeveloperSettings()) + every { preferenceStore.keyboardHeightPortrait } returns 0 + every { preferenceStore.keyboardHeightLandscape } returns 0 + + viewModel = MainScreenViewModel( + channelDataCoordinator = channelDataCoordinator, + appearanceSettingsDataStore = appearanceSettingsDataStore, + preferenceStore = preferenceStore, + developerSettingsDataStore = developerSettingsDataStore, + userStateRepository = userStateRepository, + ) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state has no failure`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + val state = awaitItem() + assertNull(state.failure) + assertFalse(state.acknowledged) + } + } + + @Test + fun `failure is emitted when loading fails`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) // initial + + globalLoadingStateFlow.value = FAILURE_1 + val state = awaitItem() + assertEquals(FAILURE_1, state.failure) + assertFalse(state.acknowledged) + } + } + + @Test + fun `acknowledged failure is marked as such`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) // unacknowledged emission + + viewModel.acknowledgeFailure(FAILURE_1) + val state = awaitItem() + assertEquals(FAILURE_1, state.failure) + assertTrue(state.acknowledged) + } + } + + @Test + fun `acknowledged failure does not re-emit as unacknowledged`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) + + viewModel.acknowledgeFailure(FAILURE_1) + val state = awaitItem() + assertTrue(state.acknowledged) + + // Same failure value re-emitted — should stay acknowledged + expectNoEvents() + } + } + + @Test + fun `new different failure is unacknowledged`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) + viewModel.acknowledgeFailure(FAILURE_1) + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_2 + val state = awaitItem() + assertEquals(FAILURE_2, state.failure) + assertFalse(state.acknowledged) + } + } + + @Test + fun `loading state clears failure`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) + viewModel.acknowledgeFailure(FAILURE_1) + skipItems(1) + + globalLoadingStateFlow.value = GlobalLoadingState.Loading + val state = awaitItem() + assertNull(state.failure) + assertFalse(state.acknowledged) + } + } + + @Test + fun `retry resets acknowledged so same failure can re-show`() = runTest(testDispatcher) { + every { channelDataCoordinator.retryDataLoading(any()) } answers { + globalLoadingStateFlow.value = GlobalLoadingState.Loading + } + + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) + viewModel.acknowledgeFailure(FAILURE_1) + skipItems(1) + + // Retry triggers Loading → acknowledged is cleared reactively + viewModel.retryDataLoading(FAILURE_1) + val loadingState = awaitItem() + assertNull(loadingState.failure) + assertFalse(loadingState.acknowledged) + + // Same failure comes back after retry — should be unacknowledged + globalLoadingStateFlow.value = FAILURE_1 + val state = awaitItem() + assertEquals(FAILURE_1, state.failure) + assertFalse(state.acknowledged) + } + } + + @Test + fun `loaded state clears failure`() = runTest(testDispatcher) { + viewModel.loadingFailureState.test { + skipItems(1) + + globalLoadingStateFlow.value = FAILURE_1 + skipItems(1) + + globalLoadingStateFlow.value = GlobalLoadingState.Loaded + val state = awaitItem() + assertNull(state.failure) + } + } + + companion object { + private val FAILURE_1 = GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.DankChatBadges, RuntimeException("test"))), + ) + private val FAILURE_2 = GlobalLoadingState.Failed( + failures = setOf(DataLoadingFailure(DataLoadingStep.GlobalBadges, RuntimeException("test"))), + ) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt new file mode 100644 index 000000000..d6bf8536f --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/main/SuggestionReplacementTest.kt @@ -0,0 +1,95 @@ +package com.flxrs.dankchat.ui.main + +import com.flxrs.dankchat.ui.main.input.SuggestionReplacementResult +import com.flxrs.dankchat.ui.main.input.computeSuggestionReplacement +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +internal class SuggestionReplacementTest { + @Test + fun `replaces word at end of text`() { + val result = computeSuggestionReplacement("hello as", cursorPos = 8, suggestionText = "asd") + assertEquals(expected = 6, actual = result.replaceStart) + assertEquals(expected = 8, actual = result.replaceEnd) + assertEquals(expected = "asd ", actual = result.replacement) + assertEquals(expected = 10, actual = result.newCursorPos) + + val newText = "hello as".replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "hello asd ", actual = newText) + } + + @Test + fun `replaces typed portion at beginning, preserves text after cursor`() { + // "asdasd asdasd" -> typing "asd" before existing text + val text = "asdasd asdasd" + val result = computeSuggestionReplacement(text, cursorPos = 3, suggestionText = "asd") + assertEquals(expected = 0, actual = result.replaceStart) + assertEquals(expected = 3, actual = result.replaceEnd) + assertEquals(expected = "asd ", actual = result.replacement) + + val newText = text.replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "asd asd asdasd", actual = newText) + assertEquals(expected = 4, actual = result.newCursorPos) + } + + @Test + fun `replaces typed portion mid-text`() { + // "hello as world" + val text = "hello as world" + val result = computeSuggestionReplacement(text, cursorPos = 8, suggestionText = "asd") + + val newText = text.replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "hello asd world", actual = newText) + assertEquals(expected = 10, actual = result.newCursorPos) + } + + @Test + fun `replaces at cursor position 0 with no preceding text`() { + val text = "existing" + val result = computeSuggestionReplacement(text, cursorPos = 0, suggestionText = "new") + assertEquals(expected = 0, actual = result.replaceStart) + assertEquals(expected = 0, actual = result.replaceEnd) + assertEquals(expected = "new ", actual = result.replacement) + + val newText = text.replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "new existing", actual = newText) + } + + @Test + fun `replaces at-mention typed mid-text`() { + // "hello @us more text" + val text = "hello @us more text" + val result = computeSuggestionReplacement(text, cursorPos = 9, suggestionText = "@user123") + + val newText = text.replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "hello @user123 more text", actual = newText) + assertEquals(expected = 15, actual = result.newCursorPos) + } + + @Test + fun `single word fully typed at end`() { + val result = computeSuggestionReplacement("Kappa", cursorPos = 5, suggestionText = "Kappa") + + val newText = "Kappa".replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "Kappa ", actual = newText) + assertEquals(expected = 6, actual = result.newCursorPos) + } + + @Test + fun `replacement includes trailing space`() { + val result = computeSuggestionReplacement("he", cursorPos = 2, suggestionText = "hello") + assertEquals(expected = "hello ", actual = result.replacement) + } + + @Test + fun `cursor after space replaces nothing before suggestion`() { + // "hello world" — cursor right after space, no typed chars + val text = "hello world" + val result = computeSuggestionReplacement(text, cursorPos = 6, suggestionText = "emote") + assertEquals(expected = 6, actual = result.replaceStart) + assertEquals(expected = 6, actual = result.replaceEnd) + + val newText = text.replaceRange(result.replaceStart, result.replaceEnd, result.replacement) + assertEquals(expected = "hello emote world", actual = newText) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt new file mode 100644 index 000000000..d12e7c612 --- /dev/null +++ b/app/src/test/kotlin/com/flxrs/dankchat/ui/tour/FeatureTourViewModelTest.kt @@ -0,0 +1,447 @@ +package com.flxrs.dankchat.ui.tour + +import app.cash.turbine.test +import com.flxrs.dankchat.data.auth.StartupValidation +import com.flxrs.dankchat.data.auth.StartupValidationHolder +import com.flxrs.dankchat.ui.onboarding.OnboardingDataStore +import com.flxrs.dankchat.ui.onboarding.OnboardingSettings +import io.mockk.coEvery +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockKExtension::class) +internal class FeatureTourViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() + private val settingsFlow = MutableStateFlow(OnboardingSettings()) + private val onboardingDataStore: OnboardingDataStore = mockk() + + private lateinit var viewModel: FeatureTourViewModel + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + every { onboardingDataStore.settings } returns settingsFlow + every { onboardingDataStore.current() } answers { settingsFlow.value } + + coEvery { onboardingDataStore.update(any()) } coAnswers { + val transform = firstArg OnboardingSettings>() + settingsFlow.value = transform(settingsFlow.value) + } + + val startupValidationHolder = StartupValidationHolder().apply { update(StartupValidation.Validated) } + viewModel = FeatureTourViewModel(onboardingDataStore, startupValidationHolder) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + // -- Post-onboarding step resolution -- + + @Test + fun `initial state is Idle when onboarding not completed`() = runTest(testDispatcher) { + viewModel.uiState.test { + assertEquals(PostOnboardingStep.Idle, awaitItem().postOnboardingStep) + } + } + + @Test + fun `step is Idle when onboarding complete but channels empty`() = runTest(testDispatcher) { + viewModel.uiState.test { + assertEquals(PostOnboardingStep.Idle, awaitItem().postOnboardingStep) + + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = true, ready = true) + + // State stays Idle — StateFlow deduplicates, no new emission + expectNoEvents() + } + } + + @Test + fun `step is Idle when channels not ready`() = runTest(testDispatcher) { + viewModel.uiState.test { + assertEquals(PostOnboardingStep.Idle, awaitItem().postOnboardingStep) + + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = false) + + // State stays Idle — StateFlow deduplicates, no new emission + expectNoEvents() + } + } + + @Test + fun `step is ToolbarPlusHint when onboarding complete and channels ready`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + + assertEquals(PostOnboardingStep.ToolbarPlusHint, expectMostRecentItem().postOnboardingStep) + } + } + + @Test + fun `step is FeatureTour after toolbar hint dismissed`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + viewModel.onToolbarHintDismissed() + + assertEquals(PostOnboardingStep.FeatureTour, expectMostRecentItem().postOnboardingStep) + } + } + + @Test + fun `step is Complete when tour version is current and toolbar hint done`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { + it.copy( + hasCompletedOnboarding = true, + hasShownToolbarHint = true, + featureTourVersion = CURRENT_TOUR_VERSION, + ) + } + viewModel.onChannelsChanged(empty = false, ready = true) + + assertEquals(PostOnboardingStep.Complete, expectMostRecentItem().postOnboardingStep) + } + } + + @Test + fun `existing user migration skips toolbar hint but shows tour`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { + it.copy( + hasCompletedOnboarding = true, + hasShownToolbarHint = true, + featureTourVersion = 0, + ) + } + viewModel.onChannelsChanged(empty = false, ready = true) + + assertEquals(PostOnboardingStep.FeatureTour, expectMostRecentItem().postOnboardingStep) + } + } + + // -- Tour lifecycle -- + + @Test + fun `startTour activates tour at first step`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + + val state = expectMostRecentItem() + assertTrue(state.isTourActive) + assertEquals(TourStep.InputActions, state.currentTourStep) + } + } + + @Test + fun `startTour is idempotent when already active`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // move to OverflowMenu + viewModel.startTour() // should be no-op + + assertEquals(TourStep.OverflowMenu, expectMostRecentItem().currentTourStep) + } + } + + @Test + fun `advance progresses through all steps in order`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + assertEquals(TourStep.InputActions, expectMostRecentItem().currentTourStep) + + viewModel.advance() + assertEquals(TourStep.OverflowMenu, expectMostRecentItem().currentTourStep) + + viewModel.advance() + assertEquals(TourStep.ConfigureActions, expectMostRecentItem().currentTourStep) + + viewModel.advance() + assertEquals(TourStep.SwipeGesture, expectMostRecentItem().currentTourStep) + + viewModel.advance() + assertEquals(TourStep.RecoveryFab, expectMostRecentItem().currentTourStep) + } + } + + @Test + fun `advance past last step completes tour`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } + + val state = expectMostRecentItem() + assertFalse(state.isTourActive) + assertNull(state.currentTourStep) + assertEquals(PostOnboardingStep.Complete, state.postOnboardingStep) + } + } + + @Test + fun `skipTour completes immediately`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // at OverflowMenu + + viewModel.skipTour() + + val state = expectMostRecentItem() + assertFalse(state.isTourActive) + assertNull(state.currentTourStep) + assertEquals(PostOnboardingStep.Complete, state.postOnboardingStep) + } + } + + @Test + fun `startTour after completion is no-op`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } + + viewModel.startTour() + + assertFalse(expectMostRecentItem().isTourActive) + } + } + + // -- Persistence -- + + @Test + fun `completeTour persists tour version and clears step`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } + cancelAndIgnoreRemainingEvents() + } + + val persisted = settingsFlow.value + assertEquals(CURRENT_TOUR_VERSION, persisted.featureTourVersion) + assertEquals(0, persisted.featureTourStep) + assertTrue(persisted.hasShownToolbarHint) + } + + @Test + fun `advance persists current step index`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // step 1 + cancelAndIgnoreRemainingEvents() + } + + assertEquals(1, settingsFlow.value.featureTourStep) + } + + @Test + fun `skipTour before tour starts completes everything`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + // Currently at ToolbarPlusHint + + viewModel.skipTour() + + assertEquals(PostOnboardingStep.Complete, expectMostRecentItem().postOnboardingStep) + } + + val persisted = settingsFlow.value + assertEquals(CURRENT_TOUR_VERSION, persisted.featureTourVersion) + assertTrue(persisted.hasShownToolbarHint) + } + + // -- Toolbar hint -- + + @Test + fun `onToolbarHintDismissed is idempotent`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + + viewModel.onToolbarHintDismissed() + viewModel.onToolbarHintDismissed() // second call should be no-op + + assertEquals(PostOnboardingStep.FeatureTour, expectMostRecentItem().postOnboardingStep) + } + } + + @Test + fun `onAddedChannelFromToolbar marks hint done`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + + viewModel.onAddedChannelFromToolbar() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(settingsFlow.value.hasShownToolbarHint) + } + + // -- Side effects -- + + @Test + fun `ConfigureActions step forces overflow open`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // OverflowMenu + viewModel.advance() // ConfigureActions + + assertTrue(expectMostRecentItem().forceOverflowOpen) + } + } + + @Test + fun `SwipeGesture step clears forceOverflowOpen`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // OverflowMenu + viewModel.advance() // ConfigureActions + viewModel.advance() // SwipeGesture + + assertFalse(expectMostRecentItem().forceOverflowOpen) + } + } + + @Test + fun `RecoveryFab step sets gestureInputHidden`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + viewModel.advance() // OverflowMenu + viewModel.advance() // ConfigureActions + viewModel.advance() // SwipeGesture + viewModel.advance() // RecoveryFab + + assertTrue(expectMostRecentItem().gestureInputHidden) + } + } + + @Test + fun `tour completion clears gestureInputHidden`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + setupAndStartTour() + repeat(TourStep.entries.size) { viewModel.advance() } + + assertFalse(expectMostRecentItem().gestureInputHidden) + } + } + + // -- Resume -- + + @Test + fun `tour resumes at persisted step with correct side effects`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { + it.copy( + hasCompletedOnboarding = true, + hasShownToolbarHint = true, + featureTourVersion = 0, + featureTourStep = 2, + ) + } + viewModel.onChannelsChanged(empty = false, ready = true) + + viewModel.startTour() + + val state = expectMostRecentItem() + assertEquals(TourStep.ConfigureActions, state.currentTourStep) + assertTrue(state.forceOverflowOpen) + } + } + + @Test + fun `stale persisted step is ignored when version gap is too large`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { + it.copy( + hasCompletedOnboarding = true, + hasShownToolbarHint = true, + featureTourVersion = -1, // gap of 2 + featureTourStep = 3, + ) + } + viewModel.onChannelsChanged(empty = false, ready = true) + + viewModel.startTour() + + assertEquals(TourStep.InputActions, expectMostRecentItem().currentTourStep) + } + } + + @Test + fun `resume at RecoveryFab step sets gestureInputHidden`() = runTest(testDispatcher) { + viewModel.uiState.test { + skipItems(1) + emitSettings { + it.copy( + hasCompletedOnboarding = true, + hasShownToolbarHint = true, + featureTourVersion = 0, + featureTourStep = 4, // RecoveryFab + ) + } + viewModel.onChannelsChanged(empty = false, ready = true) + + viewModel.startTour() + + val state = expectMostRecentItem() + assertEquals(TourStep.RecoveryFab, state.currentTourStep) + assertTrue(state.gestureInputHidden) + } + } + + // -- Helpers -- + + private fun setupAndStartTour() { + emitSettings { it.copy(hasCompletedOnboarding = true) } + viewModel.onChannelsChanged(empty = false, ready = true) + viewModel.onToolbarHintDismissed() + viewModel.startTour() + } + + private fun emitSettings(transform: (OnboardingSettings) -> OnboardingSettings) { + settingsFlow.value = transform(settingsFlow.value) + } +} diff --git a/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt b/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt index 015262159..f44e1f9b2 100644 --- a/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt +++ b/app/src/test/kotlin/com/flxrs/dankchat/utils/DateTimeUtilsTest.kt @@ -1,12 +1,16 @@ package com.flxrs.dankchat.utils +import com.flxrs.dankchat.utils.DateTimeUtils.DurationPart +import com.flxrs.dankchat.utils.DateTimeUtils.DurationUnit.DAYS +import com.flxrs.dankchat.utils.DateTimeUtils.DurationUnit.HOURS +import com.flxrs.dankchat.utils.DateTimeUtils.DurationUnit.MINUTES +import com.flxrs.dankchat.utils.DateTimeUtils.DurationUnit.SECONDS +import com.flxrs.dankchat.utils.DateTimeUtils.DurationUnit.WEEKS import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNull - internal class DateTimeUtilsTest { - @Test fun `formats 10 seconds correctly`() { val result = DateTimeUtils.formatSeconds(10) @@ -102,4 +106,76 @@ internal class DateTimeUtilsTest { val result = DateTimeUtils.durationToSeconds("3s 1h 4d 5m") assertEquals(expected = 349503, actual = result) } -} \ No newline at end of file + + @Test + fun `decomposes 30 minutes`() { + val result = DateTimeUtils.decomposeMinutes(30) + assertEquals(expected = listOf(DurationPart(30, MINUTES)), actual = result) + } + + @Test + fun `decomposes 60 minutes to 1 hour`() { + val result = DateTimeUtils.decomposeMinutes(60) + assertEquals(expected = listOf(DurationPart(1, HOURS)), actual = result) + } + + @Test + fun `decomposes 90 minutes to 1 hour 30 minutes`() { + val result = DateTimeUtils.decomposeMinutes(90) + assertEquals(expected = listOf(DurationPart(1, HOURS), DurationPart(30, MINUTES)), actual = result) + } + + @Test + fun `decomposes 1440 minutes to 1 day`() { + val result = DateTimeUtils.decomposeMinutes(1440) + assertEquals(expected = listOf(DurationPart(1, DAYS)), actual = result) + } + + @Test + fun `decomposes 10080 minutes to 1 week`() { + val result = DateTimeUtils.decomposeMinutes(10080) + assertEquals(expected = listOf(DurationPart(1, WEEKS)), actual = result) + } + + @Test + fun `decomposes 20160 minutes to 2 weeks`() { + val result = DateTimeUtils.decomposeMinutes(20160) + assertEquals(expected = listOf(DurationPart(2, WEEKS)), actual = result) + } + + @Test + fun `decomposes 11520 minutes to 1 week 1 day`() { + val result = DateTimeUtils.decomposeMinutes(11520) + assertEquals(expected = listOf(DurationPart(1, WEEKS), DurationPart(1, DAYS)), actual = result) + } + + @Test + fun `decomposes 0 minutes to empty list`() { + val result = DateTimeUtils.decomposeMinutes(0) + assertEquals(expected = emptyList(), actual = result) + } + + @Test + fun `decomposes 30 seconds`() { + val result = DateTimeUtils.decomposeSeconds(30) + assertEquals(expected = listOf(DurationPart(30, SECONDS)), actual = result) + } + + @Test + fun `decomposes 60 seconds to 1 minute`() { + val result = DateTimeUtils.decomposeSeconds(60) + assertEquals(expected = listOf(DurationPart(1, MINUTES)), actual = result) + } + + @Test + fun `decomposes 125 seconds to 2 minutes 5 seconds`() { + val result = DateTimeUtils.decomposeSeconds(125) + assertEquals(expected = listOf(DurationPart(2, MINUTES), DurationPart(5, SECONDS)), actual = result) + } + + @Test + fun `decomposes 0 seconds to empty list`() { + val result = DateTimeUtils.decomposeSeconds(0) + assertEquals(expected = emptyList(), actual = result) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 856650337..52468c272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,17 @@ +buildscript { + dependencies { + classpath(libs.kotlin.gradle) + } +} + plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.compose) apply false - alias(libs.plugins.nav.safeargs.kotlin) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.about.libraries.android) apply false + alias(libs.plugins.android.junit5) apply false + alias(libs.plugins.spotless) apply false + alias(libs.plugins.detekt) apply false } diff --git a/gradle.properties b/gradle.properties index 9c325d502..e47418fbc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,8 @@ android.useAndroidX=true kotlin.code.style=official -android.enableJetifier=false org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true org.gradle.configuration-cache=true org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC kotlin.daemon.jvmargs=-Xmx2g -android.nonTransitiveRClass=false -android.nonFinalResIds=false -kapt.use.k2=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..a5a00fbe2 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3426ffcaa54c3f62406beb1f1ab8b179/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d6690dfd71c4c91e08577437b5b2beb0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/4945f00643ec68e7c7a6b66f90124f89/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3cd7045fca9a72cd9bc7d14a385e594c/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/552c7bffe0370c66410a51c55985b511/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7a35e2ff..cf70ae1da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,11 +8,11 @@ ktor = "3.4.2" coil = "3.4.0" okhttp = "5.3.2" ksp = "2.3.6" -koin = "4.1.1" -koin-annotations = "2.3.1" -about-libraries = "13.2.1" +koin = "4.2.0" +koin-compiler-plugin = "0.6.2" +about-libraries = "14.0.0" -androidGradlePlugin = "8.13.2" +androidGradlePlugin = "9.1.0" androidDesugarLibs = "2.1.5" androidxActivity = "1.13.0" androidxBrowser = "1.10.0" @@ -33,23 +33,41 @@ androidxDataStore = "1.2.1" compose = "1.10.6" compose-icons = "1.7.8" compose-materia3 = "1.5.0-alpha16" +compose-material3-adaptive = "1.2.0" compose-unstyled = "1.49.6" -material = "1.13.0" +appcompat = "1.7.0" +splashscreen = "1.0.1" flexBox = "3.0.0" -autoLinkText = "2.0.2" +autoLinkText = "2.0.2" + +logbackAndroid = "3.0.0" +kotlinLogging = "7.0.7" processPhoenix = "3.0.0" colorPicker = "3.1.0" +materialKolor = "4.1.1" +reorderable = "2.4.3" + +spotless = "8.4.0" +ktlint = "1.8.0" +detekt = "main-SNAPSHOT" # TODO switch to stable alpha.3+ when released +composeRules = "0.5.6" junit = "6.0.3" +androidJunit5 = "2.0.1" mockk = "1.14.9" +turbine = "1.2.0" [libraries] android-desugar-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "androidDesugarLibs" } -android-material = { module = "com.google.android.material:material", version.ref = "material" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" } android-flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexBox" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } + +kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } @@ -93,14 +111,14 @@ compose-icons-extended = { module = "androidx.compose.material:material-icons-ex compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-unstyled = { module = "com.composables:core", version.ref = "compose-unstyled" } +compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "compose-material3-adaptive" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core" } koin-android = { module = "io.insert-koin:koin-android" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } -koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-annotations" } -koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-annotations" } +koin-annotations = { module = "io.insert-koin:koin-annotations" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -116,18 +134,27 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver3-junit5", version.ref = "okhttp" } colorpicker-android = { module = "com.github.martin-stone:hsv-alpha-color-picker-android", version.ref = "colorPicker" } +materialkolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } autolinktext = { module = "sh.calvin.autolinktext:autolinktext", version.ref = "autoLinkText" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logbackAndroid" } +kotlin-logging = { module = "io.github.oshai:kotlin-logging-jvm", version.ref = "kotlinLogging" } + process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "about-libraries" } +detekt-compose-rules = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRules" } + junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -137,5 +164,9 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } nav-safeargs-kotlin = { id = "androidx.navigation.safeargs.kotlin", version.ref = "androidxNavigation" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-compiler-plugin" } about-libraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "about-libraries" } +android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "androidJunit5" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +detekt = { id = "dev.detekt", version.ref = "detekt" } androidx-room = { id = "androidx.room", version.ref = "androidxRoom" } #TODO use me when working diff --git a/scripts/update_emoji_data.py b/scripts/update_emoji_data.py new file mode 100644 index 000000000..ea65c32ea --- /dev/null +++ b/scripts/update_emoji_data.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Downloads emoji data from iamcal/emoji-data and generates a stripped-down +JSON resource for DankChat's emoji shortcode suggestions. + +Output: app/src/main/res/raw/emoji_data.json +Source: https://github.com/iamcal/emoji-data (Emoji 17.0 / Unicode 17.0) + +Usage: python3 scripts/update_emoji_data.py +""" + +import json +import os +import urllib.request + +# Using Nerixyz fork until upstream PR is merged: https://github.com/iamcal/emoji-data/pull/255 +# Switch back to iamcal/emoji-data/master once merged. +EMOJI_DATA_URL = "https://raw.githubusercontent.com/Nerixyz/emoji-data/feat/17-0/emoji.json" +OUTPUT_PATH = os.path.join(os.path.dirname(__file__), "..", "app", "src", "main", "res", "raw", "emoji_data.json") + + +def main(): + print(f"Downloading emoji data from {EMOJI_DATA_URL}...") + with urllib.request.urlopen(EMOJI_DATA_URL) as response: + data = json.loads(response.read()) + + print(f"Loaded {len(data)} emoji entries") + + entries = [] + for emoji in data: + category = emoji.get("category", "") + if category == "Component": + continue + + codes = emoji["unified"].split("-") + unicode_char = "".join(chr(int(c, 16)) for c in codes) + + for shortcode in emoji.get("short_names", []): + entries.append({"code": shortcode, "unicode": unicode_char}) + + entries.sort(key=lambda e: e["code"]) + + output_path = os.path.normpath(OUTPUT_PATH) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(entries, f, ensure_ascii=False, separators=(",", ":")) + + size_kb = os.path.getsize(output_path) / 1024 + print(f"Written {len(entries)} entries to {output_path} ({size_kb:.1f} KB)") + + +if __name__ == "__main__": + main() diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d69b7084..c253021b6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,19 +1,29 @@ -@file:Suppress("UnstableApiUsage") - pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() maven(url = "https://jitpack.io") + maven(url = "https://central.sonatype.com/repository/maven-snapshots/") + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "dev.detekt") { + useModule("dev.detekt:detekt-gradle-plugin:${requested.version}") + } + } } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven(url = "https://jitpack.io") + maven(url = "https://central.sonatype.com/repository/maven-snapshots/") } }